Redesign das páginas internas no novo design system (AAA) (#2)

* 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>
This commit is contained in:
Lucas Rayan GuerraandClaude Sonnet 5.5 authored and GitHub committed 2026-10-20 16:10:15 -03:00
1 parent 4f27f5858b
commit bc9ae5c036
34 files changed
+4911 -6874

No files matched your search

+101 -200
View File
@@ -1,218 +1,119 @@
#!/usr/bin/env node
/**
* Auditor de consistência entre os temas claro e escuro.
* Auditor do sistema de design.
*
* Verifica quatro coisas nos arquivos .jsx do projeto:
* Regra-mãe: nenhuma tela inventa cor, tamanho de texto ou rótulo. Tudo vem
* dos tokens e das classes de app/globals.css, que já trocam sozinhos entre
* claro e escuro. O auditor falha (código 1) quando encontra:
*
* 1. TOM INEXISTENTE: classe usando um valor fora da escala declarada.
* Não gera CSS: o elemento herda a cor do pai.
* 2. PAR INVERTIDO: no tema escuro o texto deveria clarear (número menor)
* e o fundo/borda deveria escurecer (número maior).
* 3. CONTRASTE BAIXO: par abaixo de 4.5:1 (AA para texto).
* 4. SEM TEMA ESCURO: arquivo que pinta cor mas não tem nenhuma variante
* dark:. Foi assim que o glossário passou meses só com
* tema claro sem ninguém notar.
* 5. FAMÍLIA FORA DO PADRÃO: gray/zinc/neutral/stone no lugar de slate. Além
* de destoar, escapa das checagens acima, que olham slate.
* 6. COR FIXA INLINE: style={{ borderColor: 'rgba(...)' }} com valor literal.
* Style inline vence classe, então anula a cor do tema
* (inclusive as variantes dark: e hover:). Cor vinda do
* dado (serie.cor) é permitida, ver DESIGN.md seção 5.
* 1. PALETA CRUA: bg-slate-100, text-purple-600, border-gray-300...
* Use bg-surface-2, text-accent, border-line.
* 2. PAR dark: dark:bg-..., dark:text-... Os tokens já adaptam ao tema,
* então o par é redundante e foi como o tema escuro ficou
* inconsistente no passado. Exceção: painel que é escuro
* nos dois temas pode afinar com dark:...-[#hex] ou white/NN.
* 3. TEXTO MINÚSCULO: text-[9px] a text-[11px]. O menor tamanho é text-xs.
* 4. CAIXA ALTA COM TRACKING: uppercase / tracking-wide(r|st). Os rótulos
* do site são frases curtas em caixa normal.
* 5. EMOJI NA INTERFACE: ícones vêm do lucide-react.
* 6. COR FIXA INLINE: style={{ color: '#fff' }} anula o tema. Cor vinda de
* variável (dado de visualização) é permitida.
* 7. CONTRASTE DOS TOKENS: todo par de texto de globals.css em 7:1 (AAA),
* nos temas claro e escuro.
*
* Uso: npm run auditar-temas
* Sai com código 1 se encontrar problema. Ver DESIGN.md.
* Uso: npm run auditar-temas
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
// Escala slate: oficial do Tailwind + os meios-tons declarados em globals.css
const ESCALA = {
"50": "#f8fafc", "100": "#f1f5f9", "150": "#eaeef4", "200": "#e2e8f0",
"250": "#d6dee8", "300": "#cbd5e1", "350": "#b0bccc", "400": "#94a3b8",
"450": "#7c8ca2", "500": "#64748b", "550": "#56647a", "600": "#475569",
"650": "#3d4b5f", "700": "#334155", "750": "#283548", "800": "#1e293b",
"850": "#162032", "900": "#0f172a", "950": "#020617",
const RAIZ = new URL("..", import.meta.url).pathname;
const PASTAS = ["app", "components"];
const FAMILIAS = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
const REGRAS = [
{ id: "paleta crua", re: new RegExp(`(?<![\\w-])(?:[a-z-]+:)*(?:bg|text|border|ring|divide|from|via|to|fill|stroke|outline|decoration|placeholder|shadow)-(?:${FAMILIAS})-\\d{2,3}(?:/\\d+)?`, "g"), dica: "use um token (surface, ink, muted, line, accent, ok, danger, gold, copper)" },
{ id: "par dark:", re: /(?<![\w-])dark:(?:[a-z-]+:)*(?:bg|text|border|ring|divide|from|via|to|fill|stroke|outline|placeholder)-(?!\[#|white\/)[\w/[\]#.-]+/g, dica: "o token já muda com o tema; remova o par" },
{ id: "texto minúsculo", re: /(?<![\w-])text-\[(?:[0-9]|10|11)(?:\.\d+)?px\]/g, dica: "o menor tamanho permitido é text-xs" },
{ id: "caixa alta/tracking", re: /(?<![\w-])(?:uppercase|tracking-(?:wide|wider|widest))(?![\w-])/g, dica: "rótulo em caixa normal, sem tracking" },
{ id: "cor fixa inline", re: /style=\{\{[^}]*(?:color|background(?:Color)?|borderColor)\s*:\s*["'`](?:#|rgb|hsl)/g, dica: "style inline vence o tema; use classe de token" },
];
const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2300}-\u{23FF}]/u;
// Emoji que aparecem só dentro de comentários ou strings de teste não contam.
const SEM_COMENTARIO = (txt) => txt.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
function arquivos(dir) {
return readdirSync(dir).flatMap((nome) => {
const caminho = join(dir, nome);
if (statSync(caminho).isDirectory()) return arquivos(caminho);
return /\.(jsx|js)$/.test(nome) ? [caminho] : [];
});
}
let problemas = 0;
const relatar = (arq, linha, regra, trecho, dica) => {
problemas++;
console.log(` \x1b[31m✖\x1b[0m ${arq.replace(RAIZ, "")}:${linha} [${regra}] ${trecho} → ${dica}`);
};
const FUNDO_CLARO = "#ffffff";
const FUNDO_ESCURO = "#0f172a"; // slate-900, superfície de card no tema escuro
const AA = 4.5;
function luminancia(hexa) {
const limpo = hexa.replace("#", "");
const canal = (c) => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const [r, g, b] = [0, 2, 4].map((i) => canal(parseInt(limpo.slice(i, i + 2), 16)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contraste(a, b) {
const [maior, menor] = [luminancia(a), luminancia(b)].sort((x, y) => y - x);
return (maior + 0.05) / (menor + 0.05);
}
/** Caminha app/ e components/ recursivamente atrás de .jsx. */
function arquivos() {
const achados = [];
const anda = (dir) => {
let entradas;
try {
entradas = readdirSync(dir);
} catch {
return;
}
for (const nome of entradas) {
const caminho = join(dir, nome);
if (statSync(caminho).isDirectory()) anda(caminho);
else if (nome.endsWith(".jsx")) achados.push(caminho);
}
};
anda("app");
anda("components");
return achados.sort();
}
const RX_QUALQUER = /\b(?:text|bg|border|divide|ring|from|via|to)-slate-(\d+)\b/g;
const RX_TEXTO = /\btext-slate-(\d+) dark:text-slate-(\d+)\b/g;
const RX_SUPERFICIE = /\b(bg|border)-slate-(\d+)(?:\/\d+)? dark:\1-slate-(\d+)(?:\/\d+)?\b/g;
// texto que acompanha um componente invertido: claro -> escuro (sem /g: usado com .test)
const RX_TEXTO_INVERTIDO = /\btext-white dark:text-slate-(?:[5-9]\d0|950)\b/;
// Blocos style={{ ... }}: só dentro deles uma cor literal é problema.
// Fora disso, `color: "#6366f1"` costuma ser tabela de dados (categorias de pino).
const RX_BLOCO_STYLE = /style=\{\{([\s\S]*?)\}\}/g;
// Cor literal: aspas seguidas de #, rgb(), hsl() ou nome de cor.
const RX_COR_FIXA = /\b(borderColor|backgroundColor|color)\s*:\s*['"](?:#|rgb|hsl|transparent|white|black)/g;
// Qualquer classe que pinte cor, para medir se o arquivo tem tema escuro.
const RX_PINTA = /\b(?:text|bg|border|divide|from|via|to)-(?:slate|gray|zinc|neutral|stone|white)(?:-\d+)?(?:\/\d+)?\b/g;
// Famílias de cinza fora do padrão do projeto (que é slate).
const RX_FAMILIA_ERRADA = /\b(?:text|bg|border|divide|from|via|to)-(gray|zinc|neutral|stone)-\d+/g;
// Variante dark: DE VERDADE, seguida de um utilitário. Procurar só por "dark:"
// daria falso negativo: a palavra aparece em comentário e em texto visível.
const RX_VARIANTE_DARK = /\bdark:(?:hover:|focus:|group-hover:|active:)?[a-z]+-/;
/** Cor literal dentro de style={{}}: vence a classe e mata a variante dark:. */
function coresFixasInline(arq) {
const texto = readFileSync(arq, "utf8");
const achados = [];
for (const bloco of texto.matchAll(RX_BLOCO_STYLE)) {
for (const m of bloco[1].matchAll(RX_COR_FIXA)) {
const posicao = bloco.index + m.index;
const linha = texto.slice(0, posicao).split("\n").length;
achados.push([arq, linha, m[1], "cor literal inline anula a classe de tema"]);
}
}
return achados;
}
function main() {
const inexistentes = new Map();
const invertidos = [];
const fracos = [];
const fixas = [];
const semTemaEscuro = [];
const familiaErrada = new Map();
for (const arq of arquivos()) {
fixas.push(...coresFixasInline(arq));
console.log("\nAuditoria do sistema de design\n");
for (const pasta of PASTAS) {
for (const arq of arquivos(join(RAIZ, pasta))) {
if (/(^|\/)api\//.test(arq.replace(RAIZ, ""))) continue;
const bruto = readFileSync(arq, "utf8");
// Um arquivo que pinta muita cor e não tem nenhuma variante dark: quase
// certamente não acompanha o tema.
const pinta = (bruto.match(RX_PINTA) || []).length;
const temDark = RX_VARIANTE_DARK.test(bruto);
if (pinta >= 8 && !temDark) {
semTemaEscuro.push([arq, `${pinta} classes de cor, nenhuma variante dark:`]);
}
for (const m of bruto.matchAll(RX_FAMILIA_ERRADA)) {
const chave = `${arq} ${m[1]}-*`;
familiaErrada.set(chave, (familiaErrada.get(chave) ?? 0) + 1);
}
const linhas = bruto.split("\n");
const texto = SEM_COMENTARIO(bruto);
const linhas = texto.split("\n");
linhas.forEach((linha, i) => {
const n = i + 1;
for (const m of linha.matchAll(RX_QUALQUER)) {
if (!(m[1] in ESCALA)) {
const chave = `${arq}:${n} ${m[0]}`;
inexistentes.set(chave, (inexistentes.get(chave) ?? 0) + 1);
}
}
for (const m of linha.matchAll(RX_TEXTO)) {
const [, claro, escuro] = m;
if (!(claro in ESCALA) || !(escuro in ESCALA)) continue;
if (Number(escuro) > Number(claro)) {
invertidos.push([arq, n, m[0], "texto escurece no tema escuro"]);
}
const c1 = contraste(ESCALA[claro], FUNDO_CLARO);
const c2 = contraste(ESCALA[escuro], FUNDO_ESCURO);
if (c1 < AA || c2 < AA) {
fracos.push([arq, n, m[0], +c1.toFixed(2), +c2.toFixed(2)]);
}
}
for (const m of linha.matchAll(RX_SUPERFICIE)) {
const [, , claro, escuro] = m;
if (!(claro in ESCALA) || !(escuro in ESCALA)) continue;
if (Number(escuro) > Number(claro)) continue;
// Inversão deliberada (DESIGN.md seção 4): superfície escura nos DOIS
// temas: tooltip, bloco de código, botão escuro secundário.
if (Number(claro) >= 800 && Number(escuro) >= 800) continue;
// Botão que inverte preto<->branco NÃO é exceção: é bug.
// Ver DESIGN.md 4.1: botão carrega a própria cor.
const textoInverte =
RX_TEXTO_INVERTIDO.test(linha) ||
[...linha.matchAll(RX_TEXTO)].some((t) => Number(t[2]) > Number(t[1]));
if (textoInverte) {
invertidos.push([arq, n, m[0],
"botão invertendo preto/branco, use cor própria (DESIGN.md 4.1)"]);
continue;
}
invertidos.push([arq, n, m[0], "fundo/borda não escurece no tema escuro"]);
for (const regra of REGRAS) {
for (const m of linha.matchAll(regra.re)) relatar(arq, i + 1, regra.id, m[0], regra.dica);
}
// Emoji só interessa dentro de JSX/strings visíveis, não em dados .js
if (arq.endsWith(".jsx") && EMOJI.test(linha)) relatar(arq, i + 1, "emoji", linha.trim().slice(0, 60), "use um ícone do lucide-react");
});
}
const secao = (titulo, itens) => {
console.log(`\n${titulo}: ${itens.length}`);
for (const i of itens) console.log(" ", ...i);
};
if (inexistentes.size) {
const total = [...inexistentes.values()].reduce((a, b) => a + b, 0);
console.log(`\nTONS FORA DA ESCALA: ${total}`);
console.log(" (não geram CSS: declare em globals.css ou troque por um tom válido)");
for (const [k, v] of [...inexistentes].sort((a, b) => b[1] - a[1])) {
console.log(` ${v}x ${k}`);
}
}
secao("ARQUIVOS SEM TEMA ESCURO", semTemaEscuro);
if (familiaErrada.size) {
const total = [...familiaErrada.values()].reduce((a, b) => a + b, 0);
console.log(`\nFAMÍLIA DE CINZA FORA DO PADRÃO: ${total}`);
console.log(" (o projeto usa slate; gray/zinc/neutral/stone destoam e escapam da auditoria)");
for (const [k, v] of [...familiaErrada].sort((a, b) => b[1] - a[1])) {
console.log(` ${v}x ${k}`);
}
}
secao("PARES INVERTIDOS", invertidos);
secao(`PARES ABAIXO DE AA (${AA}:1)`, fracos);
secao("COR FIXA EM STYLE INLINE", fixas);
const total =
[...inexistentes.values()].reduce((a, b) => a + b, 0) +
[...familiaErrada.values()].reduce((a, b) => a + b, 0) +
invertidos.length + fracos.length + fixas.length + semTemaEscuro.length;
console.log(`\n${total === 0 ? "OK, nenhum problema." : `${total} problema(s).`}`);
console.log("Exceção legítima: painel escuro nos DOIS temas (tooltip, bloco de código).");
console.log("Nesses casos não use variante dark:. Ver DESIGN.md seção 4.");
return total ? 1 : 0;
}
process.exitCode = main();
// 7. Contraste dos tokens
const css = readFileSync(join(RAIZ, "app/globals.css"), "utf8");
const bloco = (sel) => {
const m = css.match(new RegExp(`\\n${sel.replace(".", "\\.")} \\{([\\s\\S]*?)\\n\\}`));
const o = {};
for (const [, k, v] of m[1].matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})/g)) o[k] = v;
return o;
};
const lum = (h) => {
const c = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
};
const razao = (a, b) => {
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
return (x + 0.05) / (y + 0.05);
};
const PARES = [
["ink", "canvas"], ["ink", "surface"], ["ink-soft", "surface"], ["ink-soft", "surface-2"],
["muted", "canvas"], ["muted", "surface"], ["muted", "surface-2"], ["faint", "surface-2"],
["accent", "canvas"], ["accent", "surface"], ["accent", "surface-2"], ["accent", "accent-soft"],
["accent-strong", "surface"], ["accent-contrast", "accent"],
["ok", "surface"], ["ok", "surface-2"], ["ok", "ok-soft"],
["danger", "surface"], ["danger", "surface-2"], ["danger", "danger-soft"],
["gold", "surface"], ["gold", "surface-2"], ["gold", "gold-soft"],
["copper", "surface"], ["copper", "surface-2"], ["copper", "copper-soft"],
];
const claro = bloco(":root");
const temas = { claro, escuro: { ...claro, ...bloco(".dark") } };
for (const [nome, t] of Object.entries(temas)) {
for (const [a, b] of PARES) {
if (!t[a] || !t[b]) continue;
const r = razao(t[a], t[b]);
if (r < 7) {
problemas++;
console.log(` \x1b[31m✖\x1b[0m contraste [${nome}] ${a} sobre ${b} = ${r.toFixed(2)}:1 (mínimo 7:1)`);
}
}
}
if (problemas === 0) {
console.log(" \x1b[32m✔\x1b[0m nenhuma violação: tokens, tamanhos, rótulos e contraste AAA em dia.\n");
} else {
console.log(`\n${problemas} violação(ões).\n`);
process.exit(1);
}