Anatomia de um Design System: do token SCSS ao tema trocável em runtime
Um passeio didático, passo a passo, pela arquitetura de design system deste próprio site — tokens como única fonte da verdade, um motor de temas que transforma mapas SCSS em CSS Custom Properties, dimensões desacopladas (modo × paleta × contraste) e componentes que nunca veem uma cor cravada no código.
Quando você mantém um site por um tempo, acontece uma coisa previsível: o botão “primário” vira cinco botões primários diferentes. O padding que era 16px vira 15px, 14px, 1rem, 1.1rem. O verde da marca aparece em quatro tons ligeiramente distintos porque cada desenvolvedor pegou a cor de um screenshot diferente. E quando alguém pede “vamos ter um modo escuro?”, você descobre que há 200 lugares com color: #1c1c18 cravados no CSS.
É para resolver exatamente isso que existe um design system. Neste artigo vou dissecar, passo a passo, a arquitetura usada neste próprio site — um app Astro com SCSS. A ideia não é teórica: é o código rodando em produção, com as decisões reais (e alguns comentários honestos sobre onde a documentação oficial já ficou para trás).
Aviso de transparência: existe um
SPEC.mdno projeto, e ele é um bom ponto de partida — mas já está defasado. Diz que há 3 temas (na verdade são ~16 paletas), não menciona o sistema de contraste, e omite categorias inteiras de componentes. Tudo o que segue foi tirado lendo o código-fonte de verdade, não o SPEC.
A ideia central em uma frase
Uma única fonte da verdade (tokens) alimenta CSS Custom Properties semânticas, e nenhum componente nunca referencia um valor visual direto — só significados.
Significados como “cor de fundo”, “cor de texto”, “borda”, “destaque”. O componente diz “pinte-me com a cor de destaque”, e não “pinte-me de #1f6f5c”. A cor #1f6f5c mora em um só lugar. Trocar a identidade visual do site inteiro vira editar um mapa.
Isso se desdobra em três camadas, que são o esqueleto de tudo:
- Tokens SCSS — constantes em tempo de compilação (espaçamento, raio, sombra, tipografia).
- Motor de temas — pega mapas de cores e gera
:root[data-theme="..."]cheios de CSS Custom Properties. - Componentes — consomem essas variáveis (
var(--c-fg)) e nunca valores crus.
Vamos por partes.
Passo 1 — Tokens: a única fonte da verdade
Tudo começa em src/styles/_tokens.scss. É um arquivo minúsculo e propositalmente entediante:
$space-1: 0.25rem; // 4px
$space-2: 0.5rem; // 8px
$space-3: 0.75rem; // 12px
$space-4: 1rem; // 16px
$space-5: 1.5rem;
$space-6: 2rem;
$space-7: 3rem;
$space-8: 4rem;
$radius-1: 8px;
$radius-2: 16px;
$radius-3: 24px;
$radius-4: 32px;
$shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.08);
$shadow-hero: 0 32px 80px rgba(0, 0, 0, 0.22);
A regra de ouro do design system inteira está aqui: esses valores não são sugestões, são a lei. Se você precisa de 16px de espaço, você escreve $space-4, não 16px. Assim, no dia em que a escala inteira precisar mudar (digamos, adotar uma escala de 4.5px), você muda uma linha e o site inteiro acompanha.
Há arquivos-irmãos com o mesmo espírito, cada um dono de um domínio visual:
_typography.scss— escala tipográfica ($font-size-base,$font-size-2xl…), pesos, line-heights, letter-spacings._breakpoints.scss— breakpoints responsivos ($breakpoint-md: 768px) e mixins de media query._animations.scss— durações ($duration-fast: 150ms), curvas de easing e keyframes comuns.
Note um padrão importante: cores não estão aqui. Cores são especiais e ganham um motor próprio, porque precisam mudar em runtime (claro/escuro). Tokens de espaço e raio não mudam no tempo de execução, então vivem como constantes SCSS. Essa distinção é o coração do design e voltaremos a ela.
Passo 2 — Breakpoints e mixins: composição em vez de repetição
Junto aos tokens vivem pequenos mixins utilitários. O de breakpoint é o mais usado:
// _breakpoints.scss
@mixin min($bp) {
@media (min-width: $bp) {
@content;
}
}
Num componente:
@use "../../styles/breakpoints" as *;
.card {
padding: $space-3;
@include min($breakpoint-md) {
padding: $space-6; // mais respiro em telas maiores
}
}
Há também mixins de layout prontos (flex-center, flex-between, container) e utilitários (sr-only, truncate, line-clamp, glass, focus-ring) em mixins/_layout.scss e mixins/_utils.scss. A filosofia é a do UNIX que o projeto adota no resto: blocos pequenos e combináveis. Em vez de reescrever o mesmo display:flex; align-items:center; justify-content:center; cinquenta vezes, você inclui um mixin.
Passo 3 — O motor de temas: mapas SCSS virando CSS Custom Properties
Aqui chegamos à parte mais elegante do sistema. Cores precisam (a) serem semanticamente nomeadas e (b) trocáveis a quente. A solução em duas fases:
Fase A: cada tema é um mapa SCSS
Em _theme-maps.scss, cada tema é só uma estrutura de dados — um mapa associando chaves semânticas a cores crus:
$light: (
bg: #f6f4ef,
surface: #ffffff,
fg: #1c1c18,
muted: #4b4b43,
accent: #1f6f5c,
accent-strong:#0e4b3d,
line: #dcd9cf,
glow: #f0ede6,
// Cores semânticas (feedback)
error: #dc2626,
success: #16a34a,
warning: #d97706,
info: #2563eb,
// ... respectivos *-soft
font-display: '"Fraunces", "Iowan Old Style", serif',
font-body: '"Manrope", "Segoe UI", sans-serif',
);
Perceba o vocabulário semântico, não literal. A chave não é verde-escuro, é accent-strong. O componente vai pedir “a cor de destaque forte”, não “o verde”. Isso é o que permite reaproveitar o mesmo componente sob paletas completamente diferentes (verde musgo, sépia, pastel Catppuccin) sem tocar no componente.
Fase B: um mixin “compila” cada mapa num bloco de CSS Custom Properties
O _theme-builder.scss é o tradutor. Ele recebe um nome e um mapa e cospe um seletor :root[data-theme="NOME"]:
@mixin theme-vars($name, $theme) {
:root[data-theme="#{$name}"] {
// 1. Cores BRUTAS — *-base, intocáveis pelo contraste
--c-bg-base: #{map.get($theme, bg)};
--c-fg-base: #{map.get($theme, fg)};
--c-accent-base: #{map.get($theme, accent)};
// ...
// 2. Cores CONSUMIDAS — as que os componentes realmente lêem
--c-bg: var(--c-bg-base);
--c-fg: var(--c-fg-base);
--c-muted: color-mix(in srgb, var(--c-fg-base) 82%, var(--c-bg-base));
--c-line: color-mix(in srgb, var(--c-fg-base) 18%, var(--c-bg-base));
// ...
}
}
E em main.scss você “liga” cada tema com uma linha:
@include builder.theme-vars('light', themes.$light);
@include builder.theme-vars('sepia', themes.$sepia);
@include builder.theme-vars('dark', themes.$dark);
@include builder.theme-vars('monokai-retro', themes.$monokai-retro);
// ... e por aí vai
O resultado no CSS final é um dicionário por tema:
:root[data-theme="light"] { --c-bg-base: #f6f4ef; --c-bg: var(--c-bg-base); ... }
:root[data-theme="dark"] { --c-bg-base: #0f1418; --c-bg: var(--c-bg-base); ... }
Como o <html> recebe data-theme="dark", o navegador ativa aquele bloco e todas as variáveis --c-* mudam de valor instantaneamente — sem JavaScript recarregando estilos, sem re-render de componente. É CSS puro resolvendo o problema.
Passo 4 — O truque do base vs. consumido (e por que ele importa)
Você deve ter estranhado a duplicação. Por que --c-bg-base e --c-bg? Parece redundante. Não é — é a engenhosidade central do sistema.
Pense assim:
--c-bg-baseé a cor original do tema, gravada em pedra.--c-bgé a cor realmente em uso, que pode ser “distorcida” por outra camada.
No estado normal, --c-bg simplesmente aponta para --c-bg-base. Mas existe uma terceira dimensão além de “qual tema” — qual nível de contraste. É aqui que entra o _contrast.scss:
// Contraste ALTO — aumenta a distinção visual (acessibilidade)
:root[data-theme][data-contrast="high"] {
--c-bg: color-mix(in srgb, var(--c-bg-base) 92%, var(--c-fg-base));
--c-fg: var(--c-fg-base);
--c-muted: color-mix(in srgb, var(--c-fg-base) 95%, var(--c-bg-base));
--c-line: color-mix(in srgb, var(--c-fg-base) 35%, var(--c-bg-base));
// ...
}
Quando o usuário ativa alto contraste, as variáveis --c-* são redefinidas — misturadas com color-mix() para mais contraste — mas os *-base permanecem intactos. O componente continua lendo var(--c-bg) como sempre leu; ele não tem ideia de que algo mudou. Acessibilidade sem nenhum componente precisar saber que acessibilidade existe.
Isso só é possível porque separamos “o que o tema define” (-base) de “o que o componente consome” (--c-*). É uma camada de indireção que paga o preço de alguns bytes de CSS em troca de uma flexibilidade enorme.
Regra de ouro para quem escreve componente: use sempre
var(--c-fg),var(--c-accent), etc. Nunca os*-base. Os-basesão propriedade do motor.
Passo 5 — Dimensões desacopladas: modo × paleta × contraste
Aqui está algo que o SPEC.md antigo não captura, mas que o código de runtime deixa cristalino. A escolha visual do usuário é, na verdade, três escolhas independentes:
| Dimensão | Valores | Onde mora |
|---|---|---|
| Modo | light · dark · auto | localStorage["webapp-mode"] + data-theme no <html> |
| Paleta clara | light, sepia, sunburst-light, feline-latte, retrobox-light, monokai-dawn… | localStorage["webapp-theme-light"] |
| Paleta escura | dark, monokai-retro, feline-mocha, retrobox-dark, sunburst-dark… | localStorage["webapp-theme-dark"] |
| Contraste | low · normal · high | localStorage["webapp-contrast"] + data-contrast |
O desacoplamento modo × paleta é o ponto genial. Você não está preso a “modo escuro = sempre o tema dark padrão”. Você pode dizer: “quando estiver de noite, quero o modo escuro especificamente na paleta Monokai Retro”. A função que resolve tudo é minúscula:
function applyTheme(): void {
const mode = localStorage.getItem('webapp-mode') || 'auto';
const lightTheme = localStorage.getItem('webapp-theme-light') || 'light';
const darkTheme = localStorage.getItem('webapp-theme-dark') || 'dark';
let activeMode = mode;
if (mode === 'auto') {
activeMode = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
}
const themeToApply = activeMode === 'dark' ? darkTheme : lightTheme;
document.documentElement.setAttribute('data-theme', themeToApply);
}
E o modo auto respeita prefers-color-scheme, ou seja, o site reage à preferência do sistema operacional sem o usuário precisar configurar nada. Tudo isso é vanilla TypeScript, sem nenhum framework de estado.
Passo 6 — Um componente real, dissecado: o Button
Toda essa teoria só vale alguma coisa se o componente no dia a dia for simples de escrever. Veja o Button.astro. Primeiro, as props tipadas:
interface Props
extends Omit<HTMLAttributes<"button">, "type">,
Omit<HTMLAttributes<"a">, "type"> {
variant?: "primary" | "secondary" | "outline" | "ghost" | "link" | "danger";
size?: "sm" | "md" | "lg";
shape?: "square" | "circle";
wide?: boolean;
block?: boolean;
loading?: boolean;
href?: string; // vira <a> automaticamente
type?: "button" | "submit" | "reset";
}
Repare em três decisões de design:
- Variantes semânticas, não literais. Você pede
variant="primary", nãovariant="verde". - Polimorfismo controlado: se vier
href, o componente renderiza um<a>; senão, um<button>. Um só componente cobre os dois casos. - O tamanho/variante viram classes montadas por um array filtrado — o padrão de casa:
const classes = [
"btn",
`btn--${variant}`,
`btn--${size}`,
shape && `btn--${shape}`,
block && "btn--block",
loading && "btn--loading",
className, // classes extras do chamador
].filter(Boolean).join(" ");
Agora o estilo. É onde o design system brilha de verdade. O botão define variáveis CSS locais como defaults e cada variante só redefine essas variáveis — em vez de reescrever background/color em cada bloco:
.btn {
// defaults locais
--btn-color: var(--c-fg);
--btn-bg: transparent;
--btn-border: transparent;
background-color: var(--btn-bg);
color: var(--btn-color);
border: 1px solid var(--btn-border);
border-radius: tokens.$radius-1; // token, nunca 8px cru
transition: background-color .2s ..., transform .1s ...;
&:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
&:active:not(:disabled) { transform: scale(0.95); }
}
.btn--primary {
--btn-bg: var(--c-accent);
--btn-border: var(--c-accent);
--btn-color: var(--c-bg);
--btn-hover-bg: var(--c-accent-strong);
}
Veja o efeito cascata: o botão primário pediu --c-accent — que vale #1f6f5c no tema claro, #7ed1b6 no tema dark, #A6E22E no Monokai Retro. O mesmo botão, seis paletas, zero mudanças no componente. É o design system pagando dividendos.
Note também que tokens.$radius-1 aparece — mostrando que tokens SCSS e CSS Custom Properties coexistem: tokens para o que é estático (raio, espaço), CSS vars para o que muda por tema/contraste (cores).
Passo 7 — A anatomia de um componente (as convenções de casa)
Olhando a árvore src/components/ui/, o padrão se repete em todos. As convenções, validadas no código real:
Pasta própria por componente. Cada componente vive em categoria/NomeComponente/NomeComponente.astro. Isso permite co-localizar arquivos relacionados (uma Preview.astro para o Storybook visual, variantes como WhatsAppButton.astro etc.):
actions/Button/
├── Button.astro
├── Preview.astro
└── WhatsAppButton.astro
Categorias semânticas, classificadas pela intenção do componente:
| Categoria | Quando usar |
|---|---|
actions/ | A interação principal é um clique (Button, Modal, Dropdown, FAB) |
data-display/ | Exibir dado, sem input (Card, Table, Badge, Carousel, Timeline) |
data-input/ | Capturar entrada (InputField, Select, Checkbox, FileUpload) |
feedback/ | Comunicar estado/resultado (Toast, Alert, Skeleton, Progress) |
layout/ | Estrutura de página (Hero, Footer, Stack, DrawerSidebar) |
navigation/ | Mudar de página/seção (Navbar, Breadcrumbs, Tabs, Dock) |
mockup/ | Molduras para demos (Browser, Phone, Window) |
graphics/, media/, icons/ | Visuais específicos (VectorField, SmartImage, WhatsAppIcon) |
O
SPEC.mdantigo lista só 7 categorias e meia dúzia de componentes. A realidade: são mais de 80 componentes em 10 categorias. Há umAccordion,Timeline,Countdown,Skeleton,DrawerSidebar,Dock,ServiceSimulator,Hover3DCard… a documentação sempre corre atrás da implementação — confie na pasta.
Estilo scoped com @use tokens. Cada .astro traz seu CSS colocado num <style lang="scss"> que importa os tokens:
<style lang="scss">
@use "../../../styles/tokens";
.card { padding: tokens.$space-4; border-radius: tokens.$radius-2; }
</style>
Slots em vez de props para conteúdo. Seguindo a filosofia Astro, conteúdo vai em <slot />, não em props.children. Isso mantém os componentes composáveis.
Barrel exports. Um index.ts em cada categoria e um ui/index.ts no topo permitem importar limpo:
import { Button, Modal } from '@/components/ui';
Passo 8 — Troca de tema em runtime, sem framework
Como o usuário efetivamente muda de tema? Aqui entra uma das partes mais bem resolvidas: tudo funciona com um único listener delegado no document, sem framework de estado, e sobrevive a View Transitions do Astro.
Os botões de UI carregam atributos data-*:
<button data-tm-mode="dark">Escuro</button>
<button data-tm-contrast="high">Alto contraste</button>
<button data-theme-option-dark="monokai-retro">Monokai Retro</button>
E um único handler decide o que fazer com cada clique:
export function initThemeControl(): void {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const modeBtn = target.closest('[data-tm-mode]');
if (modeBtn) {
localStorage.setItem('webapp-mode', modeBtn.getAttribute('data-tm-mode')!);
applyTheme(); // aplica no <html>
syncThemeControls(); // atualiza estado visual de TODOS os controles
return;
}
// ... mesmo padrão para contraste e seleção de paleta
});
// Re-sincroniza depois de uma View Transition (Astro re-renderiza os controles)
document.addEventListener('astro:page-load', () => syncThemeControls());
}
Pontos que valem ouro:
- Delegação de evento: um listener só, no
document, atende todos os botões de tema da página inteira — presente ou futuro. Componentes não precisam se inscrever em nada. syncThemeControls(): depois de mudar o tema, todos os botões da página ganham/ perdem a classeis-activepara refletir o estado atual. Isso significa que, se você tiver o seletor de tema no header e numa página de configurações, os dois ficam em sincronia automaticamente.astro:page-load: o Astro, em navegações com View Transitions, recria os elementos. Esse hook garante que os controles recém-renderizados sejam sincronizados de novo. Sem ele, você clicaria em “Escuro”, o tema mudaria, mas o botão ficaria marcado como “Claro” na próxima página.- Idempotente: a flag
initializedimpede que o listener seja registrado duas vezes.
Tudo isso em ~120 linhas de TypeScript puro. Sem React, sem Redux, sem nada. É uma aula de como resolver um problema de estado com as primitivas certas da plataforma.
Passo 9 — Acessibilidade não é um adorno, é fundação
Um design system que ignora acessibilidade é só um stylesheet bonito. Aqui ela está embutida na fundação:
prefers-reduced-motion respeitado globalmente em main.scss:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Quem tem labirintite ou sensibilidade a movimento recebe um site estático, automaticamente, sem precisar pedir.
Foco visível padronizado — todo :focus-visible ganha um anel de --c-accent:
:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
Estados ARIA nos componentes. O botão com loading não só mostra um spinner: também recebe aria-busy="true", aria-disabled="true" e um <span class="sr-only">Loading...</span> para leitores de tela.
Skip link para pular direto ao conteúdo, e a classe .sr-only padronizada para texto só-para-leitor-de-tela.
E, fechando o ciclo, a camada de contraste (data-contrast="high") — uma ferramenta de acessibilidade de verdade, que recalcula todas as cores do sistema para máxima distinção, útil para usuários com baixa visão. Sem ela, o “alto contraste” teria que ser reimplementado em cada componente.
O catálogo real de temas
Só para você sentir a escala do que o motor suporta — bem além dos “3 temas” do SPEC antigo. As paletas vivem em _theme-maps.scss, agrupadas em famílias inspiradas em esquemas clássicos de editor:
- Básicos:
light,sepia,dark. - Monokai (7 variações):
monokai-expert,monokai-engine,monokai-hex,monokai-espresso,monokai-prism,monokai-retro,monokai-dawn(este último é claro). - Feline (inspirado em Catppuccin):
feline-latte(claro),feline-mocha(escuro). - Retrobox (inspirado em Gruvbox):
retrobox-light,retrobox-dark. - Sunburst (inspirado em Solarized):
sunburst-light,sunburst-dark.
E adicionar uma paleta nova é mecânico:
- Escreva o mapa em
_theme-maps.scsscom todas as chaves semânticas. - Ligue com uma linha em
main.scss:@include builder.theme-vars('minha-palette', themes.$minha-palette);. - (Opcional) Exponha como opção na UI com
data-theme-option-dark="minha-palette".
Pronto. Todos os 80+ componentes já funcionam na paleta nova, porque nenhum deles sabe que cores existem.
Resumo: a ideia em cinco princípios
Se você for levar cinco coisas deste artigo:
- Tokens são a única fonte da verdade. Espaço, raio, tipografia vivem como constantes SCSS; cores vivem como CSS Custom Properties. O componente nunca vê valor cru.
- Nomes semânticos, não literais.
--c-accent, não--c-verde. É o que permite a mesma UI sob 16 paletas. - Separe o “definido pelo tema” (
-base) do “consumido pelo componente” (--c-*). Essa indireção é o que dá de presente camadas como contraste, de graça. - Dimensões desacopladas. Modo (claro/escuro/auto), paleta-clara, paleta-escura e contraste são escolhas independentes, persistidas em
localStorage. - CSS resolve quando dá, TS resolve quando precisa. A troca de tema em si é CSS puro (data attribute liga um bloco de variáveis). Só o estado da UI (qual botão está ativo, persistência, View Transitions) precisa de ~120 linhas de TypeScript.
O resultado é um site onde mudar a identidade visual é editar um mapa, onde “fazer um modo escuro” já está feito, e onde um componente novo se integra a todas as paletas e níveis de contraste sem que você precise pensar nisso. É mais trabalho na fundação — mas é um trabalho que você faz uma vez.
Onde o sistema pode crescer: um roadmap honesto
O sistema é sólido na fundação, mas lendo o código de perto aparecem inconsistências reais (o próprio sistema sendo violado) e oportunidades concretas de modernização. Agrupei por eixo. Cada item aponta um arquivo ou comportamento específico — não é conselho genérico.
Primeiro, consertar o que já fere o sistema (consistência)
O design system é uma lei; quando o próprio código a quebra, a lei perde força. Achei estes ao revisar:
Button--dangercrava cor. EmButton.astroa variante danger usa#ef4444/#dc2626em vez devar(--c-error). Deveria ser--btn-bg: var(--c-error)+ hover com uma versão strong (que falta no mapa — talvez umerror-strong). Hoje essa variante não respeita tema nem contraste.- Tamanhos do botão fogem da escala.
padding-left: 1rem(e0.75rem,1.5rem) em vez detokens.$space-4/$space-3/$space-5. O sistema de tokens existe justamente para isso. - O mixin
glass()é cego ao tema. Ele cravargba(255,255,255,.6)ergba(0,0,0,.6)para o dark. Em paletas coloridas (Monokai, Catppuccin, Gruvbox) o vidro fica errado. Deveria derivar de--c-surfacecomcolor-mix(in oklab, var(--c-surface) 60%, transparent). - Sombras não adaptam ao tema.
$shadow-*sãorgba(0,0,0,...)fixas. No dark, sombras pretas sobre superfície escura somem ou sujam. Criar um token--shadow-colorpor tema e compor as sombras comcolor-mixdá elevação correta em qualquer paleta. - Mapas mortos.
monokai-engine,monokai-hex,feline-mochaesunburst-darkexistem em_theme-maps.scssmas não estão ligados emmain.scss. Ou ligue os quatro (uma linha cada) ou remova — manter código definido e inacessível é dívida silenciosa.
Esses são bugs de design system: fáceis de corrigir e de alto valor simbólico (mostram que a lei vale para todos).
Mais moderno
color-schemepor tema. Hoje:root { color-scheme: light dark }é global. Cada mapa deveria declarar se é claro/escuro, e o builder emitircolor-scheme: light(oudark). Sem isso, controles nativos (<select>, scrollbar, autofill,accent-color) não acompanham a paleta — num tema Monokai escuro, um<select>abre branco. Basta uma chavescheme: light|darkno mapa e umcolor-scheme: #{...}no builder.- Migrar os mapas para
oklch(). Cor perceptualmente uniforme e wide-gamut (P3). Comoklch, derivar tons (um accent 10% mais claro) fica previsível; emhex/rgb, não. Trocarcolor-mix(in srgb, ...)porin oklch, ...também. Resultado: transições de tema mais suaves e contraste mais correto. - Tokens de espaço como CSS Custom Properties. Hoje
$space-*é só SCSS (compila para literal). Exportar também--space-4: 1remetc. permite (a) densidade adaptativa via@media, (b) uso em JS/inline, (c) sobreposição por tema. Pequeno custo, grande flexibilidade. - CSS
@layerpara cascade. A camada de contraste hoje vence por especificidade ([data-theme][data-contrast="high"]). Declarar@layer themes, base, components, utilities;torna a precedência explícita e previsível — sem depender de contagem de seletores. - Padronizar o espaço de cor do
color-mix. Hoje há mistura emsrgb, emoklabe emoklchespalhados. Escolha um (recomendooklabouoklch) e padronize — resultados inconsistentes entre componentes hoje.
Mais responsivo
- Container queries. Os componentes hoje só enxergam o viewport. Com
@container, umCardouSidebarNavreage ao espaço do pai — útil quando o mesmo componente aparece num layout largo e numa sidebar estreita. CSS moderno bem suportado. - Tipografia fluída em toda a escala. Só
.section-titleusaclamp(). A escala$font-size-*éremfixo. Uma escala fluída (clamp(1rem, 0.9rem + 0.5vw, 1.25rem)) escala suavemente entre mobile e desktop, sem media queries escalonadas. - Espaçamento fluído. Mesma ideia para
--space-*: respiro um pouco menor em telas pequenas, maior em telas grandes, de forma contínua. .articlecom largura contínua. Hoje sãomax-widthpor breakpoint (720→780→1140→1280). Ummax-width: clamp(100%, 90vw, 1280px)commin()simplifica e suaviza.
Mais seguro e robusto
Aqui “seguro” menos como security e mais como confiabilidade/qualidade — mas há um ponto de CSP real:
- Lint que proíbe valor cru.
stylelint+ regra de strict-value para cores (sóvar(--c-*)) e espaçamento (só tokens). Teria pegado o#ef4444dodangerautomaticamente, num PR. É a melhor defesa contra a deriva do design system. - Catálogo de temas como fonte tipada única. Hoje
themes.tsexportaMODESeCONTRAST_LEVELS, mas o catálogo de paletas é string livre nos atributosdata-theme-option-dark="...". Umregistrytipado (type ThemeId = 'light' | 'dark' | ...) gerando a UI elimina typos e mantém TS sincronizado com o SCSS (hoje é fácil adicionar um mapa e esquecer da UI, ou vice-versa). - Testes para
theme-control.ts. É vanilla TS puro sobrelocalStorage/matchMedia— perfeitamente testável em jsdom. Garantir que modoauto+prefers-color-scheme: darkresolve certo, quedata-contrastpersiste, etc. - Testes de acessibilidade por tema. Rodar
axe-corenas 12 paletas × 3 níveis de contraste. Hoje nada garante quemonokai-retroem contrastenormalatinge WCAG AA. Automatizar isso fecha um risco real. - Gate de
@supportsnoglass. Envolverbackdrop-filterem@supports (backdrop-filter: blur(1px))evita degrade silencioso e custo de GPU em dispositivos sem suporte. - CSP estrito no inline anti-FOUC. O
ThemeScriptprecisa ser inline para evitar flash de tema errado. Com CSP estrito, prever suporte a nonce/hash nesse script.
Mais profissional
- Living style guide (
/design-system). Já existemPreview.astroao lado de vários componentes. Formalizar uma rota de galeria viva — todos os componentes, todas as variantes, todos os temas — vira a fonte da verdade visual e substitui oSPEC.mdestático (que vimos desatualizar rápido). Documentação que se testa é documentação que existe. - Tokens em formato W3C / Style Dictionary. Definir tokens num formato agnóstico (o W3C Design Tokens format) e gerar o SCSS a partir dele. Mesma fonte pode alimentar Figma e, no futuro, mobile. Acaba com o “token no código diverge do token no Figma”.
- Versionamento semântico do DS.
@webapp/uiversionado, com changelog e deprecation path (uma variante renomeada fica obsoleta por uma release antes de sumir). É o que separa um stylesheet de um produto. - Regressão visual automatizada. Snapshots do Playwright por tema/contraste — um diff visual avisa quando uma paleta quebrou um componente.
Mais elegante
- Optical sizing para a Fraunces. A
Frauncesé uma fonte variável.font-optical-sizing: auto(oufont-variation-settings) faz os títulos ficarem mais nítidos/certos em cada tamanho — detalhe de alta qualidade tipográfica que quase ninguém ativa. - Sistema de elevação por tema. Sombras que respeitam o tema (via
--shadow-color+color-mix, como acima) dão profundidade correta no dark — a marca de um design system maduro. - View Transitions nomeadas. O site já usa
astro:page-load(View Transitions ligadas). Adicionartransition:nameem listagem→detalhe cria morphs elegantes entre rotas, de graça. - Microinterações com curvas físicas. O
:active { transform: scale(0.95) }do botão é cru. Uma curva tipo$ease-spring(cubic-bezier com overshoot suave) no transform dá vida — sempre dentro doprefers-reduced-motion. - Tipografia de alta qualidade.
text-wrap: balanceem títulos etext-wrap: prettyem parágrafos (evita viúvas/orfãs), letter-spacing negativo sutil em display grandes. São umas linhas de CSS que elevam a percepção de acabamento. - Anel de foco consistente em todo interativo. Hoje o foco é consistente em alguns lugares. Padronizar
:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px }globalmente (já começa emmain.scss) fecha o polimento de teclado.
A thread comum: o sistema já entrega o difícil (temas trocáveis em runtime, contraste como camada, 80+ componentes consistentes). O que resta é disciplina (lint + testes para a lei valer), modernização pontual (oklch, container queries, color-scheme) e acabamento (elevação por tema, tipografia variável). Nada disso é reescrever — é afiar.
Para fuçar no código real, comece por src/styles/_theme-builder.scss (o motor), src/styles/_theme-maps.scss (as paletas) e src/components/ui/actions/Button/Button.astro (um componente exemplar). E duvide do SPEC.md — a verdade está nos arquivos.