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
PessoalPeríodo
2026 – atualPapel
Criador, MantenedorTodo 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ô
| Camada | Tecnologia |
|---|---|
| Linguagem | SCSS/Sass — @use, @forward, funções e mixins nativos |
| Monorepo | npm workspaces — 5 pacotes publicados independentemente |
| Distribuição | GitHub Packages — registry npm privado por pacote |
| Testes | sass-true via Jest — 307 testes, incluindo contraste WCAG calculado |
| Qualidade | Stylelint + Prettier |
| CI/CD | GitHub Actions — build/lint/test em todo push, publish por tag |
| Preview local | Docker + Traefik, servido em fui.localhost |
| Commits | Conventional 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:
- tokens — variáveis SCSS de compile-time (paleta, espaçamento, tipografia)
- utilities — classes atômicas (
fui-bg-primary,fui-p-4,fui-shadow-md) - layout — grid, breakpoints, container
- themes — geração dos temas (
[data-theme="light"]/[data-theme="dark"]) - base — reset e estilos de elemento HTML puro
- components —
fui-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-overridese funções comoshadow-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-surfacedireto via<style>: a cascata sempre vence, mas só o que tem fallback viacolor-mix()(como a sombra) se readapta sozinho. Texto não — CSS não temcolor-contrast()com suporte real ainda, então sobrescrever cor de superfície em runtime exige atualizar--fui-color-on-surfacemanualmente. 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:
| Papel | Light | Dark |
|---|---|---|
surface | neutral-50 | neutral-800 |
surface-alt | neutral-100 | neutral-700 |
surface-raised | #fff | neutral-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-skeuno 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.

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.

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.

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.

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 (transitionrespeitandoprefers-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-imageganharam fallback emforced-colors: active(Windows High Contrast Mode não renderizabackground-imagedecorativo), e o_content.scssnunca declarabackground-colornemcolorno<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:
| Tag | Publica |
|---|---|
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:
- 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
baseque ainda não existe no registry. - 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.