Files
ESPDocs/scripts/auditar-temas.mjs
T
Lucas Rayan GuerraandClaude Sonnet 5.5 bc9ae5c036 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>
2026-10-20 16:10:15 -03:00

120 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Auditor do sistema de design.
*
* 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. 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
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
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}`);
};
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");
const texto = SEM_COMENTARIO(bruto);
const linhas = texto.split("\n");
linhas.forEach((linha, i) => {
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");
});
}
}
// 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);
}