* feat(design): redesign das páginas internas no novo design system Padroniza todas as páginas e componentes internos nos tokens e primitivos de app/globals.css, com contraste AAA (7:1) nos dois temas e alvos de toque de 44px. - Páginas reescritas: glossário, séries, série, seletor, comparador, diagnóstico, catálogo, component registry, frameworks, framework, sobre e a nova 404. - Componentes: PageHero e SectionHead novos; BoardCard, CodeExample, PinRestrictions, SeriesTabMenu e ConnectionsDiagram refeitos, com abas e diálogo acessíveis. - Remove emojis, cores avulsas por série, caixa alta com tracking, texto menor que 12px e os pares slate-*/dark:*. - Comparador: "mostrar só as diferenças" e leitura de ?chips=. - Corrige overflow em 320/390px e ajusta accent/copper do tema claro para fechar 7:1. - scripts/auditar-temas.mjs passa a travar paleta crua, dark:, texto pequeno, emoji e contraste dos tokens. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016W1xmizFcmXLrmabSvfSuc * ci: roda lint, auditorias de design e build em cada PR Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016W1xmizFcmXLrmabSvfSuc --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
470 lines
19 KiB
React
470 lines
19 KiB
React
"use client";
|
|
|
|
import { useState, useMemo, useRef } from "react";
|
|
import Header from "@/components/Header";
|
|
import Footer from "@/components/Footer";
|
|
import PageHero from "@/components/PageHero";
|
|
import {
|
|
categoriasDiagnostico,
|
|
sintomasRapidos,
|
|
errosCatalogo,
|
|
regrasDecodificadorLog,
|
|
} from "@/lib/diagnosticoData";
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
Check,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
Copy,
|
|
Cpu,
|
|
ExternalLink,
|
|
HelpCircle,
|
|
Layers,
|
|
LayoutGrid,
|
|
RotateCcw,
|
|
Search,
|
|
ShieldAlert,
|
|
Terminal,
|
|
Wifi,
|
|
Wrench,
|
|
X,
|
|
XCircle,
|
|
Zap,
|
|
} from "lucide-react";
|
|
|
|
/** Os rótulos dos sintomas vêm com emoji nos dados; a interface usa só texto e ícones. */
|
|
const semEmoji = (texto) => texto.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "");
|
|
|
|
const ICONES = { Zap, Cpu, Layers, Activity, Terminal, Wifi };
|
|
|
|
const EXEMPLOS_LOG = [
|
|
["Brownout", "Brownout detector was triggered\n\nrst:0xc (SW_CPU_RESET),boot:0x13"],
|
|
["LoadProhibited", "Guru Meditation Error: Core 0 panic'ed (LoadProhibited).\nEXCVADDR: 0x00000000"],
|
|
["Task watchdog", "Task watchdog got triggered. The following tasks/users did not reset the watchdog: - IDLE0"],
|
|
["Handshake TLS", "mbedtls_ssl_handshake returned -0x2700\nThe certificate is not yet valid"],
|
|
];
|
|
|
|
const GRAVIDADE = {
|
|
"Crítica": "text-danger",
|
|
Alta: "text-gold",
|
|
};
|
|
|
|
const CHECKLISTS = [
|
|
{
|
|
icon: Zap,
|
|
titulo: "Hardware e alimentação",
|
|
itens: [
|
|
"Capacitor de 10 µF a 100 µF de baixo ESR no VCC, mais um cerâmico de 100 nF junto ao chip.",
|
|
"LDO dimensionado para picos de 600 mA a 1 A, com folga térmica.",
|
|
"Nenhum periférico forçando nível lógico nos pinos de strapping durante o boot.",
|
|
"Capacitor de 10 µF ou 1 µF no pino EN (RST) para auto-reset estável na gravação.",
|
|
],
|
|
},
|
|
{
|
|
icon: Layers,
|
|
titulo: "Firmware e FreeRTOS",
|
|
itens: [
|
|
"Toda tarefa FreeRTOS cede a CPU com vTaskDelay, filas ou semáforos.",
|
|
"Toda ISR marcada com IRAM_ATTR e sem chamadas bloqueantes.",
|
|
"Sem uso contínuo de String do Arduino em laços, para evitar fragmentação do heap.",
|
|
"Horário sincronizado por SNTP antes de abrir conexões TLS/HTTPS.",
|
|
],
|
|
},
|
|
];
|
|
|
|
export default function DiagnosticoPage() {
|
|
const [termoBusca, setTermoBusca] = useState("");
|
|
const [categoriaAtiva, setCategoriaAtiva] = useState("todos");
|
|
const [sintomaAtivo, setSintomaAtivo] = useState(null);
|
|
const [cardAberto, setCardAberto] = useState(null);
|
|
const [linguagemCodigo, setLinguagemCodigo] = useState({}); // { [id]: 'arduino' | 'espidf' }
|
|
const [copiadoId, setCopiadoId] = useState(null);
|
|
const searchInputRef = useRef(null);
|
|
|
|
// Estado do Analisador de Logs
|
|
const [logInput, setLogInput] = useState("");
|
|
const [resultadoLog, setResultadoLog] = useState(null);
|
|
|
|
// Função de Análise do Log
|
|
const analisarLog = (texto) => {
|
|
setLogInput(texto);
|
|
if (!texto || texto.trim() === "") {
|
|
setResultadoLog(null);
|
|
return;
|
|
}
|
|
|
|
for (const regra of regrasDecodificadorLog) {
|
|
if (regra.padrao.test(texto)) {
|
|
setResultadoLog(regra);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setResultadoLog({
|
|
titulo: "Padrão de erro não categorizado automaticamente",
|
|
tipo: "Desconhecido",
|
|
explicacao:
|
|
"Não identificamos uma assinatura clássica de Guru Meditation ou Brownout neste trecho. Verifique se a taxa de baud rate (ex: 115200) está correta e se o cabeçalho 'rst:' ou 'panic' está presente.",
|
|
acaoRecomendada:
|
|
"Procure por palavras-chave como 'Guru Meditation', 'rst:', 'LoadProhibited', 'watchdog' ou 'panic' no console serial.",
|
|
});
|
|
};
|
|
|
|
const copiarParaClipboard = (texto, id) => {
|
|
navigator.clipboard.writeText(texto);
|
|
setCopiadoId(id);
|
|
setTimeout(() => setCopiadoId(null), 2000);
|
|
};
|
|
|
|
// Filtragem dos erros
|
|
const errosFiltrados = useMemo(() => {
|
|
const termo = termoBusca.toLowerCase().trim();
|
|
return errosCatalogo.filter((erro) => {
|
|
const matchCategoria =
|
|
categoriaAtiva === "todos" || erro.categoria === categoriaAtiva;
|
|
const matchSintoma =
|
|
!sintomaAtivo || erro.sintomas.includes(sintomaAtivo);
|
|
const matchBusca =
|
|
!termo ||
|
|
erro.titulo.toLowerCase().includes(termo) ||
|
|
erro.resumo.toLowerCase().includes(termo) ||
|
|
erro.causaRaiz.toLowerCase().includes(termo) ||
|
|
erro.logSerial.toLowerCase().includes(termo);
|
|
|
|
return matchCategoria && matchSintoma && matchBusca;
|
|
});
|
|
}, [categoriaAtiva, sintomaAtivo, termoBusca]);
|
|
|
|
const temFiltroAtivo =
|
|
termoBusca.trim() !== "" ||
|
|
categoriaAtiva !== "todos" ||
|
|
sintomaAtivo !== null;
|
|
|
|
const limparTodosFiltros = () => {
|
|
setTermoBusca("");
|
|
setCategoriaAtiva("todos");
|
|
setSintomaAtivo(null);
|
|
searchInputRef.current?.focus();
|
|
};
|
|
|
|
const toggleCard = (id) => {
|
|
setCardAberto((prev) => (prev === id ? null : id));
|
|
};
|
|
|
|
const setLang = (id, lang) => {
|
|
setLinguagemCodigo((prev) => ({ ...prev, [id]: lang }));
|
|
};
|
|
|
|
|
|
const totalFiltros = (termoBusca.trim() ? 1 : 0) + (categoriaAtiva !== "todos" ? 1 : 0) + (sintomaAtivo ? 1 : 0);
|
|
|
|
return (
|
|
<div className="site-canvas min-h-screen">
|
|
<Header />
|
|
|
|
<main id="conteudo">
|
|
<PageHero
|
|
trail={[{ label: "Diagnóstico" }]}
|
|
icon={AlertTriangle}
|
|
label="Depuração de firmware"
|
|
title="Diagnóstico de falhas do ESP32"
|
|
lede="Entenda a causa de Brownout, Guru Meditation, Watchdog e Stack Overflow, e aplique a correção de engenharia, não o remendo."
|
|
>
|
|
<a
|
|
href="https://cienciaembarcada.com.br/publicacoes/esp32-5-erros-comuns-e-como-resolve-los-profissionalmente/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-strong"
|
|
>
|
|
Baseado no artigo do Ciência Embarcada
|
|
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
|
<span className="sr-only"> (abre em nova aba)</span>
|
|
</a>
|
|
</PageHero>
|
|
|
|
<section className="shell pt-10 sm:pt-12" aria-labelledby="decodificador-titulo">
|
|
<div className="panel p-5 sm:p-8">
|
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
|
|
<div className="max-w-xl">
|
|
<h2 id="decodificador-titulo" className="section-title">Cole o log e veja a causa</h2>
|
|
<p className="mt-2 text-sm text-muted">Copie o trecho do monitor serial. O diagnóstico aparece na hora.</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-sm text-muted">Testar com</span>
|
|
{EXEMPLOS_LOG.map(([rotulo, texto]) => (
|
|
<button key={rotulo} type="button" onClick={() => analisarLog(texto)} className="chip-btn">
|
|
{rotulo}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative mt-5">
|
|
<label htmlFor="log-serial" className="sr-only">Log do monitor serial</label>
|
|
<textarea
|
|
id="log-serial"
|
|
rows={4}
|
|
value={logInput}
|
|
onChange={(e) => analisarLog(e.target.value)}
|
|
placeholder="Cole o log do monitor serial aqui"
|
|
spellCheck={false}
|
|
className="code-panel field !min-h-32 w-full resize-y !rounded-xl !border-white/10 !bg-[var(--code-bg)] p-4 font-mono text-sm !text-[#e9eef4] placeholder:!text-[#a6afbd]"
|
|
/>
|
|
{logInput && (
|
|
<button type="button" onClick={() => analisarLog("")} className="absolute right-3 top-3 inline-flex min-h-11 items-center gap-1 rounded-full border border-white/15 px-3 text-xs font-medium text-[#e9eef4] hover:bg-white/10">
|
|
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
|
Limpar
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div role="status" aria-live="polite">
|
|
{resultadoLog && (
|
|
<div className="callout callout-warn mt-4 flex items-start gap-3">
|
|
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-gold" aria-hidden="true" />
|
|
<div className="min-w-0 space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="font-semibold text-ink">{resultadoLog.titulo}</h3>
|
|
<span className="chip">{resultadoLog.tipo}</span>
|
|
</div>
|
|
<p><strong className="font-semibold text-ink">Causa provável:</strong> {resultadoLog.explicacao}</p>
|
|
<p><strong className="font-semibold text-ink">O que fazer:</strong> {resultadoLog.acaoRecomendada}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="shell section pt-12 sm:pt-14" aria-labelledby="catalogo-titulo">
|
|
<h2 id="catalogo-titulo" className="section-title">Catálogo de falhas</h2>
|
|
|
|
<div className="panel mt-6 p-5 sm:p-6">
|
|
<div className="relative">
|
|
<label htmlFor="busca-erro" className="sr-only">Buscar por erro, sintoma ou mensagem serial</label>
|
|
<Search className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted" aria-hidden="true" />
|
|
<input
|
|
id="busca-erro"
|
|
ref={searchInputRef}
|
|
type="search"
|
|
value={termoBusca}
|
|
onChange={(e) => setTermoBusca(e.target.value)}
|
|
placeholder="Buscar erro, sintoma ou mensagem"
|
|
autoComplete="off"
|
|
className="field pl-12 pr-4 text-base"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-5">
|
|
<h3 className="text-sm font-semibold text-ink">Sintoma</h3>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{sintomasRapidos.map((sintoma) => (
|
|
<button key={sintoma.id} type="button" onClick={() => setSintomaAtivo(sintomaAtivo === sintoma.id ? null : sintoma.id)} aria-pressed={sintomaAtivo === sintoma.id} className="chip-btn">
|
|
{semEmoji(sintoma.rotulo)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 border-t border-line pt-5">
|
|
<h3 className="text-sm font-semibold text-ink">Área</h3>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => setCategoriaAtiva("todos")} aria-pressed={categoriaAtiva === "todos"} className="chip-btn">
|
|
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
|
|
Todas
|
|
<span className="tabular-nums opacity-80">{errosCatalogo.length}</span>
|
|
</button>
|
|
{categoriasDiagnostico.map((cat) => {
|
|
const Icone = ICONES[cat.icone] ?? AlertTriangle;
|
|
const total = errosCatalogo.filter((e) => e.categoria === cat.id).length;
|
|
return (
|
|
<button key={cat.id} type="button" onClick={() => setCategoriaAtiva(cat.id)} aria-pressed={categoriaAtiva === cat.id} className="chip-btn">
|
|
<Icone className="h-4 w-4" aria-hidden="true" />
|
|
{cat.nome.split(/ &|,/)[0]}
|
|
<span className="tabular-nums opacity-80">{total}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
|
<p role="status" aria-live="polite" className="text-sm font-semibold text-ink">
|
|
{errosFiltrados.length} {errosFiltrados.length === 1 ? "falha" : "falhas"}
|
|
{totalFiltros > 0 && <span className="font-normal text-muted"> com os filtros atuais</span>}
|
|
</p>
|
|
{temFiltroAtivo && (
|
|
<button type="button" onClick={limparTodosFiltros} className="inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-strong">
|
|
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
|
Redefinir filtros
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-3">
|
|
{errosFiltrados.length === 0 ? (
|
|
<div className="card px-6 py-14 text-center">
|
|
<HelpCircle className="mx-auto h-10 w-10 text-muted" aria-hidden="true" />
|
|
<h3 className="mt-4 font-display text-xl font-semibold text-ink">Nenhuma falha com esses filtros</h3>
|
|
<p className="mx-auto mt-2 max-w-md text-sm text-muted">Busque por um termo mais geral ou remova o sintoma e a área selecionados.</p>
|
|
<button type="button" onClick={limparTodosFiltros} className="btn btn-primary mt-6">Limpar filtros</button>
|
|
</div>
|
|
) : (
|
|
errosFiltrados.map((erro) => {
|
|
const estaAberto = cardAberto === erro.id;
|
|
const lang = linguagemCodigo[erro.id] || "arduino";
|
|
const codigoAtual =
|
|
lang === "arduino" ? erro.codigoArduino : lang === "espidf" ? erro.codigoEspIdf : erro.codigoMicroPython || "# Exemplo não aplicável";
|
|
const catInfo = categoriasDiagnostico.find((c) => c.id === erro.categoria);
|
|
const IconeCat = ICONES[catInfo?.icone] ?? AlertTriangle;
|
|
|
|
return (
|
|
<article key={erro.id} id={erro.id} className={`card scroll-mt-28 overflow-hidden ${estaAberto ? "border-line-strong" : ""}`}>
|
|
<h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleCard(erro.id)}
|
|
aria-expanded={estaAberto}
|
|
aria-controls={`detalhe-${erro.id}`}
|
|
className="flex w-full cursor-pointer items-start justify-between gap-4 p-5 text-left sm:p-6"
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="flex flex-wrap items-center gap-2">
|
|
<span className="chip">
|
|
<IconeCat className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
|
{catInfo?.nome}
|
|
</span>
|
|
<span className={`chip ${GRAVIDADE[erro.gravidade] ?? "text-accent"}`}>Gravidade {erro.gravidade.toLowerCase()}</span>
|
|
</span>
|
|
<span className="mt-3 block font-display text-xl font-semibold tracking-[-0.03em] text-ink">{erro.titulo}</span>
|
|
<span className="mt-1.5 block text-sm font-normal leading-relaxed text-muted">{erro.resumo}</span>
|
|
</span>
|
|
<span className="mt-1 grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface-2 text-muted">
|
|
<ChevronDown className={`h-4 w-4 transition-transform ${estaAberto ? "rotate-180" : ""}`} aria-hidden="true" />
|
|
</span>
|
|
</button>
|
|
</h3>
|
|
|
|
{estaAberto && (
|
|
<div id={`detalhe-${erro.id}`} className="space-y-6 border-t border-line p-5 sm:p-6">
|
|
<div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-ink">
|
|
<Terminal className="h-4 w-4 text-accent" aria-hidden="true" />
|
|
Como aparece no monitor serial
|
|
</h4>
|
|
<button type="button" onClick={() => copiarParaClipboard(erro.logSerial, `log-${erro.id}`)} className="inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-strong">
|
|
{copiadoId === `log-${erro.id}` ? <Check className="h-4 w-4 text-ok" aria-hidden="true" /> : <Copy className="h-4 w-4" aria-hidden="true" />}
|
|
{copiadoId === `log-${erro.id}` ? "Copiado" : "Copiar log"}
|
|
</button>
|
|
</div>
|
|
<div className="code-panel mt-1">
|
|
<pre tabIndex={0}><code className="text-[#e9eef4]">{erro.logSerial}</code></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-ink">
|
|
<Cpu className="h-4 w-4 text-accent" aria-hidden="true" />
|
|
Causa raiz
|
|
</h4>
|
|
<p className="callout mt-2">{erro.causaRaiz}</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="callout callout-danger">
|
|
<p className="flex items-center gap-2 font-semibold text-ink">
|
|
<XCircle className="h-4 w-4 shrink-0 text-danger" aria-hidden="true" />
|
|
O remendo comum
|
|
</p>
|
|
<p className="mt-2 font-medium text-ink">{erro.gambiarra.titulo}</p>
|
|
<p className="mt-1">{erro.gambiarra.descricao}</p>
|
|
<p className="mt-2"><strong className="font-semibold text-danger">Consequência:</strong> {erro.gambiarra.consequencia}</p>
|
|
</div>
|
|
|
|
<div className="callout callout-ok">
|
|
<p className="flex items-center gap-2 font-semibold text-ink">
|
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-ok" aria-hidden="true" />
|
|
A solução de engenharia
|
|
</p>
|
|
<p className="mt-2 font-medium text-ink">{erro.solucaoEngenharia.titulo}</p>
|
|
<ul className="mt-2 space-y-1.5">
|
|
{erro.solucaoEngenharia.passos.map((passo) => (
|
|
<li key={passo} className="flex items-start gap-2">
|
|
<Check className="mt-1 h-3.5 w-3.5 shrink-0 text-ok" aria-hidden="true" />
|
|
{passo}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div role="group" aria-label="Linguagem do exemplo" className="flex flex-wrap gap-2">
|
|
{[
|
|
["arduino", "Arduino C++"],
|
|
["espidf", "ESP-IDF (C)"],
|
|
["micropython", "MicroPython"],
|
|
].map(([id, rotulo]) => (
|
|
<button key={id} type="button" onClick={() => setLang(erro.id, id)} aria-pressed={lang === id} className="chip-btn">
|
|
{rotulo}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button type="button" onClick={() => copiarParaClipboard(codigoAtual, `code-${erro.id}`)} className="inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-strong">
|
|
{copiadoId === `code-${erro.id}` ? <Check className="h-4 w-4 text-ok" aria-hidden="true" /> : <Copy className="h-4 w-4" aria-hidden="true" />}
|
|
{copiadoId === `code-${erro.id}` ? "Copiado" : "Copiar código"}
|
|
</button>
|
|
</div>
|
|
<div className="code-panel mt-3">
|
|
<pre tabIndex={0}><code className="text-[#e9eef4]">{codigoAtual}</code></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="callout callout-warn flex items-start gap-3">
|
|
<Wrench className="mt-0.5 h-4 w-4 shrink-0 text-gold" aria-hidden="true" />
|
|
<p><strong className="font-semibold text-ink">Dica de engenharia:</strong> {erro.dicaPro}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="shell pb-20 md:pb-28" aria-labelledby="checklist-titulo">
|
|
<h2 id="checklist-titulo" className="section-title">Checklist antes de ir para campo</h2>
|
|
<p className="mt-3 max-w-2xl text-base text-muted">Itens essenciais para validar no hardware e no firmware antes de entregar o produto.</p>
|
|
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
|
{CHECKLISTS.map(({ icon: Icon, titulo, itens }) => (
|
|
<div key={titulo} className="card p-5 sm:p-7">
|
|
<h3 className="flex items-center gap-3 font-display text-xl font-semibold tracking-[-0.03em] text-ink">
|
|
<span className="grid h-9 w-9 place-items-center rounded-full bg-accent-soft text-accent">
|
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
</span>
|
|
{titulo}
|
|
</h3>
|
|
<ul className="mt-5 space-y-3">
|
|
{itens.map((item) => (
|
|
<li key={item} className="flex items-start gap-3 text-sm leading-relaxed text-ink-soft">
|
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-ok" aria-hidden="true" />
|
|
{item}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|