# fui-content

> SCSS design system that styles native HTML with five interchangeable morphisms — glass, clay, neu, skeu and flat — WCAG AA contrast computed at compile time, and zero extra classes in the HTML. It's the library that dresses this very portfolio.

Everything else in this portfolio — every `fui-btn`, every `fui-card`, every theme switch in the morphism picker up top — comes from here. `fui-content` is the SCSS design system I started building in April 2026: a monorepo with tokens, utilities, and five complete visual morphisms, published as independent packages on npm. This is both the most technical and the most personal project in this portfolio — no client, no agency deadline, just the question that started it: can you have five completely different visual identities — glass, clay, extruded plastic, physical hardware, pure flat — by swapping **a single import**, without duplicating HTML, without coupling to a framework, and without sacrificing WCAG contrast in any of them?

The answer, after more than 170 commits, is yes. And the process of getting there taught me more about modern CSS than any client project I've ever touched.

## Under the hood

<table>
<thead>
  <tr>
    <th>
      Layer
    </th>
    
    <th>
      Technology
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Language
    </td>
    
    <td>
      <a href="https://sass-lang.com/documentation/" rel="nofollow" target="_blank">
        SCSS/Sass
      </a>
      
       — native <code>
        @use
      </code>
      
      , <code>
        @forward
      </code>
      
      , functions and mixins
    </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 independently published packages
    </td>
  </tr>
  
  <tr>
    <td>
      Distribution
    </td>
    
    <td>
      <a href="https://docs.github.com/en/packages" rel="nofollow" target="_blank">
        GitHub Packages
      </a>
      
       — per-package private npm registry
    </td>
  </tr>
  
  <tr>
    <td>
      Tests
    </td>
    
    <td>
      <a href="https://www.oddbird.net/true/" rel="nofollow" target="_blank">
        sass-true
      </a>
      
       via Jest — 307 tests, including computed WCAG contrast
    </td>
  </tr>
  
  <tr>
    <td>
      Quality
    </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 on every push, publish per tag
    </td>
  </tr>
  
  <tr>
    <td>
      Local preview
    </td>
    
    <td>
      Docker + Traefik, served at <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>

The core idea is simple to state and hard to sustain: **one theme equals one morphism**. No component ever carries a morphism class (`.fui-glass .fui-btn` doesn't exist) — installing `@allangboliveira/ui-theme-glass` and toggling `data-theme` is enough for the entire component vocabulary (`fui-btn`, `fui-card`, `fui-input`...) to become glass. Switching morphism in production means swapping a package, not rewriting HTML.

## Six layers, an order that can't break

The system's entire CSS is organized under [`@layer`](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer), declared exactly once, in the very first file any consumer loads:

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

The order between layers is the single most important rule in the whole project — and the easiest one to break by accident:

1. **tokens** — compile-time Sass variables (palette, spacing, typography)
2. **utilities** — atomic classes (`fui-bg-primary`, `fui-p-4`, `fui-shadow-md`)
3. **layout** — grid, breakpoints, container
4. **themes** — theme generation (`[data-theme="light"]` / `[data-theme="dark"]`)
5. **base** — reset and plain HTML element styles
6. **components** — `fui-btn`, `fui-card`, `fui-input`...

`@layer` resolves in *declaration* order, not specificity — so once fixed, this list is a contract: if a theme package declared its own `@layer` order different from the base's, the browser would resolve the cascade wrong depending on which `<link>` the parser reached first. (This exact category of bug — non-deterministic `@layer` order — is the same class of problem I later solved on the consumer side, here on ao.dev, documented in the [project's own CLAUDE.md](https://github.com/AllanGBOliveira/ao.dev) as a "real bleed bug on cold reload." A lesson from one project became structural prevention in the other.)

## Two-layer tokens — Sass at compile time, CSS custom properties at runtime

Every visual value in the system is born as a Sass variable and emitted as a [CSS custom property](https://developer.mozilla.org/en-US/docs/Web/CSS/--*). The duality is deliberate: Sass resolves functions, map deep-merges, and contrast calculations **before** a single byte reaches the browser; the CSS var exists so a consumer can override at runtime without recompiling Sass.

```scss
// packages/base/tokens/_colors.scss — consumer customization
$palette-overrides: () !default;

// ao.dev customizes it this way, in fui.scss:
@use '@allangboliveira/ui-themes-base' with (
  $palette-overrides: ("brand": (500: #ff6b35, 600: #e55a2b))
);
```

```css
/* what reaches the browser, already resolved */
:root {
  --fui-color-primary: #7c3aed;
  --fui-color-on-primary: #fafafa; /* computed, not hardcoded — see next section */
}
```

Two customization levels coexist by design:

- **Sass compile-time** — `$palette-overrides` and functions like `shadow-color-for()`/`text-color-for()`: automatic derivation, full recalculation span (text color, shadow color, everything readjusts).
- **CSS runtime** — overriding `--fui-color-surface` directly via `<style>`: the cascade always wins, but only what has a `color-mix()` fallback (like shadows) readapts itself. Text does **not** — CSS still has no real supported `color-contrast()`, so overriding surface color at runtime requires manually updating `--fui-color-on-surface`. I documented this asymmetry explicitly so it wouldn't become a surprise for anyone consuming the library.

## A palette with guaranteed WCAG contrast — computed, not guessed

This was the single most interesting problem in the whole project. Every design system palette carries the same risk: someone picks `blue-500` as text color over a `blue-600` background, it looks great in Figma, and it silently fails WCAG AA (4.5:1) until an accessibility audit catches it.

The fix here was to eliminate the guesswork entirely: a Sass function computes relative luminance and contrast ratio at compile time, and decides on its own whether text should be light or dark.

```scss
// packages/base/utilities/_a11y.scss
@function luminance($color) {
  // relative luminance formula (WCAG 2.1)
  $r: red($color) / 255;
  $g: green($color) / 255;
  $b: blue($color) / 255;
  // ... per-channel gamma correction
  @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) {
  // returns neutral-50 or neutral-950 — whichever has HIGHER contrast against $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);
}
```

The practical result: **88 button variants with guaranteed contrast** — 8 color scales × 11 steps (`50` through `950`) — every single one tested. The test suite (`sass-true` via Jest) runs this check against every real combination:

```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 steps each, all compliant)

Tests:       307 passed, 307 total
```

This isn't a visual snapshot test — it's a mathematical test running the exact contrast formula WCAG defines, against every real cell of the palette, every time someone runs `npm test`. If I add a new color to the palette someday and it fails contrast at some step, CI catches it before I'd even notice visually.

## Shadow and surface — the problem pure black was hiding

One of the project's first real bugs: in dark mode, `box-shadow` over `neutral-900` (near-pure black) is **invisible** — black on black, even with generous blur. Cards and buttons lost all visual elevation the moment a user switched to dark.

The fix wasn't blindly cranking up blur or opacity — it was recalibrating the surface steps themselves:

<table>
<thead>
  <tr>
    <th>
      Role
    </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>

Moving dark mode's base from `neutral-900`/`950` to `neutral-800` (a gray, not a near-black) opened up room for shadows to stand out against. In parallel, I created a semantic `color-shadow` token — light uses `rgba(0,0,0,0.15)`, dark uses `rgba(0,0,0,0.55)` — because the same shadow opacity that's subtle over white becomes imperceptible over dark gray. A function (`shadow-color-for($bg)`) derives that opacity automatically from the background's luminance, with specific thresholds:

```scss
// luminance < 0.03 → opacity 0.55
// luminance < 0.10 → opacity 0.45
// luminance < 0.30 → opacity 0.30
// luminance < 0.65 → opacity 0.20
// otherwise         → opacity 0.15
```

And as a safety net for whenever a consumer overrides `--fui-color-surface` via plain CSS without knowing about the Sass function, every `box-shadow` in the system carries a `color-mix()` fallback:

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

If `--fui-color-shadow` doesn't exist, the browser derives a plausible shadow straight from the surface color itself — never black-on-black, even under last-minute customization.

## Five morphisms, the same component, light and dark at once

> Installing and importing the package already applies the morphism to every component. No `.fui-glass`, no `.fui-skeu` in the HTML — the theme is the package, not a class.

Each morphism is its own package (`@allangboliveira/ui-theme-{glass,clay,neu,skeu}`), with full component parity: button, card, input, checkbox, radio, select, tabs, navbar, pagination, tag, content. The exact same HTML markup changes its entire identity depending on which package is installed. Every screenshot below is the same section of the system's test page — the button's states and semantic-colors tables — with **light and dark side by side in the same image**, because comparing both modes at once is the most honest visual test a multi-theme design system can get.

### Skeuomorphism — physical hardware

Side-lit perspective, like a physical keyboard key: convex gradient, four shadow layers for smooth transitions between states, and a real "pressed" signal — `translateY(1px)` combined with `border-style: inset` and a flipped gradient, not just a smaller shadow.

<figure className="rehype-figure">

![Skeuomorphic button with convex gradient, captured in light and dark side by side on the same test screen](/images/pages/projects/fui-content/fui-content-skeu.png)<figcaption>

Skeuomorphic button with convex gradient, captured in light and dark side by side on the same test screen

</figcaption>
</figure>

The skeu card goes further than the button: it simulates a physical 3×5 inch index card, with **horizontal ruled lines on by default** (`fui-card-lined`) and paper-color variants (`new`, `cream`, `aged`, `dirty`, `yellow`, `blue`) — including an optional vertical margin line (`fui-card-margin`, the "college-ruled notebook" effect).

### Glassmorphism — glass and translucency

Real `backdrop-filter: blur()`, opacity calibrated per component — 15% on the card versus 50% on the button, because a card's reading area needs more background transparency without losing legibility of overlaid text.

<figure className="rehype-figure">

![Translucent glassmorphic button with backdrop-filter, captured in light and dark side by side](/images/pages/projects/fui-content/fui-content-glass.png)<figcaption>

Translucent glassmorphic button with backdrop-filter, captured in light and dark side by side

</figcaption>
</figure>

### Claymorphism — inflated clay

Colored HSL shadows (not gray) derived from the component's own color, generous border radii (26–50px), and a progressive depth scale — the "inflated" effect that gives the morphism its name.

<figure className="rehype-figure">

![Claymorphic button with colored shadow and generously rounded corners, captured in light and dark side by side](/images/pages/projects/fui-content/fui-content-clay.png)<figcaption>

Claymorphic button with colored shadow and generously rounded corners, captured in light and dark side by side

</figcaption>
</figure>

### Neumorphism — extruded plastic

Top-down perspective (camera from above, not from the side — which is why the active state has **no** `translateY`, unlike skeu). Background must equal the surface color, with a dual shadow via `color-mix()` simulating light from a single angle.

<figure className="rehype-figure">

![Neumorphic button extruded from its own background, captured in light and dark side by side](/images/pages/projects/fui-content/fui-content-neu.png)<figcaption>

Neumorphic button extruded from its own background, captured in light and dark side by side

</figcaption>
</figure>

The test page itself documents a real limitation of this morphism, right in a visible warning above the semantic-colors table: in the `soft` and `strong` variants, the background is always transparent and the role's own color becomes the text color — and not every `soft` (step 400) or `strong` (desaturated) combination passes WCAG AA. The documented recommendation is to use the standard tier when guaranteed contrast matters more than visual variation. I'd rather leave that limitation exposed than hide it behind a component that looks perfect and isn't.

### Flat — the base without morphism

With no theme package installed, `@allangboliveira/ui-themes-base` alone is already a complete, usable design system — flat, no decorative shadow, just tokens and the base. It's the morphism almost nobody consciously "picks," but it needs to work flawlessly on its own, because it's the floor all four others are built on. Its screenshot is the one that opens this case study, over in this page's sidebar.

## `border-aware-padding` — when the border steals space from the padding

A subtle consistency bug: two buttons with the exact same declared `padding`, one with no border and one with `border: 2px solid`, render with visibly different inner areas — the border "eats" padding space, so the thicker-bordered button's text looks more cramped.

The fix became a mixin used in every component with a border that varies between morphisms:

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

The formula compensates for the difference between a reference border (the one the design was calibrated for) and the current morphism's actual border — skeu and clay tend to use thicker borders for the relief effect, glass barely uses a border at all. Without this mixin, every morphism would need a manually recalibrated padding value; with it, the same spacing token produces the same perceived inner area across any morphism.

## Actually tested, not just compiled

- **307 tests** via [sass-true](https://www.oddbird.net/true/) running inside Jest — pure functions (`px-to-rem`, `map-range`, `fluid`), mixins (`transition` respecting `prefers-reduced-motion`), tokens (palette, opacity, radius, typography), and the WCAG contrast tests already described.
- **Stylelint + Prettier** enforced in CI — SCSS formatting and conventions don't depend on manual review.
- **IBM Equal Access** drove real accessibility fixes in the library itself, not just in the consumer: indicators that relied solely on `background-image` gained a `forced-colors: active` fallback (Windows High Contrast Mode doesn't render decorative `background-image`), and `_content.scss` never declares `background-color` or `color` on `<pre>` — that responsibility stays **exclusively** with Shiki on the consumer side, the exact same division-of-responsibility rule documented here on ao.dev.

## CI/CD — deploy order is a rule, not a suggestion

The monorepo publishes five independent packages, each with its own version tag:

<table>
<thead>
  <tr>
    <th>
      Tag
    </th>
    
    <th>
      Publishes
    </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
# triggers the "CD — Publish" workflow, which runs lint + test
# + a compile check before any `npm publish`
```

Two discipline rules I learned the hard way, not from a manual:

1. **A single commit for any version bump across packages that depend on each other** — avoids a published theme pointing at a `base` version that doesn't exist in the registry yet.
2. **No more than two morphisms published in parallel** — publishing all four at once multiplies the surface area for something to go wrong in a single window, with no real speed gain.

Every push (not just to `main`) already runs the `Compile, Lint & Test` job — compiling all five packages with `sass`, checking formatting, lint, and the 307 tests. No `feat/`, `fix/`, `chore/`, `refactor/`, or `docs/` branch escapes it. And since April, an active ruleset on the repository blocks direct pushes to `main` — even for me: any change, even a documentation one, has to go through a Pull Request with a green check. It's the same discipline I ask for on any client project, just automated and non-negotiable here.

## `fui-content` — the wrapper the project is named after

The entire design system's name comes from a single class: `.fui-content`, the reading-context wrapper that styles any HTML generated by a CMS, rich editor, or Markdown pipeline — `h2`, `p`, `pre`, `blockquote`, `kbd`, `mark`, `figure`, all of it, with no dependency on any framework name. It's literally what renders this very paragraph you're reading right now, inside ao.dev's `<article class="fui-content">`.

Every visual property of the wrapper is a CSS custom property, and morphisms only ever override the variables — never a selector, never a property directly:

```scss
.fui-content {
  --fui-content-code-bg:        var(--fui-color-surface-alt);
  --fui-content-code-shadow:    none;   // morphism hook
  --fui-content-code-backdrop:  none;   // morphism hook (glass)
  --fui-content-quote-accent:   var(--fui-color-primary);
  --fui-content-mark-bg-image:  none;   // morphism hook ("ink" gradient)
  --fui-content-hr-shadow:      none;   // morphism hook (groove effect)
}
```

Every `// morphism hook` is a var that only gets a real value inside each theme's specific `_content.scss` file — the base declares the variable empty, the morphism fills it in. `.fui-content` never needs to know which morphism is active; it only exposes the extension points.

## What it became: ao.dev

This project and the portfolio you're browsing were, in practice, the same project from the start — every component I finish here unlocks a real page on ao.dev. Priority order was never "what's most interesting to build," it was always "what does the next portfolio page need": Button first (used everywhere), Card next (every listing), Badge/Tag (inside every card), Navbar, form fields, Timeline (the About page), Meta block and Breadcrumb (case study), Code block (technical blog), Avatar.

The morphism switcher at the top of this very site — the gear icon that swaps between skeu, glass, neu, clay, and flat in real time — is the most honest acceptance test the library could have: if switching theme here, live, with no reload, no flash, and no broken contrast in any of the five, the design system is doing exactly what it promised.

## What's still missing

The documented roadmap includes an optional "pro" layer — procedural textures via `filter` + SVG (`feTurbulence`/`feDisplacementMap`) applied on a pseudo-element: frosted grain for glass, plastic/rubber texture for neu, metal/leather/wood for skeu, fine grain for clay. None of it is implemented yet — the deliberate call was to finish component parity across all five morphisms first, and only then consider the texture layer as an optional add-on package (`.fui-texture`), without touching base behavior.

This repository was marked private since its very first commit — part of the process of publishing this portfolio was pulling this library out of the closet: dropping `private`, rotating the tokens that only made sense while nobody else could see the code, and open-sourcing the design system that literally powers the page you're reading right now.
