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:
1 parent
4f27f5858b
commit
bc9ae5c036
34 files changed
+4911
-6874
No files matched your search
+101
-200
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user