# 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.

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ô

<table>
<thead>
  <tr>
    <th>
      Camada
    </th>
    
    <th>
      Tecnologia
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Linguagem
    </td>
    
    <td>
      <a href="https://sass-lang.com/documentation/" rel="nofollow" target="_blank">
        SCSS/Sass
      </a>
      
       — <code>
        @use
      </code>
      
      , <code>
        @forward
      </code>
      
      , funções e mixins nativos
    </td>
  </tr>
  
  <tr>
    <td>
      Monorepo
    </td>
    
    <td>
      <a href="https://docs.npmjs.com/cli/v10/using-npm/workspaces" rel="nofollow" target="_blank">
        npm workspaces
      </a>
      
       — 5 pacotes publicados independentemente
    </td>
  </tr>
  
  <tr>
    <td>
      Distribuição
    </td>
    
    <td>
      <a href="https://docs.github.com/en/packages" rel="nofollow" target="_blank">
        GitHub Packages
      </a>
      
       — registry npm privado por pacote
    </td>
  </tr>
  
  <tr>
    <td>
      Testes
    </td>
    
    <td>
      <a href="https://www.oddbird.net/true/" rel="nofollow" target="_blank">
        sass-true
      </a>
      
       via Jest — 307 testes, incluindo contraste WCAG calculado
    </td>
  </tr>
  
  <tr>
    <td>
      Qualidade
    </td>
    
    <td>
      <a href="https://stylelint.io/" rel="nofollow" target="_blank">
        Stylelint
      </a>
      
       + <a href="https://prettier.io/" rel="nofollow" target="_blank">
        Prettier
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      CI/CD
    </td>
    
    <td>
      <a href="https://docs.github.com/en/actions" rel="nofollow" target="_blank">
        GitHub Actions
      </a>
      
       — build/lint/test em todo push, publish por tag
    </td>
  </tr>
  
  <tr>
    <td>
      Preview local
    </td>
    
    <td>
      Docker + Traefik, servido em <code>
        fui.localhost
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Commits
    </td>
    
    <td>
      <a href="https://www.conventionalcommits.org/" rel="nofollow" target="_blank">
        Conventional Commits
      </a>
      
       via Husky + commitlint
    </td>
  </tr>
</tbody>
</table>

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`](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer), declarados uma única vez, logo no primeiro arquivo que qualquer consumidor carrega:

```scss
// 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. **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](https://github.com/AllanGBOliveira/ao.dev) 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](https://developer.mozilla.org/en-US/docs/Web/CSS/--*). 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.

```scss
// 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))
);
```

```css
/* 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.

```scss
// 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:

```bash
$ 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:

<table>
<thead>
  <tr>
    <th>
      Papel
    </th>
    
    <th>
      Light
    </th>
    
    <th>
      Dark
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        surface
      </code>
    </td>
    
    <td>
      <code>
        neutral-50
      </code>
    </td>
    
    <td>
      <code>
        neutral-800
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        surface-alt
      </code>
    </td>
    
    <td>
      <code>
        neutral-100
      </code>
    </td>
    
    <td>
      <code>
        neutral-700
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        surface-raised
      </code>
    </td>
    
    <td>
      <code>
        #fff
      </code>
    </td>
    
    <td>
      <code>
        neutral-600
      </code>
    </td>
  </tr>
</tbody>
</table>

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:

```scss
// 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:

```css
--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.

<figure className="rehype-figure">

![Botão skeuomórfico com gradiente convexo, capturado em light e dark lado a lado na mesma tela de testes](/images/pages/projects/fui-content/fui-content-skeu.png)<figcaption>

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

</figcaption>
</figure>

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.

<figure className="rehype-figure">

![Botão glassmórfico translúcido com backdrop-filter, capturado em light e dark lado a lado](/images/pages/projects/fui-content/fui-content-glass.png)<figcaption>

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

</figcaption>
</figure>

### 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.

<figure className="rehype-figure">

![Botão claymórfico com sombra colorida e bordas bem arredondadas, capturado em light e dark lado a lado](/images/pages/projects/fui-content/fui-content-clay.png)<figcaption>

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

</figcaption>
</figure>

### 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.

<figure className="rehype-figure">

![Botão neumórfico extrudado do próprio fundo, capturado em light e dark lado a lado](/images/pages/projects/fui-content/fui-content-neu.png)<figcaption>

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

</figcaption>
</figure>

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:

```scss
@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](https://www.oddbird.net/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:

<table>
<thead>
  <tr>
    <th>
      Tag
    </th>
    
    <th>
      Publica
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        base/v0.3.0-canary.36
      </code>
    </td>
    
    <td>
      <code>
        @allangboliveira/ui-themes-base
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        theme-glass/v0.2.0-canary.17
      </code>
    </td>
    
    <td>
      <code>
        @allangboliveira/ui-theme-glass
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        theme-clay/v0.2.0-canary.18
      </code>
    </td>
    
    <td>
      <code>
        @allangboliveira/ui-theme-clay
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        theme-neu/v0.2.0-canary.18
      </code>
    </td>
    
    <td>
      <code>
        @allangboliveira/ui-theme-neu
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        theme-skeu/v0.2.0-canary.17
      </code>
    </td>
    
    <td>
      <code>
        @allangboliveira/ui-theme-skeu
      </code>
    </td>
  </tr>
</tbody>
</table>

```bash
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:

```scss
.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.
