AO·dev
open-sourcedesign-systemPessoalpúblico
Ver como Markdown (abre em nova aba)

fui-content

Design system SCSS que estiliza HTML nativo com cinco morfismos intercambiáveis — glass, clay, neu, skeu e flat — contraste WCAG AA calculado em compile-time e zero classe extra no HTML. É a lib que veste este próprio portfólio.

Empresa

Pessoal

Período

2026 – atual

Papel

Criador, Mantenedor

Todo o resto neste portfólio — cada fui-btn, fui-card, cada troca de tema no seletor de morfismo lá em cima — vem daqui. fui-content é o design system SCSS que comecei a construir em abril de 2026: um monorepo com tokens, utilitários e cinco morfismos visuais completos, publicado como pacotes independentes no npm. Este projeto é, ao mesmo tempo, o mais técnico e o mais pessoal do portfólio — não tem cliente, não tem prazo de agência, só a pergunta que me fez começar: dá pra ter cinco identidades visuais completamente diferentes — vidro, argila, plástico extrudado, hardware físico, flat puro — trocando um único import, sem duplicar HTML, sem framework acoplado, e sem sacrificar contraste WCAG em nenhuma delas?

A resposta, depois de mais de 170 commits, é sim. E o processo de chegar lá ensinou mais sobre CSS moderno do que qualquer projeto de cliente que já toquei.

Por baixo do capô

CamadaTecnologia
LinguagemSCSS/Sass@use, @forward, funções e mixins nativos
Monoreponpm workspaces — 5 pacotes publicados independentemente
DistribuiçãoGitHub Packages — registry npm privado por pacote
Testessass-true via Jest — 307 testes, incluindo contraste WCAG calculado
QualidadeStylelint + Prettier
CI/CDGitHub Actions — build/lint/test em todo push, publish por tag
Preview localDocker + Traefik, servido em fui.localhost
CommitsConventional Commits via Husky + commitlint

A ideia central é simples de enunciar e difícil de sustentar: um tema = um morfismo. Nenhum componente carrega classe de morfismo (.fui-glass .fui-btn não existe) — instalar @allangboliveira/ui-theme-glass e trocar data-theme já é suficiente para todo o vocabulário de componentes (fui-btn, fui-card, fui-input...) virar vidro. Trocar de morfismo em produção significa trocar de pacote, não reescrever HTML.

Seis camadas, uma ordem que não pode quebrar

O CSS inteiro do sistema é organizado em @layer, declarados uma única vez, logo no primeiro arquivo que qualquer consumidor carrega:

// packages/base/layout/_grid.scss
@layer fui.layout, fui.base, fui.components, fui.utilities;

A ordem entre camadas é a regra mais importante do projeto inteiro — e a que mais fácil se quebra sem querer:

  1. tokens — variáveis SCSS de compile-time (paleta, espaçamento, tipografia)
  2. utilities — classes atômicas (fui-bg-primary, fui-p-4, fui-shadow-md)
  3. layout — grid, breakpoints, container
  4. themes — geração dos temas ([data-theme="light"] / [data-theme="dark"])
  5. base — reset e estilos de elemento HTML puro
  6. componentsfui-btn, fui-card, fui-input...

@layer resolve em ordem de declaração, não de especificidade — por isso essa lista, uma vez fixada, é contrato: se um pacote de tema declarasse sua própria ordem de @layer diferente da do base, o browser resolveria a cascata errado dependendo de qual <link> o parser encontrasse primeiro. (Essa exata categoria de bug — ordem de @layer não fixada de forma determinística — é a mesma classe de problema que resolvi no lado consumidor, aqui no ao.dev, documentada no CLAUDE.md do próprio projeto como "bug real de bleed em reload frio". A lição de um projeto virou prevenção estrutural no outro.)

Tokens em duas camadas — Sass em compile-time, CSS custom properties em runtime

Todo valor visual do sistema nasce como variável Sass e é emitido como CSS custom property. A dualidade é deliberada: o Sass resolve funções, deep-merge de mapas e cálculo de contraste antes de qualquer byte chegar ao browser; o CSS var existe para o consumidor poder sobrescrever em runtime sem recompilar Sass.

// packages/base/tokens/_colors.scss — customização do consumidor
$palette-overrides: () !default;

// ao.dev customiza assim, em fui.scss:
@use '@allangboliveira/ui-themes-base' with (
  $palette-overrides: ("brand": (500: #ff6b35, 600: #e55a2b))
);
/* o que chega ao browser, já resolvido */
:root {
  --fui-color-primary: #7c3aed;
  --fui-color-on-primary: #fafafa; /* calculado, não hardcoded — ver seção seguinte */
}

Dois níveis de customização coexistem por design:

  • Sass compile-time$palette-overrides e funções como shadow-color-for()/text-color-for(): derivação automática, span completo de recálculo (cor de texto, cor de sombra, tudo se ajusta).
  • CSS runtime — sobrescrever --fui-color-surface direto via <style>: a cascata sempre vence, mas só o que tem fallback via color-mix() (como a sombra) se readapta sozinho. Texto não — CSS não tem color-contrast() com suporte real ainda, então sobrescrever cor de superfície em runtime exige atualizar --fui-color-on-surface manualmente. Documentei essa assimetria explicitamente para não virar surpresa de quem usa a lib.

Paleta com contraste WCAG garantido — calculado, não adivinhado

Esse foi o problema mais interessante do projeto inteiro. Toda paleta de design system tem o mesmo risco: alguém escolhe blue-500 como cor de texto sobre blue-600 como fundo, parece bonito no Figma, e falha WCAG AA (4.5:1) sem ninguém perceber até uma auditoria de acessibilidade pegar.

A solução aqui foi eliminar a adivinhação: uma função Sass calcula luminância relativa e razão de contraste em compile-time, e decide sozinha se o texto deve ser claro ou escuro.

// packages/base/utilities/_a11y.scss
@function luminance($color) {
  // fórmula de luminância relativa (WCAG 2.1)
  $r: red($color) / 255;
  $g: green($color) / 255;
  $b: blue($color) / 255;
  // ... gamma correction por canal
  @return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
}

@function contrast-ratio($fg, $bg) {
  $l1: luminance($fg) + 0.05;
  $l2: luminance($bg) + 0.05;
  @return math.max($l1, $l2) / math.min($l1, $l2);
}

@function text-color-for($bg) {
  // retorna neutral-50 ou neutral-950 — o que tiver MAIOR contraste contra $bg
  $contrast-light: contrast-ratio($neutral-50, $bg);
  $contrast-dark: contrast-ratio($neutral-950, $bg);
  @return if($contrast-light > $contrast-dark, $neutral-50, $neutral-950);
}

O resultado prático: 88 variantes de botão com contraste garantido — 8 escalas de cor × 11 degraus (50 a 950) — cada uma testada. A suíte de testes (sass-true via Jest) roda essa verificação para cada combinação real:

$ npm test

  A11y button palette WCAG AA
    Button palette WCAG AA contrast (4.5:1)
      neutral
 neutral-50 has a compliant text choice
 neutral-100 has a compliant text choice
        ...
      blue / green / red / yellow / purple / ...
 (11 degraus cada, todos compliant)

Tests:       307 passed, 307 total

Não é teste de snapshot visual — é teste matemático rodando a mesma fórmula de contraste que o WCAG define, contra cada célula real da paleta, toda vez que alguém roda npm test. Se um dia eu adicionar uma cor nova à paleta e ela quebrar contraste em algum degrau, o CI pega antes de eu nem notar visualmente.

Sombra e superfície — o problema que pure black escondia

Um dos primeiros bugs reais do projeto: em dark mode, box-shadow sobre neutral-900 (quase preto puro) fica invisível — preto sobre preto, mesmo com blur generoso. Cards e botões perdiam toda a elevação visual assim que o usuário trocava pra dark.

A correção não foi aumentar blur ou opacidade às cegas — foi recalibrar os próprios degraus de superfície:

PapelLightDark
surfaceneutral-50neutral-800
surface-altneutral-100neutral-700
surface-raised#fffneutral-600

Mover a base do dark mode de neutral-900/950 para neutral-800 (um cinza, não um quase-preto) abriu espaço pra sombra ter contra o que se destacar. Em paralelo, criei um token semântico color-shadow — light usa rgba(0,0,0,0.15), dark usa rgba(0,0,0,0.55) — porque a mesma opacidade de sombra que é sutil sobre branco fica imperceptível sobre cinza escuro. Uma função (shadow-color-for($bg)) deriva essa opacidade automaticamente pela luminância do fundo, com thresholds específicos:

// luminância < 0.03 → opacity 0.55
// luminância < 0.10 → opacity 0.45
// luminância < 0.30 → opacity 0.30
// luminância < 0.65 → opacity 0.20
// caso contrário     → opacity 0.15

E, como fallback pra quando um consumidor sobrescreve --fui-color-surface via CSS puro sem saber da função Sass, todo box-shadow do sistema carrega um color-mix() de segurança:

--fui-shadow-md: 0 4px 6px -1px var(--fui-color-shadow, color-mix(in srgb, var(--fui-color-surface) 75%, black));

Se --fui-color-shadow não existir, o browser deriva uma sombra plausível a partir da própria cor de superfície — nunca preto-sobre-preto, mesmo em customização de última hora.

Cinco morfismos, o mesmo componente, luz e escuro ao mesmo tempo

Instalar e importar o pacote já aplica o morfismo a todos os componentes. Sem .fui-glass, sem .fui-skeu no HTML — o tema é o pacote, não uma classe.

Cada morfismo é um pacote próprio (@allangboliveira/ui-theme-{glass,clay,neu,skeu}), com paridade completa de componentes: button, card, input, checkbox, radio, select, tabs, navbar, pagination, tag, content. A mesma marcação HTML muda de identidade inteira conforme qual pacote está instalado. As capturas abaixo são todas da mesma seção da página de testes do sistema — a tabela de estados e de cores semânticas do botão — com light e dark lado a lado na mesma imagem, porque comparar os dois modos ao mesmo tempo é o teste visual mais honesto que existe pra um design system multi-tema.

Skeuomorfismo — hardware físico

Perspectiva lateral, como um teclado físico: gradiente convexo, quatro camadas de sombra para transição suave entre estados, e um sinal de "pressionado" real — translateY(1px) combinado com border-style: inset e o gradiente invertido, não só uma sombra menor.

Botão skeuomórfico com gradiente convexo, capturado em light e dark lado a lado na mesma tela de testes
Botão skeuomórfico com gradiente convexo, capturado em light e dark lado a lado na mesma tela de testes

O card skeu vai além do botão: simula um índice físico 3×5 polegadas, com linhas de pauta horizontais ligadas por padrão (fui-card-lined) e variantes de cor de papel (new, cream, aged, dirty, yellow, blue) — inclusive uma linha vertical de margem opcional (fui-card-margin, o efeito "caderno universitário").

Glassmorfismo — vidro e translucidez

backdrop-filter: blur() de verdade, com opacidade calibrada por componente — 15% no card contra 50% no botão, porque a área de leitura de um card precisa de mais transparência de fundo sem perder legibilidade do texto sobreposto.

Botão glassmórfico translúcido com backdrop-filter, capturado em light e dark lado a lado
Botão glassmórfico translúcido com backdrop-filter, capturado em light e dark lado a lado

Claymorfismo — argila inflada

Sombras HSL coloridas (não cinza) derivadas da própria cor do componente, raios de borda generosos (26–50px) e uma escala de profundidade progressiva — o efeito "inflado" que dá nome ao morfismo.

Botão claymórfico com sombra colorida e bordas bem arredondadas, capturado em light e dark lado a lado
Botão claymórfico com sombra colorida e bordas bem arredondadas, capturado em light e dark lado a lado

Neumorfismo — plástico extrudado

Perspectiva top-down (câmera de cima, não de lado — por isso sem translateY no estado ativo, diferente do skeu). Fundo obrigatoriamente igual à cor de superfície, com sombra dupla via color-mix() simulando luz vinda de um único ângulo.

Botão neumórfico extrudado do próprio fundo, capturado em light e dark lado a lado
Botão neumórfico extrudado do próprio fundo, capturado em light e dark lado a lado

A própria página de testes documenta uma limitação real desse morfismo, direto num aviso visível acima da tabela de cores semânticas: nas variantes soft e strong, o fundo é sempre transparente e a cor do papel vira a cor do texto — e nem toda combinação de soft (degrau 400) ou strong (dessaturado) passa WCAG AA. A recomendação documentada é usar o tier padrão quando contraste garantido importa mais que a variação visual. Prefiro deixar essa limitação exposta a esconder atrás de um componente que parece perfeito e não é.

Flat — a base sem morfismo

Sem pacote de tema instalado, @allangboliveira/ui-themes-base sozinho já é um design system completo e utilizável — flat, sem sombra decorativa, só os tokens e a base. É o morfismo que quase ninguém "escolhe" conscientemente, mas que precisa funcionar perfeitamente sozinho, porque é o piso sobre o qual todos os outros quatro são construídos. A screenshot dela é a que abre este case study, ali na barra lateral desta página.

border-aware-padding — quando a borda rouba espaço da borda

Um bug sutil de consistência: dois botões com o mesmo padding declarado, um sem borda e outro com border: 2px solid, renderizam com áreas internas visualmente diferentes — a borda "come" espaço do padding, então o texto do botão com borda mais grossa parece mais apertado.

A correção virou um mixin usado em todo componente com borda variável entre morfismos:

@mixin border-aware-padding($padding-base, $border-ref, $border-actual) {
  padding: calc(#{$padding-base} + #{$border-ref} - #{$border-actual});
}

A fórmula compensa a diferença entre uma borda de referência (a que o design foi calibrado para) e a borda real do morfismo atual — skeu e clay tendem a usar bordas mais grossas para o efeito de relevo, glass quase não usa borda nenhuma. Sem esse mixin, cada morfismo precisaria de um valor de padding manualmente recalibrado; com ele, o mesmo token de espaçamento produz a mesma área interna percebida em qualquer morfismo.

Testado a sério, não só compilado

  • 307 testes via sass-true rodando dentro do Jest — funções puras (px-to-rem, map-range, fluid), mixins (transition respeitando prefers-reduced-motion), tokens (paleta, opacidade, raio, tipografia) e os testes de contraste WCAG já descritos.
  • Stylelint + Prettier obrigatórios no CI — formatação e convenções de SCSS não dependem de review manual.
  • IBM Equal Access guiou correções reais de acessibilidade na própria lib, não só no consumidor: indicadores que dependiam só de background-image ganharam fallback em forced-colors: active (Windows High Contrast Mode não renderiza background-image decorativo), e o _content.scss nunca declara background-color nem color no <pre> — essa responsabilidade fica exclusivamente com o Shiki do lado consumidor, exatamente a mesma regra de divisão que documentei aqui no ao.dev.

CI/CD — ordem de deploy é regra, não sugestão

O monorepo publica cinco pacotes independentes, cada um com sua própria tag de versão:

TagPublica
base/v0.3.0-canary.36@allangboliveira/ui-themes-base
theme-glass/v0.2.0-canary.17@allangboliveira/ui-theme-glass
theme-clay/v0.2.0-canary.18@allangboliveira/ui-theme-clay
theme-neu/v0.2.0-canary.18@allangboliveira/ui-theme-neu
theme-skeu/v0.2.0-canary.17@allangboliveira/ui-theme-skeu
git tag base/v0.3.0 && git push origin base/v0.3.0
# dispara o workflow "CD — Publish", que roda lint + test
# + compile check antes de qualquer `npm publish`

Duas regras de disciplina que aprendi a sério, não de manual:

  1. Um único commit para todo bump de versão entre pacotes que dependem uns dos outros — evita um tema publicado apontando pra uma versão de base que ainda não existe no registry.
  2. No máximo dois morfismos publicados em paralelo — publicar os quatro ao mesmo tempo multiplica a superfície de algo dar errado numa janela só, sem ganho real de velocidade.

Todo push (não só em main) já roda o job Compile, Lint & Test — compila os cinco pacotes com sass, checa formatação, lint e os 307 testes. Nenhuma branch de feat/, fix/, chore/, refactor/ ou docs/ escapa disso. E desde abril, um ruleset ativo no repositório bloqueia push direto em main — até para mim: qualquer mudança, mesmo de documentação, precisa passar por Pull Request com o check verde. É a mesma disciplina que peço em qualquer projeto de cliente, só que aqui automatizada e inegociável.

fui-content — o wrapper que dá nome ao projeto

O nome do design system inteiro vem de uma única classe: .fui-content, o wrapper de contexto de leitura que estiliza qualquer HTML gerado por CMS, editor rico ou pipeline Markdown — h2, p, pre, blockquote, kbd, mark, figure, tudo, sem depender de nome de framework. É literalmente o que renderiza este parágrafo que você está lendo agora, dentro do <article class="fui-content"> do ao.dev.

Toda propriedade visual do wrapper é uma CSS custom property, e os morfismos sobrescrevem só as variáveis — nunca seletor, nunca propriedade direta:

.fui-content {
  --fui-content-code-bg:        var(--fui-color-surface-alt);
  --fui-content-code-shadow:    none;   // hook de morfismo
  --fui-content-code-backdrop:  none;   // hook de morfismo (glass)
  --fui-content-quote-accent:   var(--fui-color-primary);
  --fui-content-mark-bg-image:  none;   // hook de morfismo (gradiente "caneta")
  --fui-content-hr-shadow:      none;   // hook de morfismo (efeito sulco)
}

Cada // hook de morfismo é uma var que só ganha valor real dentro do arquivo _content.scss específico de cada tema — o base declara a variável vazia, o morfismo a preenche. .fui-content nunca precisa saber qual morfismo está ativo; só expõe os pontos de extensão.

O que virou ao.dev

Esse projeto e o portfólio que você está navegando nasceram como o mesmo projeto na prática — cada componente que termino aqui desbloqueia uma página real do ao.dev. A ordem de prioridade nunca foi "o que é mais interessante de construir", foi sempre "o que a próxima página do portfólio precisa": Button primeiro (usado em toda parte), Card em seguida (toda listagem), Badge/Tag (dentro de todo card), Navbar, campos de formulário, Timeline (a página Sobre), Meta block e Breadcrumb (case study), Code block (blog técnico), Avatar.

O seletor de morfismo no topo deste próprio site — a engrenagem que troca entre skeu, glass, neu, clay e flat em tempo real — é o teste de aceitação mais honesto que a lib podia ter: se trocar de tema aqui, ao vivo, sem reload, sem flash, sem quebrar contraste em nenhum dos cinco, o design system está fazendo o que prometeu.

O que ainda falta

O roadmap documentado inclui uma camada "pro" opcional — texturas procedurais via filter + SVG (feTurbulence/feDisplacementMap) aplicadas em pseudo-elemento: granulado fosco pro glass, textura de plástico/borracha pro neu, metal/couro/madeira pro skeu, microgrão pro clay. Nada disso foi implementado ainda — a decisão deliberada foi terminar paridade de componentes nos cinco morfismos primeiro, e só então considerar a camada de textura como pacote adicional opcional (.fui-texture), sem tocar no comportamento base.

Este repositório estava marcado como privado desde o primeiro commit — parte do processo de publicar este próprio portfólio foi justamente tirar essa lib do armário: remover o private, girar os tokens que só faziam sentido enquanto ninguém mais podia ver o código, e deixar público o design system que sustenta, literalmente, a página que você está lendo agora.