#!/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(`(? 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); }