* 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>
120 lines
5.8 KiB
JavaScript
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);
|
|
}
|