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.md no 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:

  1. Tokens SCSS — constantes em tempo de compilação (espaçamento, raio, sombra, tipografia).
  2. Motor de temas — pega mapas de cores e gera :root[data-theme="..."] cheios de CSS Custom Properties.
  3. 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 -base sã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ãoValoresOnde mora
Modolight · dark · autolocalStorage["webapp-mode"] + data-theme no <html>
Paleta claralight, sepia, sunburst-light, feline-latte, retrobox-light, monokai-dawnlocalStorage["webapp-theme-light"]
Paleta escuradark, monokai-retro, feline-mocha, retrobox-dark, sunburst-darklocalStorage["webapp-theme-dark"]
Contrastelow · normal · highlocalStorage["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:

  1. Variantes semânticas, não literais. Você pede variant="primary", não variant="verde".
  2. Polimorfismo controlado: se vier href, o componente renderiza um <a>; senão, um <button>. Um só componente cobre os dois casos.
  3. 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:

CategoriaQuando 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.md antigo lista só 7 categorias e meia dúzia de componentes. A realidade: são mais de 80 componentes em 10 categorias. Há um Accordion, 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 classe is-active para 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 initialized impede 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:

  1. Escreva o mapa em _theme-maps.scss com todas as chaves semânticas.
  2. Ligue com uma linha em main.scss: @include builder.theme-vars('minha-palette', themes.$minha-palette);.
  3. (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:

  1. 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.
  2. Nomes semânticos, não literais. --c-accent, não --c-verde. É o que permite a mesma UI sob 16 paletas.
  3. 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.
  4. Dimensões desacopladas. Modo (claro/escuro/auto), paleta-clara, paleta-escura e contraste são escolhas independentes, persistidas em localStorage.
  5. 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--danger crava cor. Em Button.astro a variante danger usa #ef4444 / #dc2626 em vez de var(--c-error). Deveria ser --btn-bg: var(--c-error) + hover com uma versão strong (que falta no mapa — talvez um error-strong). Hoje essa variante não respeita tema nem contraste.
  • Tamanhos do botão fogem da escala. padding-left: 1rem (e 0.75rem, 1.5rem) em vez de tokens.$space-4 / $space-3 / $space-5. O sistema de tokens existe justamente para isso.
  • O mixin glass() é cego ao tema. Ele crava rgba(255,255,255,.6) e rgba(0,0,0,.6) para o dark. Em paletas coloridas (Monokai, Catppuccin, Gruvbox) o vidro fica errado. Deveria derivar de --c-surface com color-mix(in oklab, var(--c-surface) 60%, transparent).
  • Sombras não adaptam ao tema. $shadow-* são rgba(0,0,0,...) fixas. No dark, sombras pretas sobre superfície escura somem ou sujam. Criar um token --shadow-color por tema e compor as sombras com color-mix dá elevação correta em qualquer paleta.
  • Mapas mortos. monokai-engine, monokai-hex, feline-mocha e sunburst-dark existem em _theme-maps.scss mas não estão ligados em main.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-scheme por tema. Hoje :root { color-scheme: light dark } é global. Cada mapa deveria declarar se é claro/escuro, e o builder emitir color-scheme: light (ou dark). Sem isso, controles nativos (<select>, scrollbar, autofill, accent-color) não acompanham a paleta — num tema Monokai escuro, um <select> abre branco. Basta uma chave scheme: light|dark no mapa e um color-scheme: #{...} no builder.
  • Migrar os mapas para oklch(). Cor perceptualmente uniforme e wide-gamut (P3). Com oklch, derivar tons (um accent 10% mais claro) fica previsível; em hex/rgb, não. Trocar color-mix(in srgb, ...) por in 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: 1rem etc. permite (a) densidade adaptativa via @media, (b) uso em JS/inline, (c) sobreposição por tema. Pequeno custo, grande flexibilidade.
  • CSS @layer para 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 em srgb, em oklab e em oklch espalhados. Escolha um (recomendo oklab ou oklch) e padronize — resultados inconsistentes entre componentes hoje.

Mais responsivo

  • Container queries. Os componentes hoje só enxergam o viewport. Com @container, um Card ou SidebarNav reage 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..section-title usa clamp(). A escala $font-size-* é rem fixo. 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.
  • .article com largura contínua. Hoje são max-width por breakpoint (720→780→1140→1280). Um max-width: clamp(100%, 90vw, 1280px) com min() 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 #ef4444 do danger automaticamente, num PR. É a melhor defesa contra a deriva do design system.
  • Catálogo de temas como fonte tipada única. Hoje themes.ts exporta MODES e CONTRAST_LEVELS, mas o catálogo de paletas é string livre nos atributos data-theme-option-dark="...". Um registry tipado (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 sobre localStorage/matchMedia — perfeitamente testável em jsdom. Garantir que modo auto + prefers-color-scheme: dark resolve certo, que data-contrast persiste, etc.
  • Testes de acessibilidade por tema. Rodar axe-core nas 12 paletas × 3 níveis de contraste. Hoje nada garante que monokai-retro em contraste normal atinge WCAG AA. Automatizar isso fecha um risco real.
  • Gate de @supports no glass. Envolver backdrop-filter em @supports (backdrop-filter: blur(1px)) evita degrade silencioso e custo de GPU em dispositivos sem suporte.
  • CSP estrito no inline anti-FOUC. O ThemeScript precisa 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á existem Preview.astro ao 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 o SPEC.md está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/ui versionado, 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 (ou font-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). Adicionar transition:name em 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 do prefers-reduced-motion.
  • Tipografia de alta qualidade. text-wrap: balance em títulos e text-wrap: pretty em 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 em main.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.