# LI.FI Brand Style Guide 2.0

> Modern & Institutional — Mar 2026

LI.FI evolves from bridge aggregator to universal execution layer — modern, institutional, and developer-trusted. This document defines the visual language, tone, and design decisions that make that positioning tangible across every surface.

---

## 01 — Positioning & Direction

### Category Position

**The Universal Execution Layer.**

One integration connects any application to all on-chain liquidity — across every chain, bridge, DEX, and yield protocol. For wallets, DeFi protocols, payments, neobanks, RWA platforms, and AI agents.

### Brand Values

**Precision** — Every route, route quote, and execution is optimised to the basis point. We don't approximate.

**Universality** — 60+ chains, 20+ bridges, 20+ DEXs. The most complete coverage in the market.

**Reliability** — Enterprise-grade infrastructure trusted by Coinbase, MetaMask, and Rainbow at scale.

**Forward Motion** — AI-native APIs, MCP Server, agentic commerce — we're building for where crypto is going.

### 6 Use Case Pillars

Each use case has a distinct audience, tone, and color signal. The core LI.FI Blue unifies all verticals — the accent color differentiates them.

| Use Case | Audience | Tagline | Accent Color |
|---|---|---|---|
| Wallets | Wallet dev teams | Invisible bridging. Native experience. | LI.FI Blue `#0047FF` |
| DeFi Protocols | Protocol engineers | One transaction. Any chain. Any action. | Protocol Purple `#7C3AED` |
| Payments | Fintech builders | Stablecoin rails, built for the real world. | Payments Green `#10B981` |
| Neobanks | Crypto banking teams | Banking superpowers for self-custodial users. | Neobank Gold `#F59E0B` |
| RWA | TradFi / institutions | Any asset. Any chain. Institutional grade. | Foundation Ink `#0B0D12` |
| AI Agents | AI / agent builders | Let your agents act, not just think. | LI.FI Teal `#00BFA5` |

### Competitive Differentiation Map

LI.FI 2.0 occupies the visual sweet spot between two extremes:

**0x — Too Minimal:** Black/gray/white only, zero accent color, no visual warmth, no motion language, no brand memory. Positions as raw infrastructure APIs.

**Wormhole — Too Dark:** Dark/black foundation with plum, coral, and lime accents. Motion-rich but not codified. Hard to enterprise-sell.

**LI.FI — Sweet Spot:** Pale-paper institutional-first foundation, Sapphire `#405CCF` action + Pink `#F7C2FF` signature on Ink fields, codified motion system, precise + warm + enterprise-ready. Geist + Geist Mono as a cohesive two-family system.

---

## 02 — Color System

Built on a precise, institutional palette. Sapphire (`#405CCF`) is the action colour — links, CTAs, data. One rationed Pink (`#F7C2FF`) is the signature. All grounded in Ink fields on Paper.

> **Figma colour sync — one-way.** Figma's colour variables are a one-way rendering of `styles.css`, not a second source of truth — the same canonical-source-vs-rendering family as the catalog and the dashboard snapshot. The source of truth stays in `styles.css`; fix a colour there and re-sync, never hand-edit it in Figma. Canonical rule: `CLAUDE.md → "Figma colour sync — one-way from styles.css"`. Full pipeline + roadmap: `design/figma-sync.md`.

### LI.FI 1.0 is the only default — never auto-apply any non-default preset

**Hard rule, no exceptions.** Every page across the site renders LI.FI 1.0 on first paint. The CSS `:root` block in `styles.css` is the sole source of first-paint colours and it always paints the approved LI.FI brand — Sapphire action / Pink signature / Ink fields / Paper light surface, Geist + Geist Mono (the legacy token names hold the new roles: `--lifi-sapphire` = Sapphire, `--lifi-pink` = Pink, `--lifi-ink` = Ink):

```css
:root {
  --lifi-sapphire:       oklch(52% 0.18 269);    /* primary — Sapphire #405CCF (native, both modes; dark lift dropped Jun 2026 so white CTA labels flip + AA) */
  --lifi-sapphire-mid:   oklch(62% 0.1584 269);  /* = seed+10L (deriveTokens runtime); accent text/links — 5.16:1 on dark ink */
  --lifi-sapphire-light: oklch(96% 0.0234 269);
  --lifi-pink:       oklch(88% 0.1 322);     /* secondary — Pink #F7C2FF */
  --lifi-ink:       oklch(66% 0.063 276);   /* tertiary — Ink */
}
```

Other brands — the old blue/Figtree direction and the interim cobalt "2.0" preset are both retired; Jumper, partner, and competitor presets live in `presets.js` and stay selectable from the **Theme Composer** — the change applies in the current session only. **The page never auto-restores a non-default preset from localStorage on reload.**

**Why this rule (Apr 2026 v3 — bug history).**

- **v1 (early DS).** `lifi-design-system.css` was a separate stylesheet that redefined `:root` with LI.FI 2.0 hex values. The browser parsed it before `styles.css` finished cascading; first paint always showed 2.0 even though `styles.css` immediately overrode it. Visible flash on every load.
- **v2 fix (commit `7a9e836`, Apr 2026).** Removed the link to `lifi-design-system.css` from every DS page. `:root` was now LI.FI 1.0 alone — first paint was correct.
- **v3 fix (Apr 2026 — this rule).** A second FOUC source was discovered: `shared.js` loaded at the bottom of `<body>` had a "pre-paint" IIFE at the top of the file that read the brand-palette key (`brand-palette`, then named `lifi-brand-palette`) from localStorage and called `applyPalette(savedPalette)`. Because `shared.js` itself loads post-body, the IIFE actually ran *post*-paint — applying the saved palette (often 2.0 from a previous composer experiment) after the page had already painted as 1.0. Result: 1.0 → 2.0 flash on every reload.

The v3 fix removes the auto-restore entirely. CSS `:root` is the only thing that paints first; the composer is the only path to non-default; the change applies in-session; localStorage no longer drives first paint.

**Implementation contract — what code may and may not do.**

| Action | Allowed? | Notes |
|---|---|---|
| `styles.css` `:root` defines `--lifi-*` as LI.FI 1.0 OKLCH | ✅ | The canonical first-paint source. |
| Adding a stylesheet that redefines `--lifi-*` for the page default | ❌ | Re-introduces the v1 bug. |
| `shared.js` bootstrap IIFE reads `lifi-theme` and sets `data-theme` | ✅ | Theme is dark/light; orthogonal to palette. |
| `shared.js` bootstrap IIFE reads `brand-palette` and applies | ❌ | Re-introduces the v3 bug. |
| `shared.js → applyPresetById` mutates `:root` on user click | ✅ | Composer in-session change is the only intended mutator. |
| `shared.js → initBrandPalette` reads `localStorage` to populate the composer's UI | ✅ | UI hint only — doesn't apply to `:root`. |
| Adding a "Remember last session" toggle to the composer | ❌ | Without explicit brand-review approval. The default = 1.0 invariant must hold. |

**Don'ts.**

- Don't introduce a `--lifi-sapphire: #0047FF` literal anywhere in CSS (including page-local `<style>` blocks). The hex value is a brand-2.0 marker; if it appears in CSS, the page paints 2.0 in some contexts.
- Don't author "default" props on a Theme Composer setting that point at LI.FI 2.0 seeds. The default is always 1.0.
- Don't reach for `localStorage.getItem('brand-palette')` in any code path that mutates `:root` style. That key is now read only by the composer for UI population — never for page-paint.

### Primary Colors

| Name | Hex | Usage |
|---|---|---|
| Sapphire | `#405CCF` | Action — primary CTA, links, focus, data |
| Pink | `#F7C2FF` | Signature accent (≤10%) |
| Ink | `#0C0E2E` | Surfaces, text, dominant fields |
| Paper | `#FAFBFF` | Light page surface |
| Slate | `#5C6070` | Secondary text on light |

### Neutral Scale (Ink)

| Token | Hex | Usage |
|---|---|---|
| Ink 900 | `#0B0D12` | Body text, dark headers/footers |
| Ink 700 | `#1E2330` | Primary text |
| Ink 500 | `#4B5563` | Secondary text |
| Ink 400 | `#6B7280` | Muted / caption |
| Ink 300 | `#9CA3AF` | Disabled states |
| Ink 200 | `#D1D5DB` | Borders |
| Ink 100 | `#E9EBF0` | Dividers, subtle borders |
| Ink 50 | `#F7F8FA` | Alternate row backgrounds |
| Ink 0 | `#FFFFFF` | Canvas / page background |

### Semantic Colors

| Name | Token | Dark hex | Light hex | Usage |
|---|---|---|---|---|
| Success | `--success` | `#00C481` | `#009660` | Positive states, confirmations |
| Info | `--info` | `#00BAE9` | `#0094BC` | Informational notices, neutral system updates |
| Warning | `--warn` | `#E6AD00` | `#B98800` | Caution, pending states |
| Danger | `--danger` | `#F14445` | `#DD0F1A` | Failures, destructive actions |

The semantic tier is **status-only**. Don't substitute spectral hues for semantic state — `--spectral-5` is green-family, but a designer toggling chart colors shouldn't accidentally restyle every success message.

The tier is **formula-driven and tunable per theme** (June 2026 — the Semantic Composer merge): each role derives from `--brand-anchor` via per-role L-offset / C-multiplier / locked-hue coefficients (`--sem-*` in `styles.css`), and a theme record may carry a `semantics` override edited in the Theme Composer's **Semantic states** section — the fix for brand palettes that would otherwise wash a state out of its legible band. Canonical contract: `CLAUDE.md → "Theme tokens track the scope"`; catalog: `design-system/foundations.html#semantic-composer`.

### Semantic state pairs — bg / border / fg

Every semantic color also ships as a **three-token pair** for compound surfaces — alert cards, inline status banners, callout chips. The pattern matches the way SaaS systems like Vercel Geist and Stripe Dashboard expose semantic states: a deep low-chroma background, a mid-chroma border at the same hue, and the saturated foreground for icon + emphasis text.

| Role | Token shape | Lightness (dark mode) | Lightness (light mode) | Where it goes |
|---|---|---|---|---|
| Background | `--{state}-bg` | L 0.20–0.22, C 0.05 | L 0.96, C 0.04 | Card fill, banner background |
| Border | `--{state}-border` | L 0.34–0.36, C 0.10–0.12 | L 0.86–0.88, C 0.10 | 1px stroke around the card |
| Foreground | `--{state}-fg` | = base `--{state}` token | = base `--{state}` token | Icon, headline, link inside the card |

All three share the **same hue** as the base semantic token. Hue locks the relationship — the bg/border read as "tinted versions of the same color" rather than three independent decisions. Lightness + chroma do the contrast work.

**Why explicit OKLCH values, not `color-mix`** — `color-mix(in oklch, ...)` between a saturated semantic color and the surface drags the interpolated hue toward surface blue (the path through OKLab isn't constant-hue). The bg + border tokens use **explicit OKLCH literals** with H locked to the semantic hue so the tint family stays hue-faithful at any L · C.

| State | Token | Dark hex | Light hex |
|---|---|---|---|
| Success | `--success-bg` | `#001D0E` | `#E6F8EE` |
|  | `--success-border` | `#004726` | `#A7E2C0` |
|  | `--success-fg` | `#00C481` | `#009660` |
| Info | `--info-bg` | `#001B25` | `#E2F4FA` |
|  | `--info-border` | `#00425B` | `#9CDCED` |
|  | `--info-fg` | `#00BAE9` | `#0094BC` |
| Warning | `--warn-bg` | `#251800` | `#FAF1DA` |
|  | `--warn-border` | `#553700` | `#E5C97A` |
|  | `--warn-fg` | `#E6AD00` | `#B98800` |
| Danger | `--danger-bg` | `#290B0A` | `#FBE7E8` |
|  | `--danger-border` | `#691214` | `#F1A6AB` |
|  | `--danger-fg` | `#F14445` | `#DD0F1A` |

**Usage**

```html
<div class="alert alert--success" role="status">
  <span class="alert__icon">✓</span>
  <p class="alert__msg">Bridge route found — 12 hops, 4.2s estimate.</p>
</div>
```

```css
.alert--success {
  background: var(--success-bg);
  border: 1px solid var(--success-border);
  color: var(--success-fg);
}
```

**Don'ts**

- **Don't pair an `-fg` with a non-matching state's `-bg`.** The hue lock is what makes the family read as a coherent system — `--info-fg` on `--success-bg` reads as a bug, not a stylistic choice.
- **Don't introduce a 5th state.** Four covers status (success / info / warn / danger). "Pending", "draft", "muted" should stay in the neutral ramp.
- **Don't use semantic pairs as decoration.** A green-tinted card that isn't a success state will train users to ignore the success state when it matters.

**Source** — `styles.css` `:root` block, search `Semantic state pairs`; the dark-mode and light-mode (`[data-theme="light"]`) definitions sit together so the pair-table maps 1:1 across themes. Live previews at `#brand-colors → Semantic state pairs` in the catalog.

### Official Brand Identity Colors

| Name | Hex | Usage |
|---|---|---|
| Brand Blue (Official) | `#3F49E1` | Single canonical brand blue — marketing, social, campaigns, product UI |
| Brand Pink (Official) | `#F7C2FF` | Restricted — marketing & consumer surfaces only |
| Brand Gradient | `#3F49E1 → #F7C2FF` | Hero sections, campaign banners, social assets |

**Pink Restrictions:** Never use on enterprise UI, API docs, product UI, RWA/institutional pages, or sales decks.

### Color Ratio System

Inspired by the most formalized design systems in the competitive set, LI.FI codifies how often each color appears. This prevents accent overuse and ensures visual discipline at scale.

| Color | Ratio | Usage |
|---|---|---|
| White / Ink 50 | 60% | Primary surfaces, page backgrounds |
| Ink 900 | 20% | Headers, footers, text |
| LI.FI Blue | 12% | CTAs, links, focus states |
| LI.FI Teal | 5% | Success, AI/agents, data viz |
| Brand Pink | 3% | Consumer marketing surfaces only |

### Semantic Token Names

Tokens carry meaning, not just values. Naming colors after their brand purpose embeds intent into the design system and guides correct usage without documentation overhead.

- **Execution** (`#0047FF`) — Action, precision, primary CTAs
- **Route** (`#00BFA5`) — Flow, success, AI features
- **Foundation** (`#0B0D12`) — Infrastructure, trust, authority
- **Signal** (`#F7C2FF`) — Consumer, marketing, warmth

### Spectrum — extended brand palette

The brand color system has **two layers within Brand Colors**:

| Layer | Tokens | Switches with brand preset? | Use for |
| --- | --- | --- | --- |
| **Identity** (3) | `--accent-primary` · `--accent-secondary` · `--accent-tertiary` | **Yes** | Brand identity moments — heros, primary CTAs, gradients, brand surfaces |
| **Spectrum** (12) | `--spectral-1` … `--spectral-12` (numeric) + `--spectral-coral` … `--spectral-pink` (named) | **No** *(by design)* | Categorical data viz, illustrations, decorative blocks — content where the color carries no semantic meaning |

Together they form **the full brand palette**. The two layers exist for the same reason any palette has both "logo colors" and "system colors" — identity needs to shift when the brand presents differently (LI.FI Blue / Across / Uniswap presets), but data and decoration shouldn't restyle on every preset switch.

**Why a 12-color extended palette exists.** Chart consumers used to fall back to `var(--accent-primary)` for the first series and ad-hoc OKLCH literals for the rest, which (a) restyled chart data on every brand-preset switch (semantically wrong — chart data is data, not branding) and (b) gave each chart consumer their own slightly-different per-hue OKLCH values, drifting from any system standard. The extended palette gives chart and illustration consumers a stable, system-curated set of 12 distinct hues, all sharing the same perceptual-equality tuning.

**Adjacent tiers** — outside Brand Colors but related:

| Tier | Tokens | Note |
| --- | --- | --- |
| Semantic | `--success` · `--warn` · `--danger` · `--info` | Status only — never substitute spectral hues for semantic state |
| Series | `--series-1` … `--series-8` | Chart-curated subset of Spectrum (var() references; values flow from the 12) |
| Neutrals | `--ink-*` · `--surface-*` | Surfaces, text, borders |

**The 12 hues — dashboard-discipline direction (Apr 2026 v3).** v2 pushed chromas toward each hue's gamut ceiling (C 0.18–0.30, L 0.58–0.82) for "vibrant" decorative use, but that wide band produced a "muddy" read on dashboards: magenta at C 0.30 screamed next to teal at C 0.18, lightness drifted 24 points across indices, and a single universal value forced cool hues to fail AA contrast on light backgrounds. v3 keeps the 12-hue / 30°-spacing architecture but applies the five-rule grammar above — tight L/C bands per mode, dual-mode tokens (`--spectral-N-dark` and `--spectral-N-light`), brand-anchored `--spectral-9-light` at exact LI.FI Blue.

| # | Name | Dark mode (L · C · H) | Dark hex | Light mode (L · C · H) | Light hex |
|---|---|---|---|---|---|
| 1 | coral | 0.72 · 0.20 · 20 | `#FF8C7A` | 0.55 · 0.21 · 20 | `#DC4A4A` |
| 2 | orange | 0.74 · 0.20 · 50 | `#FF9358` | 0.58 · 0.20 · 50 | `#C9722E` |
| 3 | amber | 0.82 · 0.18 · 80 | `#FFB454` | 0.66 · 0.16 · 80 | `#B58A1A` |
| 4 | lime | 0.84 · 0.20 · 110 | `#C5E866` | 0.62 · 0.20 · 110 | `#6EA214` |
| 5 | green | 0.78 · 0.22 · 140 | `#5EEAA0` | 0.55 · 0.20 · 140 | `#0E9D5C` |
| 6 | teal | 0.76 · 0.18 · 175 | `#3DD9D9` | 0.55 · 0.13 · 175 | `#008483` |
| 7 | cyan | 0.78 · 0.18 · 205 | `#42E2F5` | 0.55 · 0.16 · 205 | `#007D9B` |
| 8 | sky | 0.74 · 0.20 · 235 | `#67BAFF` | 0.50 · 0.20 · 235 | `#1862C8` |
| 9 | **blue** | 0.70 · 0.22 · 265 | `#7B86FF` | **0.44 · 0.23 · 268** | **`#3F49E1`** ← LI.FI Blue |
| 10 | violet | 0.66 · 0.22 · 295 | `#9B5DE5` | 0.45 · 0.22 · 295 | `#7C3AED` |
| 11 | magenta | 0.72 · 0.24 · 325 | `#D472FA` | 0.50 · 0.24 · 325 | `#C042E0` |
| 12 | rose | 0.76 · 0.22 · 355 | `#FF8FCB` | 0.55 · 0.22 · 355 | `#E1538F` |

Discipline summary — dark L 0.66–0.84 (yellow exception 0.82–0.84), C 0.18–0.24; light L 0.45–0.66 (yellow 0.62–0.66), C 0.13–0.24. Every cool-hue light-mode value clears 4.5:1 against `#FFFFFF`; every dark-mode value clears 4.4:1+ against `#13151D`. Sequential (`--seq-1..9`) and diverging (`--div-1..9`) palettes follow the same dual-mode pattern — see styles.css.

**Per-theme chart ramp (June 2026)** — the spectral primitives stay fixed across brand presets, but the *series* tier (`--series-1..12`) gained an opt-in Theme Composer axis: `charts: 'brand'` on a theme record paints a generated 12-hue ramp anchored on the theme's primary hue, with L/C sampled from the discipline table above — so partner-tinted surfaces (the Portal) can carry on-brand categorical charts without ever touching `--spectral-*` (avatar tones and decorative spectral consumers stay put). Default remains the fixed spectral mapping.

**Light/dark mode** — the same 12 hue *identities* in both, with L dropping 0.10–0.16 in light mode for contrast against light surfaces. Chroma carries through (lower L gives chroma more headroom before sRGB clipping). Coral is coral in either theme; consumers don't manage theme branches manually.

**Series Palette derives from Spectrum.** `--series-1` through `--series-8` are var() references to spectral tokens, picked for max categorical distance:

| Series | Spectrum source | Why this index |
|---|---|---|
| `--series-1` | `--spectral-9` (blue) | Default first series — highest contrast against either theme background |
| `--series-2` | `--spectral-2` (orange) | Warm complement to blue — max categorical distance |
| `--series-3` | `--spectral-5` (green) | Third axis on the wheel |
| `--series-4` | `--spectral-11` (magenta) | Fourth axis — opposite side |
| `--series-5` | `--spectral-3` (amber) | Adjacent to orange but distinct |
| `--series-6` | `--spectral-7` (cyan) | Cool family fill |
| `--series-7` | `--spectral-10` (violet) | Deep cool, distinct from blue |
| `--series-8` | `--spectral-1` (coral) | Warm, distinct from orange |

This is a behavior change from earlier iterations where `--series-1 = var(--accent-primary)` and `--series-2 = var(--accent-secondary)` — chart colors used to restyle on Theme Composer brand switches. As of Apr 2026 v2 they don't; chart data should read the same regardless of which brand preset is active.

**Don'ts**

- **Don't use the extended palette for UI chrome** — buttons, borders, focus rings, hover states bind to the identity accents + neutrals so they restyle when the user picks Across or Uniswap. The 12 extended hues are stable on purpose; that's the wrong behavior for chrome.
- **Don't substitute spectral hues for semantic state.** `--spectral-5` is in the green family but `--success` (semantic green) lives in the Semantic tier. A designer changing the success token won't update charts; a designer changing spectral-5 won't update success states.
- **Don't hand-pick chart colors outside the curated subsets.** The 4 / 6 / 8-series subsets in the catalog are pre-tuned for max categorical distance. Picking `--spectral-9` + `--spectral-8` for two series puts deep blue next to sky blue — the eye reads them as one variable.
- **For more than 8 categories, group instead of stacking colors.** 12 is the extended palette's ceiling. Beyond that, group ("rest" bucket) or switch to a sequential palette.

**Source** — `styles.css` `:root` block (search `Spectrum Palette` for the dark-mode definitions; `[data-theme="light"]` block lower for overrides). Live previews + 4/6/8 chart subset examples at `#spectrum` in the catalog.

### Palette grammar — five rules that prevent muddiness (Apr 2026 v3)

Every new palette in this system — extended Spectrum, future per-product subsets, Theme Composer presets, dashboard chart colors — follows the same five disciplines. They were codified after the dashboard palette work in `artifacts/report-lifi-omni-theme.html` revealed why earlier `--spectral-*` iterations read as muddy: the band was wide, lightness drifted, and the same set was reused across both modes.

**1. Tight lightness band per mode.** Hold L within ~10 points across the whole palette so adjacent series have similar visual weight. Yellow is the only legitimate outlier — pure yellow at L ≤ 0.70 reads as olive/dark-amber, not yellow, so let yellows live higher (`--spectral-3`, `--spectral-4` clear L 0.80 in dark mode by necessity). Everything else stays in the band:

| Mode | Body band | Yellow exception |
|---|---|---|
| Dark (against `#13151D`) | L 0.70 ± 0.06 (range 0.66–0.76) | L 0.82–0.84 |
| Light (against `#FFFFFF`) | L 0.50 ± 0.08 (range 0.42–0.58) | L 0.62–0.66 |

If you find yourself at L 0.78 in dark mode and L 0.42 in light mode for the same hue, that's the dual-token system working — a single universal value would force one of those modes to fail AA contrast against the tile.

**2. Tight chroma band with documented exceptions.** Aim for C ≈ 0.20 across all hues. Two exceptions are allowed and necessary:

- **Cyans/teals (H 175–210)**: cap at C 0.16–0.18 in dark, C 0.13–0.16 in light. Pushing past those values produces a neon-cyan that reads as "alert" rather than "data."
- **Magentas/violets (H 295–325)**: can take C 0.22–0.24 because OKLCH gamut allows it AND magenta needs the extra chroma to feel saturated against neighboring blues/violets that already sit at the gamut limit.

Anything else — coral, orange, amber, lime, green, sky, blue, rose — sits at C 0.20 ± 0.02. The `--spectral-*` block in `styles.css` documents each per-hue chroma with rationale comments.

**3. Dual-mode tokens, not light-mode lightness offsets.** Every palette tier defines `--{token}-N-dark` and `--{token}-N-light` as constants in `:root` (always available), then re-points `--{token}-N` via `[data-theme="light"]`. Do **not** define a single universal value and accept a contrast loss in one mode. The architecture in v3:

```css
:root {
  --spectral-9-dark:  oklch(0.70 0.22 265);  /* #7B86FF dashboard-default cool blue */
  --spectral-9-light: oklch(0.44 0.23 268);  /* #3F49E1 exact LI.FI Blue */
  --spectral-9: var(--spectral-9-dark);       /* default alias */
}
[data-theme="light"] { --spectral-9: var(--spectral-9-light); }
```

Same pattern for `--seq-N` (sequential) and `--div-N` (diverging). The dual-token discipline is what unlocks WCAG-AA-on-both-tiles + tight band-per-mode simultaneously.

**4. Brand anchor preserved at a known index.** One palette entry must be exactly the brand primary at full saturation. For `--spectral-*`, that's `--spectral-9-light = oklch(0.44 0.23 268)` which round-trips to `#3F49E1` — identical to LI.FI Blue from `presets.js` LI.FI 1.0. Same for `--seq-6-light`. This guarantees "default first chart series in light mode = brand color" without any token override.

When the brand evolves (e.g. LI.FI 2.0 with Execution Blue `#0047FF`), update three things in this order:
1. `presets.js` brand seeds (the source of truth)
2. The matching `--spectral-N-light` and `--seq-6-light` to round-trip to the new hex
3. Re-verify WCAG AA contrast against `#FFFFFF` and `#13151D`

**5. Hue array is fixed, not creative.** All 12 spectral indices live at `H = [20, 50, 80, 110, 140, 175, 205, 235, 265, 295, 325, 355]`. The +30° spacing maximizes categorical distance between adjacent indices. The starting H 20° (warm coral) preserves the index→named-color contract: `--spectral-1 === --spectral-coral`. Don't move the hue array — only L and C are tunable per index.

**Three palette tiers in v3:**

| Tier | Tokens | Use |
|---|---|---|
| **Discrete** | `--spectral-1..12` (theme-aware) | Categorical: per-chain, per-tool, per-bridge breakdowns. The 8-color `--series-*` subset is curated from these for max consecutive-pair distance. |
| **Sequential** | `--seq-1..9` (theme-aware) | Single-hue Blue ramp for "more is more" metrics: heatmaps, choropleths, density plots. Direction flips per mode (high = brighter on dark, darker on light). |
| **Diverging** | `--div-1..9` (theme-aware, mode-agnostic neutral) | Pink ↔ Slate ↔ Green for benchmark deltas. CVD-safe (Pink→Slate→Green sidesteps red-green deuteranopia). Stop 5 (zero) is identical across modes so "no change" reads consistently. |

Live previews of all three tiers at `#spectrum`, `#sequential-palette`, `#diverging-palette` in the catalog.

### Gradient System

**Brand Gradient** — `linear-gradient(135deg, #0047FF → #0090E0 → #00BFA5)`
Hero text gradients, CTA backgrounds, logo lockup accent fills. The signature LI.FI gradient — Blue to Teal across 135°.

**Subtle Section Tint** — `linear-gradient(135deg, rgba(0,71,255,0.08) → rgba(0,191,165,0.04))`
Alternate section backgrounds, card tints, hero panel fills. Adds warmth without obscuring the white canvas.

**Radial Hero Glow** — `radial-gradient(ellipse 90% 70% at 50% -5%, rgba(0,71,255,0.15) → transparent)`
Section entrance glow applied as `::before` overlay on hero/feature sections. Creates ambient depth on a light background.

**Teal Radial Accent** — `radial-gradient(ellipse 55% 60% at 90% 50%, rgba(0,191,165,0.10) → transparent)`
Right-side teal ambient — pairs with the blue hero glow for a dual-axis depth effect. Use on sections featuring route/flow metaphors.

**Gradient Rules:**
- Do: Use gradient text on hero H1 and reserved section/card titles · Apply radial glows as `::before` overlays · Keep gradients within Blue↔Teal spectrum · Limit to 1–2 gradient elements per section
- Don't: Apply gradients to body text · Use gradients on buttons or form elements · Mix more than 2 gradient types in one section · Use gradients outside the Blue–Teal axis

### Gradient Typography

The same shimmer used on the hero H1 highlight can be promoted to any title-level element via the `.text-gradient-hero` utility. It is the canonical treatment for headings that need to echo the hero's brand energy — pricing tier titles, feature card titles, major section heros.

```html
<h2 class="text-gradient-hero">Universal market access</h2>
<div class="accent-card-title">Cross-Chain Execution</div>
```

**Implementation** (`styles.css`):
- Dark mode fill: `var(--grad-brand-rich)` — Blue → mid → Teal → cyan → tertiary tint (4-stop OKLCH)
- Light mode override: `linear-gradient(135deg, var(--accent-primary), var(--accent-secondary))` — Blue → Violet, avoids pale pink at the far end
- `padding-right: 0.08em` — compensates for the glyph-clip issue caused by heavy weight + negative tracking when `background-clip: text` is applied
- Inherits font-size/weight/letter-spacing from the host element — the utility only swaps fill

**Where to apply:**
- Hero H1 highlighted span (already wired via `.hero h1 .highlight`)
- Pricing tier titles (`.accent-card-title`, applied by default)
- Section heros that introduce a product category
- Card titles that act as the primary hook in a 2- or 3-up grid

**Where NOT to apply:**
- Body copy, descriptions, or eyebrows
- Sub-headings below H2 — gradient text at small sizes reads as muddy
- Titles on tinted surfaces (accent-card primary/secondary/tertiary already tint the surface; double-gradient reduces legibility — consider reverting to `var(--text-primary)` there)
- Buttons, form labels, or interactive chrome

### Competitive White Space

Of 24 brand guides analyzed (incl. 0x, Wormhole), no brand pairs institutional blue (`#0047FF`) with teal (`#00BFA5`) on a white primary surface. **LI.FI's gradient system is the only brand in the space with this specific combination.** Wormhole's UI teal (`#78C4B6`) is the closest match — LI.FI's `#00BFA5` is more vibrant and distinct. LI.FI Blue (`#0047FF`) has zero Wormhole overlap.

### Alpha state layers — `--alpha-subtle` / `--alpha-dim` / `--alpha-raised` / `--alpha-overlay`

Four tinted overlay tokens for state — recessed tracks, inactive hover, active pills, active hover. Layered on top of any surface fill (`--surface-page` / `--surface-*`) — they don't replace the fill, they sit ON IT. Theme-flipping is built in: in dark mode they're white tints at 8/4/18/24% alpha; in light mode they're black at 6/3% (subtle/dim) and white at 88/95% (raised/overlay). Promoted to its own catalog card May 2026 with the Elevation foundation reshape.

| Token | Dark | Light | Use |
|---|---|---|---|
| `--alpha-subtle` | `oklch(100% 0 0 / 0.08)` | `oklch(0% 0 0 / 0.06)` | Recessed tracks, demo bases |
| `--alpha-dim` | `oklch(100% 0 0 / 0.04)` | `oklch(0% 0 0 / 0.03)` | Inactive hover fill |
| `--alpha-raised` | `oklch(100% 0 0 / 0.18)` | `oklch(100% 0 0 / 0.88)` | Active pills, raised tiles |
| `--alpha-overlay` | `oklch(100% 0 0 / 0.24)` | `oklch(100% 0 0 / 0.95)` | Active hover, top-tier elevation |

Pair with the **on-alpha text scale** when text sits on these layers — `--on-alpha-default` and `--on-alpha-muted` instead of `--text-primary` / `--text-secondary`. The on-alpha tokens are tuned for legibility against the resolved color of an alpha layer over the page bg, which differs from the resolved color of a solid surface fill.

**Authoring rules.**

- **Use alpha tokens, not raw `rgba()`.** A handcoded `rgba(255,255,255,0.18)` looks fine in dark mode and vanishes in light mode — the tokens solve that.
- **Layer alphas on top of fills, don't replace them.** An alpha overlay is a state on top of an underlying surface; the surface fill (`--surface-*`) is what defines the base. Set `background: var(--alpha-raised)` on a child element of the card, not on the card itself.
- **Tier semantics:** subtle &lt; dim &lt; raised &lt; overlay. Don't skip tiers (a hover going straight from subtle to overlay reads as "flashing") and don't reverse them (active should always be brighter than inactive).

Catalog reference: `#elevation-alpha` in `design-system/index.html`. Spec for the elevation foundation overall: §06 → Elevation over hard borders, plus the catalog Elevation section (`#elevation-tiers` / `#elevation-rule`).

### Panel Surfaces — one recipe for every floating glass panel

> **May 2026 reshape:** This three-token recipe is now the underlying definition of the **Glass material**. Components consume the Materials API (`--mat-bg` / `--mat-shadow` / `--mat-backdrop`) which resolves here. Authors should reach for `data-material="glass"` (or rely on the `:where()` default on `.ds-doc-menu-panel` / `.dropdown__panel` / `.playground-rail`) — never for these tokens directly inside a component rule. Spec for the consumer-facing API: see Materials below. Spec for the underlying recipe (kept here for the bug-history and the values themselves): below.

Every floating glass panel and menu (FAB popover, playground rail, theme composer, future popovers and drawers) uses the same three-token recipe so they all read as **the same surface**. Don't author `background:` + `box-shadow:` + `backdrop-filter:` for a new panel — reach for the Glass material (or, if working at the token layer for a system-wide change, these tokens). The recipe builds on `--glass-base` (Frosted Glass) and the spacing system; the shape (radius, padding, width) belongs to the component.

| Token | Role | Theme behavior |
|---|---|---|
| `--surface-panel-glass` | Fill — glass at 70% opacity over the page | Auto-flips via `--glass-base` |
| `--shadow-panel-glass` | 3-layer drop + inset highlight (lift + ground contact + top edge sheen) | Light/dark variants in `:root` + `[data-theme="light"]` |
| `--backdrop-panel-glass` | `blur(40px) saturate(140%)` — frosts the page behind | Same in both themes |

**Application matrix.** Where the recipe is currently used.

| Component | Status | Note |
|---|---|---|
| `.ds-doc-menu-panel` | ✓ aligned (Apr 2026) | FAB popover + Theme Composer sub-panels. |
| `.dropdown__panel` | ✓ aligned (Apr 2026) | Anchored dropdown menu surface. |
| `.cmd-palette__panel` | ✓ aligned (May 2026) | Command palette overlay — joined the registration in May 2026 (was "review" prior). |
| `.panel` | excluded (May 2026) | Was glass through Apr 2026; switched to opaque `--surface-raised` + `--elev-3` mid-May for dense panel content. See `#panel` in the catalog. |
| `.playground-rail` | excluded (May 2026) | Composes `.panel` for its surface chrome; no longer carries a glass `:where()` registration of its own (the rail's visual surface flows from `.panel`'s direct token reference). |
| `.search` | review | Search popover — should adopt if not already. |
| `.modal` | excluded (May 2026 v13) | Composes `.panel` for surface chrome (`--surface-raised` + `--elev-3`), same as the playground rail and swap widget. Not glass — modal content needs full legibility. See `#modal` / `#panel` in the catalog. |
| `.navbar` / `.nav-dropdown` | excluded | Uses navbar-scoped `--nav-panel-bg` / `--nav-mobile-bg` with sticky-glass constraints. Possible future migration. |

**Rules.**

- **Don't fork the recipe.** If you find yourself writing `background: color-mix(--glass-base …)` + `box-shadow:` + `backdrop-filter: blur(…)` for a new panel, stop and use the three tokens.
- **Same surface across light + dark.** Light-mode shadow lives in `[data-theme="light"]` on `--shadow-panel-glass`. Don't author per-component theme overrides.
- **Don't accent-tint the shadow.** Neutral black-only is the contract — the glass fill carries any palette connection on its own. Pre-Apr-2026 `.ds-doc-menu-panel` had a `color-mix(--accent-primary 18%)` light-mode shadow and read as a different surface from the rail; that's the failure mode this rule exists to prevent.
- **Out of scope:** cards (solid fill, not glass), navbar (uses `--nav-panel-bg` / `--nav-mobile-bg`), modals (solid surface). Don't retrofit them onto these tokens.

Catalog reference: `#material-glass` in `design-system/index.html` (the recipe is documented as the Glass material's underlying definition; the standalone `#panel-surfaces` card was retired in May 2026 to remove duplication with Surfaces).

### Materials — surface character bundles, named and swappable

**A material is the visible character of a surface — fill + border + shadow + backdrop-filter + edge highlight, bundled and named.** Where the surface tokens (`--surface-sunk` / `--surface-raised` / `--surface-card` / `--surface-panel-glass`) answer "what color is this fill," materials answer "what's the entire visual character of this card / tile / panel." Materials build *on top of* the surface tokens — they don't replace them.

**The three-layer stack — why both Surfaces and Materials exist.**

```
COMPONENTS    .card, .tile, .stat-card, .ds-doc-menu-panel
   │           consume materials via --mat-*
   ▼
MATERIALS     paper · glass · gradient · halo
   │           bundle bg + border + shadow + backdrop + highlight
   │           bg field references a surface token, never a literal
   ▼
SURFACES      --surface-card · --surface-sunk · --surface-raised
              · --surface-panel-glass · --surface-page
              the COLORS materials reach for; theme-flip in one token
```

A `paper` material's `bg` is `var(--surface-card)`, a `glass` material's `bg` is `var(--surface-panel-glass)`. Materials don't duplicate surface colors — they compose them. Deleting surfaces would force materials to embed raw `oklch()` literals (drift), and non-material consumers (page bg, navbar, modal bg, sunk wells, ambient halos) would have nowhere to look. Each layer owns one concern: surfaces = colors, materials = compositions, components = consumers.

**Why this layer exists.** Without it, every card invents its own combination — a stat-card hand-rolls `background: var(--surface-card)` + `box-shadow: var(--elev-1)` + a 1px hairline; a glass panel hand-rolls the three-token Panel Surfaces recipe; an accent card bakes `background: color-mix(--lifi-ink 18%)` plus its own border tone. Twenty cards on a page accumulate twenty subtle character forks. Materials is the systematic answer: pick a named material once at the element, the fill / border / shadow / backdrop / highlight all flow together.

**Three layers, top-down**

| Layer | Tokens | Lives in | Authored by |
| --- | --- | --- | --- |
| **Definitions** | `--material-<name>-{bg,border,shadow,backdrop,highlight}` | `:root` in `styles.css` | DS contributor when adding a new material |
| **Resolved API** | `--mat-{bg,border,shadow,shadow-hover,backdrop,highlight}` | `:root` (defaults to paper) + `[data-material]` selectors below | The DS framework |
| **Consumers** | `background: var(--mat-bg); ...` on a component rule | Component CSS in `styles.css` / `swap.css` / etc. | Component author |

The split keeps components material-agnostic — `.card` reads `var(--mat-bg)`, never `var(--material-paper-bg)`. To change the material on an instance, set `data-material="glass"` on the element. That attribute selector wires the material's definitions onto the resolved API, and the component re-reads the new values automatically.

**Light-mode overrides — one token, `--surface-card`.** Most light/dark flips flow through the upstream tokens this layer composes (`--elev-*`, `--text-primary`, `--shadow-panel-glass`, `--lifi-*`). The exception is the *card-tier surface fill*: in light mode the surface ladder maxes at `--surface-page` (100% white) — there's no value "above" it for a card to step up to. So `--surface-card` pivots to `var(--surface-page)` in `[data-theme="light"]` and cards lift via shadow alone (Material Design approach). Dark mode: `--surface-card` is the canonical 28% lightness step (bumped from 15% across the **May 2026 dark surface-ladder rebalance** — v1 went `--surface-raised` 10 → 14 and `--surface-card` 15 → 19; v2 a few days later went `--surface-raised` 14 → 16 and `--surface-card` 19 → 24 for uniform 8 % step sizes. v3 June 2026 raised the card floor 24 → 28 — themes whose seed pushes the page past L=15 (Jumper 1.0 dark surface L=16) saw the card→page gap shrink to ~8 and cards started blending into the page; +4 restores a comfortable lift without disturbing LI.FI 1.0's perceived hierarchy. Final ladder: 8 → 16 → 28 instead of 8 → 16 → 24. Light values unchanged.). The Theme Composer mirrors this rule: in dark mode it writes the formula value, in light mode it writes `var(--surface-page)` so palette changes never override the pivot. The `paper` and `halo` materials plus the Material Composer JS all reference `--surface-card`, so the theme pivot has a single source of truth. Shadow / border / backdrop tokens still flow through the upstream theme-flip without an override. (Search `--surface-card` in `styles.css`.)

**The four canonical materials**

May 2026 — reduced from eight to four. `raised`, `sunk`, and `tinted` retired (no longer needed; surface-token overrides cover their roles when one-offs come up). `frosted` collapsed into `glass` — both were doing the same job (translucent fill); glass kept the canonical name and absorbed marketing-tile defaults. Anything that was previously `data-material="frosted"` resolves cleanly to glass.

| Material | Fill | Border | Shadow | Backdrop | Use for |
| --- | --- | --- | --- | --- | --- |
| `paper` | `--surface-card` (theme-aware) | 1px @ 6 % `currentColor` | `--elev-1` | none | Stat-cards, feature-cards, action-cards, action containers — the default |
| `glass` | `--surface-panel-glass` (translucent + backdrop-blur) | none | `--shadow-panel-glass` (with inset highlight baked in) | `blur(40px) saturate(140%)` | FAB sub-panels, side rails, dropdowns, marketing tiles — aliases the canonical Panel Surfaces 3-token recipe |
| `gradient` | `linear-gradient(135deg, --lifi-sapphire, --lifi-ink)` | 1px @ 12 % white | `--elev-2` | none | Hero KPIs, marketing accent surfaces, cover tiles |
| `halo` | radial-gradient at top-right (12 % `--accent-tint` peak) over 3 % `--accent-tint` × `--surface-card` | 1px @ 6 % `currentColor` | `--elev-1` | none | Brand-identity cards, premium feature tiles, "about this product" showcases — quietly brand-forward without going full gradient. Promoted from `.brand-card` (May 2026). |

`halo` is parametric — set `--accent-tint` on the element (or a higher scope) to choose the brand accent. Defaults to `--lifi-ink`. Tiles bridge `--accent-tint` from `--tile-accent` automatically, so per-tile colours flow into the halo recipe without further wiring.

**Authoring rules**

- **Components consume `--mat-*`, never `--material-*-*`.** The point of the resolved layer is that components stay agnostic. A rule like `.stat-card { background: var(--material-paper-bg); }` is drift — it skips the override mechanism and bakes paper in. Use `var(--mat-bg)`.
- **OKLCH at the definition site only.** Materials compose `--surface-*`, `--lifi-*`, `--accent-*`, `--elev-*` — never bake an `oklch()` / hex / rgba literal inside a `--material-*-*` token. The LI.FI 1.0 FOUC bug pattern (CLAUDE.md → "LI.FI 1.0 is the only default") repeats at this layer if a contributor copies a literal in.
- **One default per element, swap via attribute.** A component that always wears one material declares it with a `:where(<class>) { --mat-bg: var(--material-glass-bg); ... }` block (specificity 0 — `data-material` overrides it). A component that picks its material per-instance leaves `--mat-*` at the `:root` paper default and sets `data-material` in the markup. Don't both-style and both-attribute the same element.
- **Don't add a per-theme override for the resolved layer (`--mat-*`).** Light/dark behavior flows from the upstream tokens. If a material reads wrong in one theme, fix it at the *definition* layer (`--material-<name>-bg` etc.) — paper's fill pivots to `--surface-page` in light mode through `--surface-card` for this exact reason. Never branch the resolved API per theme.
- **Hover choreography flows through `--mat-shadow-hover`.** Each material defines its hover-state shadow tier (`--material-<name>-shadow-hover` — paper → `--elev-2`, glass → unchanged (already floating), gradient → `--elev-3`, halo → `--elev-2`). Components consume `var(--mat-shadow-hover)` in their `:hover` rule instead of hand-rolling `var(--elev-2)`. Bg-on-hover stays a per-component concern (accent washes are idiosyncratic — tiles want `color-mix(--tile-accent X%, --surface-card)`, action-cards want their own recipe). May 2026.
- **Adding a new material extends the family.** Both the definition block (`:root`) and the `[data-material="<name>"]` attribute selector get added in the same edit, plus a row in this table, plus a `.ds-doc-card` in `#materials` in the catalog. Run `validate-doc.py` after.

**Application matrix.** Where materials are currently consumed.

| Component | Material | Status | Note |
|---|---|---|---|
| `.ds-doc-menu-panel` | `glass` | ✓ migrated (May 2026) | FAB popover + Theme Composer sub-panels. CSS reads `--mat-*`. |
| `.dropdown__panel` | `glass` | ✓ migrated (May 2026) | Marketing nav dropdowns. CSS reads `--mat-*`. |
| `.playground-rail` | `glass` | ✓ migrated (May 2026) | Wide settings rail on the playground; canonical reference for the recipe. |
| `.stat-card` | `paper` | ✓ migrated (May 2026) | KPI card. `--accent` / `--glass` / `--grad-*` modifiers stay as override classes for now. Verified zero-diff in both themes. |
| `.tile` / `.feature-card` / `.enterprise-card` | `glass` | ✓ migrated (May 2026 v2) | Marketing card primitive. Initially mapped to `frosted` in May 2026; when `frosted` retired and merged into `glass` (May 2026 v2), the default migrated cleanly. Marketing tiles now wear the same translucent + backdrop-blur recipe as floating panels, giving an "airy lifted" feel against the page-bg ambient gradients. Override per-tile with `data-material="paper"` for dense data grids where blur cost is undesirable. |
| `.brand-card` | `halo` | ✓ migrated (May 2026) | Brand-identity card (LI.FI 1.0 / competitor showcase tiles). Promoted to its own material; the radial halo + soft accent wash that made `.brand-card` distinctive is now the `halo` recipe. |
| `.card` | `paper` | ✓ migrated (May 2026) | UI-tier card primitive. Now consumes `--mat-*`; `data-material="halo"` / `"glass"` / `"gradient"` swappable per instance. |
| `.cmd-palette__panel` | `glass` | ✓ migrated (May 2026) | Was a modal-tier solid surface (`--elev-3`); now consumes `--mat-*` and wears glass via `:where(.cmd-palette__panel)`. The cmd palette reads as one of the glass-floating-panel family along with FAB sub-panels and dropdowns. |
| `.search` | `paper` | ✓ migrated (May 2026) | Search-bar form control. Was a bespoke `color-mix(--glass-base, white 6%)` recipe; now consumes `--mat-*` via `:where(.search)`. Hover and focus-within state behaviors preserved (border-color bump + focus ring shadow). |
| `.action-card` | `paper` | ✓ migrated (May 2026) | Non-breaking. The `--ac-bg` / `--ac-shadow` defaults now reference `var(--mat-bg)` / `var(--mat-shadow)`; the existing per-element customization API (`style="--ac-bg: ..."`) keeps working. Hover state mixes accent over `--surface-card` for theme correctness. |
| `.ui-card` | — | deliberately deferred | Product-surface widget shell with a custom shadow recipe (close 1px drop + 24px ambient — softer / more modal-like than `--elev-1`). Migrating to paper would replace the shadow and shift the widget's perceived weight in light mode. Keep custom until the widget rebrand explicitly revisits the elevation. |
| `.modal` | — | excluded | Different paradigm (solid card, not glass). Not a Materials consumer. |
| `.navbar` / `.nav-dropdown` | — | excluded | Use navbar-scoped `--nav-panel-bg` / `--nav-mobile-bg` with sticky-glass constraints. Possible future migration. |

**Material Composer — live editor for the Materials layer.** Materials are values; the Composer tunes them. Shipped May 2026 as `#material-composer` in the Components catalog plus a full-screen modal workspace. The picker selects which of the four materials to edit; five sliders (opacity / blur / tint / shadow / highlight) apply per material — inapplicable sliders for a given material grey out so the panel layout stays stable. Slider changes mutate `--material-<name>-*` directly on `:root`, so every consumer that reads `var(--mat-*)` (cards, tiles, FAB panel, dropdowns, playground rail) repaints automatically — no per-component wiring.

Saved variants live in `localStorage` under `lifi-mc-variants`, but the Composer NEVER auto-restores them on init (LI.FI 1.0 default rule — same brand-protection contract that governs the Theme Composer). Variants are user-action only: explicit save, explicit click to apply, explicit delete. Export emits the full state (all materials' current values + saved variants) as JSON for promotion into the system — paste into a commit, hand-promote into the `--material-*-*` definition block, run the validator.

**The Composer is not a Material.** Tuning opacity from 100 to 80 previews the existing material at that value; it doesn't author a new material. Promotion is deliberate — JSON export → manual edit of `:root` definitions → catalog card in `#materials` → row in this table. The system Materials are the curated set; Composer state is exploration.

Source: `shared.js → initMaterialComposer()`, CSS in `styles.css → MATERIAL COMPOSER` block. Catalog reference: `#material-composer` in `design-system/index.html`. The Materials layer itself: `#materials`.

---

## 03 — Typography

Geist for all UI and editorial copy. Geist Mono for technical values, addresses, and code. Never mix decorative typefaces — precision and legibility are non-negotiable.

**Why this pairing:** Geist + Geist Mono is a cohesive two-family system — one geometric sans carrying the whole UI in sentence case (weights 400 / 500 / 600), one matched monospace as the data voice for figures, labels, and code. The two share proportions and tone, so headings, body, and technical values read as one system.

### Type Scale

| Role | Token pair | Spec | Usage |
|---|---|---|---|
| Mega / Colossal | `--text-colossal` / `--lh-colossal` | Geist **800** · 256px · −5% · 0.94 lh | Single-word monument — slide canvas-fills, `CONNECT.`-scale reveals |
| Mega / Keynote | `--text-keynote` / `--lh-keynote` | Geist **800** · 192px · −5% · 0.94 lh | Deck headline sized for 16:9 presentation frames |
| Mega / Statement | `--text-statement` / `--lh-statement` | Geist **800** · 144px · −4% · 0.94 lh | Bold website statements — 2–3 words per line at 1440px |
| Mega / Poster | `--text-poster` / `--lh-poster` | Geist **800** · 112px · −4% · 0.96 lh | Landing sub-hero, dense slide content (one step above Hero) |
| Display / D1 | `--text-hero` / `--lh-hero` | Geist 700 · 80px · −4% tracking · 1.0 lh | Page-level hero headlines |
| Display / D2 | `--text-display` / `--lh-display` | Geist 700 · 56px · −3% · ~1.14 lh | Section hero headlines |
| Heading / H1 | `--text-h1` / `--lh-h1` | Geist 700 · 48px · −3% · ~1.17 lh | Top-of-page section titles |
| Heading / H2 | `--text-h2` / `--lh-h2` | Geist 700 · 32px · −3% · 1.25 lh | Card titles, major headings |
| Heading / H3 | `--text-h3` / `--lh-h3` | Geist 700 · 24px · −1.5% · ~1.33 lh | Sub-section titles, tile heads, large stat values |
| **Body / Large** | `--text-body-xl` / `--lh-body-xl` | **Geist 400 · 20px · 0 · 1.6 lh** | **Lede paragraph under a Display or Hero headline — hero subtitles, CTA subtitles, section intros. Fills the gap between H3 and Body** |
| Body / Default | `--text-body` / `--lh-body` | Geist 400 · 16px · 0 · 1.5 lh | General body copy, descriptions, card bodies |
| Small | `--text-sm` / `--lh-sm` | Geist 500 · 14px · 0 · ~1.43 lh | Buttons, inputs, form labels, mono labels |
| Caption | `--text-caption` / `--lh-caption` | Geist 400 · 12px · 0 · ~1.33 lh | Helper text, metadata, footnotes |
| Micro | `--text-micro` / `--lh-caption` | Geist 500 · 10px · +6% tracking · ~1.6 lh | Slider ticks, badge superscripts, dense overlay glyphs |
| Mono / Code | `--font-mono` | Geist Mono 400–500 · 13–14px | Addresses, values, code, token names |
| Eyebrow | composition | Geist 600 · 11–12px · +12% tracking · uppercase | Uppercase section labels, kickers |

**Token-pairing rule (non-negotiable).** Every text size has a paired line-height — the two are designed together so text baselines land on the 8-px grid. When you set `font-size: var(--text-h3)`, always set `line-height: var(--lh-h3)`. **Do not mix tokens across roles** (e.g. `font-size: var(--text-h3)` with `line-height: var(--lh-body)`). That creates a ratio of 1.0 on 24px text, which visibly crams descenders against the next line's ascenders. If you need a size *between* two roles (e.g. larger than Body but not a heading), reach for `Body / Large` — that's what it exists for.

**Source of truth.** `styles.css:254–275` holds the authoritative token values. If this table and the CSS drift, trust the CSS and fix the doc.

### Mega Display — Presentation & Statement Type

The four Mega sizes (Poster · Statement · Keynote · Colossal) sit **one-to-four steps above Hero** and exist for surfaces where words are the composition: presentation decks, poster-scale website statements, single-word animated reveals. They are not editorial type — never place them in a paragraph, a list, or any multi-line body. The line-height ratio drops to 0.94–0.96 (vs. Hero's 1.0) so cap-heights hug the next line — at 144 px+ even a small leading gap becomes a visible gutter that wrecks the composition.

**Weight override — deck tier is 800, not 700.** The rest of the large-type ladder (Hero / Display / H1) is set at **700**, the brand's heaviest editorial weight. The deck tier (Poster 112 · Statement 144 · Keynote 192 · Colossal 256) is the only exception in the scale: it ships at **800**. Reason — at canvas-fill scale the letterforms start to read as outlines once they cross ~100 px; the +100 weight bump restores the visual presence the composition expects. The override lives on the shared deck-tier rule in `styles.css` (search `.text-poster, .text-poster--fluid,` — the comment block above the rule documents the same rationale). Don't author per-class weight overrides on Mega utilities; if you need to dial the weight further, do it as a system-wide change to that single rule and update this section in lockstep.

**Picking a size**

| Size | When | Where | Fluid floor |
|---|---|---|---|
| Poster `112px` | One level above Hero for landing pages with extra breathing room; dense presentation slides (body-level content on a slide) | 1440 px+ landing pages, tight decks | H1 / 48 px |
| Statement `144px` | Bold website statement layer — the "manifesto" block on a product page, investor landing, or brand story | 1200 px+ marketing pages | Display / 56 px |
| Keynote `192px` | Presentation deck headlines. Sized so two or three words fill a 16:9 slide's safe area | 1920×1080 decks, pitch pages | Display / 56 px |
| Colossal `256px` | Single-word monument. One word per slide, one word per section divider. Canvas-fill scale — use sparingly | Deck covers, poster prints, section breaks | H1 / 48 px |

**Utilities**

```html
<!-- Static size — full token at every viewport -->
<h1 class="text-statement">Anychain execution</h1>

<!-- Fluid size — clamp()s from a phone-safe floor up to the token -->
<h1 class="text-statement--fluid">Anychain execution</h1>

<!-- With gradient highlight on selected words -->
<h1 class="text-statement--fluid">
  Move value
  <span class="text-gradient-mega">anywhere</span>
  instantly.
</h1>

<!-- Word-by-word animated reveal (staggered via --i) -->
<h1 class="text-keynote--fluid statement-words statement-words--reveal">
  <span style="--i:0">Any</span>
  <span style="--i:1">chain.</span>
  <span style="--i:2">Any</span>
  <span style="--i:3">asset.</span>
</h1>
```

**Gradient pairing.** The existing `.text-gradient-hero` works at Poster size but compresses visibly at Keynote+. For Statement / Keynote / Colossal, use **`.text-gradient-mega`** — wider 180% horizontal gradient span, 4-stop hue path (primary → primary-mid → tertiary → secondary), tuned so the shimmer reads across a single large word rather than collapsing into a center band. Apply to a child `<span>` when you want some words highlighted and others in solid `--text-primary`, mirroring the existing Hero H1 highlight pattern.

**Animation primitive.** `.statement-words` is the geometry helper for word-by-word reveals. It sets child `<span>`s to `inline-block` (so `transform` works without breaking lines) and adds a `0.25em` inter-word gap. Add `--reveal` to run the bundled keyframe — a 0.7 s fade-up per word, staggered by the `--i` CSS variable on each span (0-indexed). The reveal respects `prefers-reduced-motion: reduce`. For custom animations (word stagger into a gradient sweep, letter-by-letter, type-on) use the same wrapper and supply your own keyframes; the inline-block + `--i` contract stays the same.

**Don'ts.**

- Don't set these on anything with more than ~6 words per line. Past that they wrap ugly and the tight line-height causes visible collision.
- Don't pair Colossal with body copy at the same breakpoint — 256 px next to 16 px body copy reads as a broken layout, not a hero. Either step the Colossal down or drop to Statement.
- Don't use the static class on mobile — a static `--text-colossal` at `256px` overflows a 390 px phone. Always reach for `--fluid` unless you're shipping a desktop-only surface (a deck slide, a portrait proposal page).
- Don't apply `.text-gradient-mega` to the whole line when you can afford to highlight one word. Mega sizes are already loud — single-word gradient accents echo the hero pattern and keep the rest of the statement grounded.
- Don't stack more than one Mega on a single screen. A Keynote over a Statement over a Colossal is a Geocities poster, not a brand moment.

### Pair font-size with its tier's line-height

Every `--text-*` token has a sibling `--lh-*` at the same tier. Pair them. Body text takes `--lh-body`; small / caption-tier text takes `--lh-sm` or `--lh-caption`; display text takes `--lh-display` / `--lh-hero` / etc. The tokens carry the type-tier vocabulary on purpose — using `--lh-body` (24 px line-box) under `--text-caption` (12 px font) is a tier mismatch that reads as ~1.92 line-height ratio, not 1.5, because the line-box was designed for a 16 px font. The bug is invisible standalone but obvious side-by-side.

| Font tier | Matched line-height | Resolved ratio |
| --- | --- | --- |
| `--text-caption` (12 px) | `--lh-caption` (16 px) | ~1.33 |
| `--text-sm` (14 px) | `--lh-sm` (20 px) | ~1.43 |
| `--text-body` (16 px) | `--lh-body` (24 px) | 1.5 |
| `--text-body-lg` (18 px) | `--lh-body` (24 px) | ~1.33 — body line-height stretches to body-lg |
| `--text-h3` (24 px) | `--lh-h3` (~32 px) | ~1.33 |
| Display tiers | `--lh-display` / `--lh-hero` / etc. | unitless literal — see "Display line-height" below |

**Rule of thumb.** If you reach for a smaller font, also reach for a smaller line-height. Don't drag `--lh-body` along under caption text "for prose readability" — caption-tier text deserves caption-tier line-height; that *is* the readable choice at that font scale.

**Bug history.** May 2026 — `.tooltip--multi` shipped with `line-height: var(--lh-body)` because "it's prose, prose gets body line-height." Result: caption-size tooltip text read at 1.92 line-height ratio. The fix removed the override, letting the multi-line variant inherit `--lh-sm` from the base `.tooltip` rule. The validator now flags `line-height: var(--lh-body)` paired with `font-size: var(--text-caption|--text-sm)` (E015).

### Display line-height & gradient descenders — recurring bug

Two failure modes bite every time we ship a new display-type hero. Both are visible, both are one-line fixes, and both recur because the default tokens don't guard against them.

**1 · Never set `line-height: var(--lh-display)` (or `--lh-hero`) on a title that clamps up to `--text-hero`.**

The `--lh-*` tokens are **fixed pixel values** authored for specific body/heading sizes: `--lh-display = 64px`, `--lh-hero = 80px`. Pair them with a larger font-size and the ratio drops below 1.0 — lines literally overlap. The symptom: descenders on `g`/`y`/`p` collide with the next line's ascenders; a multi-line title visibly tucks into itself.

Minimum literal line-height for display-size type:

| Font-size range | Line-height |
|---|---|
| `--text-display` (56px) | `1.18` |
| `--text-hero` (80px) — multi-line | `1.1`–`1.18` |
| `--text-hero` (80px) — single-line | `1.0`–`1.05` acceptable |
| Mega sizes (112px+) | `0.94`–`1.0` — ratio tightens with canvas-fill scale, see above |

Use unitless literals, not `--lh-*` tokens, on any clamp-to-display title. The token-pairing rule above still holds for static sizes; it breaks on clamped display sizes.

**2 · Gradient text clips descenders at the text bounding box.**

`-webkit-background-clip: text` slices the paint at the text's content box. Any letter with a descender (`g`, `y`, `p`, `j`, `q`) inside a gradient span gets its tail cut off — most visibly on words like `digital`, `typography`, `Dashboard`.

Every gradient span on display type needs:

```css
.my-title .grad {
  background: var(--grad-brand-text);
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
  background-clip: text;
  /* three load-bearing lines — do not omit */
  display: inline-block;
  padding: 0.04em 0.08em 0.04em 0;
  line-height: inherit;
}
```

`display: inline-block` gives the span its own box. The vertical `padding: 0.04em` extends that box past the baseline so descenders have room. `line-height: inherit` keeps the span aligned to the parent's baseline; otherwise the inline-block computes its own `line-height: normal` and the span visually jumps above the rest of the title.

**Where this applies.** Everywhere LI.FI display type meets gradient fills: website hero titles, slide deck headlines, proposal covers, report covers, any skill that emits branded display type. Fix at the CSS source; downstream consumers inherit automatically.

**Audit ticket (when adding a new display-size title).** Confirm both rules at review:

- [ ] Line-height is a unitless literal ≥ 1.1 (or ≥ 0.94 for Mega sizes)
- [ ] Gradient `<span>` has `display: inline-block` + `padding: 0.04em 0.08em 0.04em 0` + `line-height: inherit`

Sites that got bitten so far: `doc-shared.css .header-title`, `page.css .hero h1`, `styles.css .uc-title`, `plans.html .plans-title`, `sign-in.html .signin-hero-title`. All fixed — reference implementations live in those rules.

### State changes shift ink, not weight — value slot identity is the weight

Within a single value slot — an amount field, a stat number, a KPI metric, a balance value, a counter, any slot whose role is "render a quantity" — preserve `font-weight` **constant** across all states: default, empty / zero, error, loading, disabled. The slot's typographic identity IS the weight. Color is the state signal.

A weight change reads as "a different element entirely" — the reader parses it as a different role or hierarchy level. The screenshot moment that pinned this down (May 2026): the `0` placeholder in an amount card rendered at weight 500 + 40% ink to communicate "empty"; both axes shifted at once. The result read as a label, not as the amount field in an empty state. Dropping just one axis (ink) preserved the slot's identity AND signaled state; dropping both broke the slot.

**What may change between states:**

- `color` / ink intensity — `--text-faint` / `--field-placeholder` for empty, `--danger` for error, `--text-muted` for disabled, etc.
- Optional secondary affordance — a dashed underline, a ghost shimmer, a dimmer secondary line below. Use only when the ink shift alone isn't enough.

**What never changes between states:**

- `font-weight` — fixed by the slot's tier
- `font-size` — fixed by the slot's tier
- `line-height` — same
- `letter-spacing` / `tracking` — same
- `font-family` — same

**Where this applies.** Amount cards, stat tiles, KPI tiles, balance values, counters, numeric labels, any slot that displays a quantity or value. Both product surfaces (swap widget, dashboards, signed-in app) and marketing surfaces (hero KPIs, marketing pages).

**Where this does NOT apply.** Content-type changes — when a slot swaps from a numeric value to a status pill or icon, that's a structural / layout change, not a state change, and a different primitive is allowed to take over.

**Bug history.** May 2026 — the zero-state demo on `#ui-amount-card` shipped with the amount at weight 500 + 40% ink. Designer feedback: *"now that I'm seeing a thinner font, I think we should keep the font weight consistent between the 'zero state' and when the amount is entered."* Fix: rolled back to default weight (700), kept the ink shift. Rule documented to prevent recurrence.

---

## 04 — Spacing & Grid

All spacing derived from an 8px base grid. Use multiples of 4px for micro-spacing. Never use arbitrary values.

| Token | Value | Usage |
|---|---|---|
| sp-1 | 4px | Icon gaps, tight element pairs |
| sp-2 | 8px | Inline gaps, label-to-input |
| sp-4 | 16px | Component internal padding |
| sp-6 | 24px | Card padding, section gaps |
| sp-10 | 40px | Major section spacing |
| sp-16 | 64px | Hero sections, page-level gaps |
| sp-24 | 96px | Large section padding |

**Layout system:** 1200px max content width · 12-column grid · 24px gutters · 24px margins

### Semantic gap families — `--stack-*`, `--cluster-*`, `--gap-card-*`

Three semantic-gap token families sit on top of the `--space-*` grid. Each owns a distinct spacing concern; reach for the right family at the right call site instead of raw `--space-*` so intent is in the source.

| Family | Range | Concern |
|---|---|---|
| `--stack-sm/md/lg/xl/2xl` | 16 / 24 / 32 / 48 / 64 | **Vertical content rhythm** between sections, form fields, paragraph blocks |
| `--cluster-xs/sm/md/lg` | 4 / 8 / 16 / 24 | **Inline-flex gaps** in horizontal rows — icon + label pairs, chip clusters, button groups |
| `--gap-card-xs/sm/md/lg/xl` | 4 / 8 / 12 / 16 / 24 | **Card-to-card spacing** in lists or grids |

**Card gap ladder (May 2026).** The card-gap family is a 5-tier ladder for the gap between card-shaped siblings. Token names describe POSITION on the ladder (`xs` / `sm` / `md` / `lg` / `xl`) — NOT the use case. Use-case-to-tier assignment is documentation; the tokens themselves are a clean numeric ladder. The `md` tier (12 px) is the default; reach for it first when in doubt.

**Why size-only names matter — the mapping is swappable.** Because the tier tokens are pure ladder positions, the entire mapping can be swapped as a unit. A future "card gap composer" can install a Compact or Spacious preset by overriding the 5 tokens at `:root` — same mechanism the Theme Composer uses to swap brand colours. Every consumer flips automatically via cascade; no component re-authoring needed. This is what makes the system future-proof.

**Consumption pattern.** Every card-list / card-grid component publishes a local `--card-gap` CSS custom property that defaults to one of the tier tokens, then reads `gap: var(--card-gap)`. This gives a component-level default plus a per-instance escape hatch — consumers override `--card-gap` inline or via context selector without re-authoring the component.

```css
.ui-quote-list {
  --card-gap: var(--gap-card-md);   /* component default */
  display: flex; flex-direction: column;
  gap: var(--card-gap);
}
```

```html
<!-- Per-instance override -->
<div class="ui-quote-list" style="--card-gap: var(--gap-card-lg);">…</div>
```

**Swapping the mapping — preset shape.** Future presets override the 5 tier tokens at `:root` under a data-attribute switch:

```css
:root[data-card-gap-preset="compact"] {
  --gap-card-xs: var(--space-2);
  --gap-card-sm: var(--space-4);
  --gap-card-md: var(--space-8);
  --gap-card-lg: var(--space-12);
  --gap-card-xl: var(--space-16);
}
```

The default mapping ships in `styles.css → :root`; preset overrides go on `:root[data-card-gap-preset="…"]`. Component CSS stays untouched.

**Convenience alias.** `--gap-card` (no modifier) resolves to `--gap-card-md`. Use the explicit `-md` name in component CSS for clarity; use the bare alias for inline overrides where brevity helps (`style="--card-gap: var(--gap-card);"`).

**Application matrix.** Where each tier is wired today. Use cases are guidance, not contracts — if a new card list reads better at a different tier, that's a designer call.

| Consumer | Tier | Default value |
|---|---|---|
| `.ui-amount-pair` (swap pair with FAB at seam) | `--gap-card-sm` | 8 px |
| `.playground-rail__theme-cards` | `--gap-card-sm` | 8 px |
| `.ui-quote-list` | `--gap-card-md` | 12 px |
| `.theme-card-grid` | `--gap-card-lg` | 16 px |
| `.tile-grid` / `.enterprise-grid` | `--gap-card-xl` | 24 px |
| `.uc-grid` | `--gap-card-xl` | 24 px |

**Rules.** One family per concern — don't cross the streams. **Reach for named tiers, never the raw `--space-*` underneath** — bypassing the tier to the raw value defeats the swappability (a future preset swap won't reach values authored as `--space-12`). The `md` tier is the default — start at `--gap-card-md` when in doubt about which tier a new card list should use. Custom values via `--card-gap` at the call site, not new tier names — the five-tier ladder is the ceiling. Catalog reference: `#card-gap` in `design-system/index.html`.

### Canvas grid spacing — `--grid-gap-x` / `--grid-gap-y` (composer-driven)

The horizontal + vertical rhythm of a content **canvas** (Portal dashboard, Playground widget stage) — distinct from the card-gap ladder above. Two mode-independent tokens, editable per-theme from the Theme Composer's **Spacing** section (a sibling of Corners).

| Token | Default | Drives |
|---|---|---|
| `--grid-gap-x` | `--space-16` (16) | horizontal gutter between side-by-side cards/panels → grid `column-gap` |
| `--grid-gap-y` | `--space-24` (24) | vertical rhythm → section `margin-bottom` + grid `row-gap` |

Two axes because the two rhythms differ by job — tight gutters between cards vs. roomier breathing between sections. **Composer control:** two standalone sliders (Horizontal + Vertical gap) under the Spacing section label, each on the ladder `8 / 12 / 16 / 20 / 24 / 32 / 40` — no preset cards (the trio + Custom-card pattern was dropped for gaps June 2026; bare sliders read clearer for a two-axis knob). Painted to the active canvas via `window.ThemeComposer.applyGapsToScope`; enabled in the Portal + Playground rail composers only (the FAB sitewide composer stays palette + corners). `.playground-canvas` pins `--grid-gap-x: 24` (its historical gutter); the Portal inherits the 16/24 defaults. **Reach for the token, not the underlying `--space-*`,** when wiring a canvas grid/section so the composer reaches it. Full contract: `CLAUDE.md → Canvas grid-spacing system`.

### Surface contrast — `--surface-sunk` recess (composer-driven, per-mode)

The canvas↔panel tonal step — how far the recessed canvas (`--surface-sunk`) sits below the page — editable per-theme from the Theme Composer's **Surface** section (a third sibling of Corners + Spacing). The slider value is the canvas's lightness **recess** below the page seed (`surf`) in L percentage points; the painted token is `--surface-sunk` L = `surf − contrast`.

| Mode | Baseline recess | Range (min → max) | Ceiling (sunk no lighter than) |
|---|---|---|---|
| dark | 1.5 (sunk 6.5 vs page 8) | 0 → `min(surf, 24)` | page (`surf`) — the panel is *lighter* than page in dark |
| light | 5 (sunk 95 vs page 100) | `surf − raised` (3) → `min(surf, 24)` | raised panel — the panel is *darker* than page in light |

**PER-MODE, not mode-independent like Corners/Spacing.** Surface contrast is a colour-family property whose headroom is genuinely mode-asymmetric (no single value reproduces the dark 1.5 / light 5 baseline), so it stores `surface: { dark, light }` and the section re-renders its slider range/value on mode flip (like the palette sliders, unlike the geometry sections). **Composer control:** one *continuous* `.slider-range--panel` (not a discrete ladder — contrast is a fine tonal axis), range recomputed per mode. Enabled in the FAB sitewide composer (paints `:root`, the same reach as Corners — a universal surface property) AND the Portal/Playground rail composers (paint the canvas). Painted via `window.ThemeComposer.applySurfaceToScope(surface, scope)`, which reads the scope's already-painted `--surface-page`/`--surface-raised` and re-asserts `--surface-sunk` AFTER `applyPalette` (the token is in `deriveTokens`' output, so applyPalette writes a baseline that the override must follow). Cold load paints nothing — the deriveTokens baseline stands. Full contract: `CLAUDE.md → Surface contrast control`.

### Border Radius

| Token | Value |
|---|---|
| sm | 4px |
| md | 8px |
| lg | 12px |
| xl | 16px |
| full | 9999px (pill) |

### Nested radius — concentric corners

When a rounded element nests inside another rounded element with padding between them, the two corner curves only read as *concentric* — staying parallel, a constant gap all the way around the bend — when the outer radius equals the inner radius plus the padding:

```
outer radius = inner radius + padding
inner radius = outer radius − padding   (same equation, solved for the child)
```

Match the radii without accounting for the padding (the naive `outer = inner`) and the curves pinch around the corner — the gap is uniform on the straight edges but collapses on the diagonal. It's the single most common rounded-corner mistake.

**Two operative directions, each with a clamp:**

- **Designing a frame around known content** (you know the child's radius; you're picking the container's) → `outer = min(inner + padding, 32px)`. The `min(…, 32px)` is a **ceiling** — beyond a sensible cap the encompassing card stops tracking the child so a large padding doesn't over-round it. Strict concentricity balloons the corner at large padding; the cap trades it for a calmer card (and the curves are far apart at that scale, so the lost concentricity barely reads). `--space-32` is the recommended ceiling.
- **Adding a nested element inside a known container** (you know the parent's radius; you're picking the child's) → `inner = max(outer − padding, 0px)`. The `max(…, 0px)` is a **floor** — a deeply-inset child goes square rather than negative. This is the default corner radius for any new nested element.

Express each with `calc()`, clamped — inward floors at 0, outward caps at `--space-32`:

```css
/* INWARD — deriving the child (--space-N is the parent's padding). */
.child  { border-radius: max(var(--card-radius) - var(--space-16), 0px); }

/* OUTWARD — deriving the frame / encompassing card (--space-N is the child's padding). */
.parent { border-radius: min(calc(var(--inner-radius) + var(--space-N)), var(--space-32)); }
```

The two clamps are twins: inward floors at 0 (corners go sharp when the child is deeply inset — the parent's straight edges begin before the child's corner, so any rounding crowds), outward caps at 32 (the card stays calm when the padding is large). Concentric *between* the clamps; flat *past* them. There is no `--nest-radius` token: the value depends on two per-context inputs (the radius + the gap), so the calc is the API. `.destination-card` is the reference outward consumer (seed 16 + pad 32 = 48, clamped to 32).

This is the rule for *deriving* a nested radius, **not** an auto-applied default. Card / panel / button radius all default to a flat 16 px (see §02 / the Radius system) for top-level surfaces, and that flat default is deliberate. Reach for the concentric calc when you genuinely nest a rounded child in a rounded padded parent and want the curves to track. Live visualization + worked examples at `design-system/foundations.html.html#nested-radius`; the durable contract is `CLAUDE.md → Nested radius — concentric corners`.

### Device resolutions & breakpoints

The screen sizes the team designs against, with a live visual reference in the catalog at `design-system/foundations.html.html#device-resolutions` (Foundations → Devices & breakpoints). The catalog card pairs a proportional device-frame visualization (all frames at one shared ≈0.12× scale, so relative footprints read true across the whole set) with an interactive viewport-width ruler that dims devices narrower than a target width. Devices are grouped by class (Laptops & desktops / Tablets / Phones), each zone separated by a `.ds-doc-divider--loose`; the ruler is the system `.slider` primitive, the live breakpoint a `.chip-accent1`, and every device frame doubles as a click/keyboard width preset.

**Logical viewport is the layout number.** Media queries and layout widths read CSS px (the *logical* viewport), not the hardware pixels. Native resolution × DPR is what the panel ships; design to the logical column and use native/DPR only for asset export (@2x / @3x).

**Laptops & desktops** (landscape): QHD 2560×1440 (DPR 1) · FHD 1920×1080 (DPR 1) · MacBook Pro 16" 1728×1117 · MacBook Pro 14" 1512×982 · MacBook Air 15" 1496×967 · MacBook Air 13.6" (M2/M3) 1470×956 · **MacBook Air 13" (M1) 1280×800** — all Apple Silicon at DPR 2, default scaled "looks-like" sizes; the 14"/16" go wider in "More Space".

**Tablets** (portrait): iPad Pro 11" 834×1194 · iPad 10.9" 820×1180 (DPR 2; landscape swaps the axes).

**Phones** (portrait): iPhone 16 Pro Max 440×956 · iPhone 14/15 Plus · Pro Max 430×932 · Pixel 8 412×915 (DPR ≈2.6) · iPhone 16 393×852 · iPhone 12–15 390×844 · iPhone SE (2/3) 375×667 (DPR 2, home button) · Galaxy S 360×800. Apple phones are DPR 3 unless noted; reserve the notch / Dynamic Island / home-indicator safe areas with `env(safe-area-inset-*)`.

**Breakpoint anchors** are the industry-standard Tailwind references — sm 640 · md 768 · lg 1024 · xl 1280 · 2xl 1536 (base = 0–639, mobile-first) — shown for cross-team shorthand only. LI.FI's own floating-surface widths are the `--w-*` ladder (§02 / `#spacing` → Surface widths), not these.

**1280 px is the practical design floor.** Treat "laptop and wider" as ≥ 1280 px (MacBook Air 13"). This is the width the **Advanced Limit widget** is sized to fit: footprint = 1168 px (view-rail 64 + form 416 + receive 640 + two 24-px gaps), leaving ≈ 56 px margin each side on a 1280 screen and scaling generously on 1440+. The widget's `swap.css` "Advanced widget tier" comment points back at this reference; this section closes that loop (see `design-system/playground.html#dual-layout`).

**The visualization primitive** (`.ds-doc-device` + `.ds-doc-device-ruler` + `.ds-doc-bp-track`) is catalog-meta documentation chrome (tier-1, `.ds-doc-` namespace) — `styles.css` → search `Device frame — screen-size reference`. Per-device sizing is data-derived (`--dw`/`--dh` = logical resolution × scale; `data-logical-w` drives the ruler); chrome is fully tokenized.

---

## 05 — Logo

6 official logo variants across three formats and two colorways.

### Variants

**Icon Mark** — Diamond mark only. Use when the full wordmark is not required (favicons, app icons, avatars).

**Horizontal Lockup** — Icon mark + "LI.FI" wordmark side by side. Primary format for nav bars, email headers, and co-brand badges.

**Vertical Lockup** — Icon mark stacked above "LI.FI" wordmark. Use for square or constrained format applications.

### Colorways

- **Light (black fill)** — On white (#FFFFFF) or light surfaces
- **Dark (white fill)** — On Ink 900 (#0B0D12) or dark colored backgrounds
- **On Brand Pink** — Use the black (light) version on #F7C2FF

### Usage Rules

**Clear space:** Minimum 1× the height of the logomark on all sides. Never crowd with other elements.

**Minimum size:** 24px height for the icon mark in digital. 48px for horizontal lockup. 12mm for print.

**Approved backgrounds:** White (`#FFFFFF`), Ink 900 (`#0B0D12`), Brand Pink (`#F7C2FF`). Never place on busy imagery, low-contrast colors, or photographic backgrounds without a solid container.

**Never:** Recolor, distort, recreate, add effects, or change proportions of the mark.

### Powered by LI.FI — Co-Brand Lockup

Three approved chip variants for SDK integrations and partner surfaces:

- **Dark chip on white** — SDK integrations, product footers, partner materials
- **Light chip on dark** — Dark UI contexts, product cards on dark surfaces
- **Blue chip on blue tint** — Hero sections, landing pages, partner spotlights

Rules: Minimum 24px height. Clear space = 1× the LI.FI icon width on all sides. Never modify the wordmark or add bylines. External partners use the dark badge. Internal sub-products use the blue badge.

### Avatars Asset Library — chain / token / DEX / bridge / wallet / social / LI.FI sub-product brand marks

Icon-only brand marks — the icon portion of a chain, token, bridge, DEX, wallet, payments product, social platform, or LI.FI sub-product brand, with the brand background color baked into the source so no wrapper tint is needed. Live in a single top-level `avatars/` directory at the repo root, sub-grouped by asset type. This is the **only** place icon-only brand marks are stored — inline SVGs in components, Figma copy-paste, or ad-hoc downloads are not allowed. Briefly renamed to `marks/` in May 2026 when the Libraries catalog split into Marks · Partner Logos · Portraits, then reverted to `avatars/` once the asset-library name and the consuming `.avatar` component name were aligned. Sibling libraries: `logos/partners/` (full wordmark+mark lockups, rendered through `.ds-doc-partner-logo`) and `portraits/` (persona / team photography, rendered through `.avatar` with optional plate modifiers).

**Folder structure**

```
avatars/
  chains/      blockchain icon marks (one file per chain)
  tokens/      token tickers (one file per token ticker)
  bridges/     bridge-protocol marks
  dexs/        DEX / aggregator marks
  wallets/     wallet-app marks
  payments/    card / payments-product marks (Visa, Kast)
  social/      social-platform marks
  lifi/        LI.FI sub-product marks
```

**Partner-org marks span folders by product type.** A partner organization's identity mark (rendered by the Portal org switcher + page-head and the `partners.html` catalog via `partner-orgs-data.js → logo.mark`) lives in the `avatars/` subfolder matching the org's **product type**, not in one shared "orgs" bucket — wallet orgs in `wallets/` (`ledger.svg`, `rabby.svg`, `slush.svg`), card / payments orgs in `payments/` (`kast.svg`). So a `logo.mark` path spans folders; pick the folder by what the org *is*, not by the fact that it's an org. An org with no brand assets (VilenDesign) sets `logo.mark: null` and falls back to an `.avatar--initial` monogram (brand-toned via `monogramTone`).

**Filename convention**

- **Kebab-case**, lowercase, `.svg` extension: `ethereum.svg`, `arbitrum-nova.svg`, `polygon-zkevm.svg`.
- Single-word chain names stay single words (`ethereum.svg`, not `eth.svg`). Tickers belong in `avatars/tokens/` where `eth.svg`, `usdc.svg`, etc. live.
- Multi-word names replace separators with `-`: `arbitrum_nova` → `arbitrum-nova`, `polygon_zkevm` → `polygon-zkevm`, `xdc_network` → `xdc-network`.
- Numeric prefixes that are part of the brand are preserved as-is: `0g.svg` is correct (the chain is literally named "0G").
- Testnets are suffixed `-testnet`: `arc-testnet.svg`. Mainnets never carry a suffix.

**SVG format**

Every mark in the library is a **32 × 32 viewBox SVG pre-plated with its brand background color** as the first `<path>`. This means the asset is self-contained — no wrapper background is needed to render it at any size, and reducing it to a circle naturally fills with the brand color. Don't add a second background in CSS; don't modify the plate color. If a mark arrives transparent, either request the plated export from the source or reject it until one is available.

**Rendering**

All three places (web UI, design catalog, marketing site) render marks through the canonical `.avatar` component (the component class name didn't change — it's the rendering primitive, agnostic to asset source). Never inline `<img src="avatars/...">` without `.avatar` — you'll get inconsistent sizing, missing border-radius, and no fallback plate. The component supports:

| Axis | Classes | Notes |
| --- | --- | --- |
| Size | `--sm` (24) · `--md` (32, default) · `--lg` (48) · `--xl` (64) | 32 matches the source viewBox for the sharpest render |
| Shape | `--square` (4 px radius, default) · `--rounded` (25 %) · `--circle` (50 %) | Circle clips the plate into a perfect disk |
| Modifier | `--ring` | 1 px inset alpha border — useful when the plate is close to the surrounding surface color (Ethereum's purple next to a dark panel) |
| Pattern | `.avatar-stack` · `.avatar-stack--tight` | Overlapped row of marks — the "integrated with N chains" marketing pattern. Each avatar after the first shifts left 30 % (or 45 % for tight); a 2 px transparent cutout (`mask-image` radial-gradient on each non-last avatar) makes the gap show whatever surface sits behind. |

**When to use each shape**

- **Square (default)** — table cells, inline lists, dense data views where the mark reads as a neutral tile. Keeps the source geometry intact.
- **Rounded (25 %)** — chips, badges, pricing tiers, feature cards. The iOS-style squircle-adjacent curve softens the tile without committing to a full circle.
- **Circle (50 %)** — profile contexts, marketing "latest integration" moments, hero stacks. Most branded — use when the mark *is* the moment.

**Adding a new mark to the library**

1. Export the asset at **32 × 32** from Figma with the brand color as a full-bleed background rect as the first layer.
2. Save into the right subfolder (`avatars/chains/`, `avatars/tokens/`, etc.).
3. Name the file in kebab-case matching the canonical brand name.
4. Add a cell to the **Avatars** section (`#avatars`) of `design-system/index.html` so the mark appears in the catalog grid. The catalog is hand-authored — dropping a file in without updating the grid leaves it invisible to future work.
5. If the mark will be referenced from a component (e.g. a chain selector), cross-reference it in the component's spec so future engineers know to reach for the library rather than inlining a new SVG.

**Don'ts**

- Don't store marks outside `avatars/`. No component-local assets, no `public/images/chain-logo.svg`, no base64 inlines. One directory, one source of truth.
- Don't rename files retroactively without updating every `src=` reference. Prefer adding a redirect/alias only if absolutely required — most renames should propagate as code changes.
- Don't apply color filters, drop shadows, or tints to a library mark. The plate is the brand — post-processing it is a brand violation.
- Don't pair a mark with a duplicate text label unless the layout genuinely needs both (e.g. a selector row). Mark + label is useful; mark + label + tooltip is noise.
- Don't put partner wordmarks here. Wordmark+mark lockups (with brand typography) live in `logos/partners/` and render through `.ds-doc-partner-logo`. See **Partner Logos** below.

### Portraits Asset Library — Personas, Team, External

Photographic portraits of people — fictional personas, real LI.FI employees, advisors / named customers / partner-side individuals — live under `portraits/` at the repo root, parallel to the icon-only brand-mark library at `avatars/`. The component contract is identical (everything renders through `.avatar`), but the source format and generation pipeline differ because these are raster photos with alpha, not 32×32 plated SVGs. **Folder name as of May 2026** — previously `avatars/people/`, renamed when the Libraries catalog split into Avatars · Partner Logos · Portraits and the file layer aligned with the editorial layer (same pass renamed `avatars/` → `avatars/` for the icon-only brand library).

**Folder structure**

```
portraits/
  personas/   fictional archetypes (Kim, Finn, Dale, Mabel) — reusable in docs, demos, marketing exploration
  team/       real LI.FI employees — internal sign-off required before any external surface
  external/   advisors, named customers, partner-side individuals — explicit consent required; may expire
```

The three buckets exist because their **governance differs**. Personas have no consent constraint; you can ship them anywhere. Team photos need an internal greenlight before they leave a private surface. External photos carry per-person terms (often time-bound). Encoding that in the path means "is it safe to drop on the public site?" answers itself.

**Filename convention**

- **Personas** — `<archetype-or-firstname>.png`. First-name only because personas are branded characters, not real people: `kim.png`, `finn.png`, `dale.png`, `mabel.png`.
- **Team / External** — `<firstname>-<lastname>.png`. Sorts the folder by first name when scanning visually: `vilen-rodeski.png`.
- **Collisions** — append a short role/dept tag: `philipp-eng.png` vs `philipp-bd.png`. Keep the tag to 2–4 letters.
- **Size derivatives** — `<slug>-<width>.png`. The `<width>` is the pixel width with no `w` suffix — when authoring `<img srcset>` the filename IS the descriptor minus the `w`: `srcset="kim-96.png 96w, kim-256.png 256w, kim.png 1024w"`. No translation step.
- Kebab-case, lowercase, ASCII only. No spaces, no underscores, no accents — same as the chain/token library.

**File format**

- **Master** — PNG-24 with full alpha (transparent background), **1024 × 1024 minimum**, square crop, subject centered with ~10 % headroom above the top of the head. The build script warns if a master lacks alpha or is smaller than 256 px.
- **Derivatives** — PNG-24 with alpha at **96** and **256**, generated by `scripts/build-people-avatars.js`. Never hand-resize.
- **Why PNG and not WebP/AVIF** — photos with alpha need it, and PNG is universal across every tool the team uses (Figma, Keynote, PowerPoint, decks, external print vendors). Lossy alpha-WebP can come later as a `<picture>` optimization if bytes ever matter; today the simplicity wins.

**Why these sizes (matches the `.avatar` ladder at 2× retina)**

- **96** covers `.avatar--xs` (24) / `--sm` (32) / `--md` (40) / `--lg` (48) at 2×.
- **256** covers `.avatar--xl` (64) / `--2xl` (96) at 2×.
- **Master (1024+)** covers marketing surfaces, deck exports, hero rows, future bigger tiers.

Three sizes is the sweet spot: one more would shave bytes; one fewer would force the browser to downscale a 1024 master into a 48-px table cell on every paint. The script enforces these two derivatives — extending the ladder is a deliberate decision (edit the `SIZES` array + this section), not a per-photo override.

**Rendering**

All consumers render through the canonical `.avatar` class with `<img srcset>` so the browser picks the smallest file that fits the viewport + DPR. Don't hand-write `<img src="…">` without `srcset` — defeats the size ladder.

```html
<img class="avatar avatar--lg avatar--circle avatar--ring"
     src="portraits/personas/kim-96.png"
     srcset="portraits/personas/kim-96.png   96w,
             portraits/personas/kim-256.png 256w,
             portraits/personas/kim.png    1024w"
     sizes="(max-width: 600px) 48px, 96px"
     alt="Kim — Shrimp persona">
```

`srcset` (not `<picture>`) because every modern engine supports PNG natively — no fallback needed. `sizes` should reflect the rendered avatar's CSS dimensions at each viewport; the browser uses it to pick the best matching `srcset` candidate.

**Shape modifiers — identical to the chain/token library**

People avatars use the same three shape modifiers as every other `.avatar` in the system; nothing avatar-specific to remember:

| Class | Radius | When to reach for it |
| --- | --- | --- |
| `.avatar--square` *(default)* | 4 px (`--radius-sm`) | Dense lists, table rows, anywhere the avatar reads as a neutral tile. **Pairs with a plate** — without one, transparent corners reveal the parent surface and the "square" silhouette disappears. |
| `.avatar--rounded` | 25 % | Cards, profile chips, feature tiles. The squircle-adjacent softening; pairs equally well with or without a plate. |
| `.avatar--circle` | 50 % | Profile contexts, hero stacks, head-shot avatars. **The default reach for transparent PNG portraits** — clips the corners that would otherwise leak, and matches the head-and-shoulders crop. |

`.avatar--ring` (the 1 px inset alpha border) layers on top of any shape and is especially useful on plated people avatars sitting next to plated chain logos — keeps the cluster visually rhythmic.

**Plate modifiers — colored ground revealed through the photo's alpha**

People photos ship as transparent PNGs (see "File format" above), which means a plate behind the image is opt-in via class or inline style. The plate routes through the existing `--avatar-bg` slot on `.avatar`, so it works the same way as the letter-block tint behind chain SVGs:

| Class | Effect |
| --- | --- |
| *(none)* | Default — `var(--alpha-dim)` neutral plate. The minimum-contrast ground that keeps every people avatar feeling consistent next to plated chain logos. |
| `.avatar--plate-primary` | LI.FI 1.0 primary accent (`--lifi-sapphire`). Role-named so the modifier describes intent, not paint — the underlying token is still the brand blue. For "this person is on a brand surface" contexts. |
| `.avatar--plate-secondary` | LI.FI 1.0 secondary accent (`--lifi-pink`). |
| `.avatar--plate-tertiary` | LI.FI 1.0 tertiary accent (`--lifi-ink`). |
| `.avatar--plate-ink` | Deep neutral — `oklch(20% 0.015 268)`, hue-268 with chroma ~0. Stays the same in both themes (does NOT flip with light mode), so "ink" reads as ink everywhere. High-contrast ground for light-skin portraits or hero standouts where the brand gradients would feel too on-brand. |
| `.avatar--plate-paper` | Light neutral — `oklch(95% 0.005 268)`. Theme-stable companion to `--plate-ink`. Quiet, almost paper-white; pair with `--ring` on dark surfaces so the plate edge stays visible. |
| `.avatar--plate-brand` | The 135° `--grad-brand` linear gradient — blue at top-left, teal at bottom-right. The default brand-ground reach. |
| `.avatar--plate-brand-rich` | The 4-stop `--grad-brand-rich` linear gradient — higher saturation, more chromatic transition. For hero-tier portraits where the plate is part of the moment. |
| `.avatar--plate-brand-radial` | Radial blue→teal vignette centred at 50% 35% (lighter teal halo behind the face). Re-creates the original Imagen portrait ground. Best on `--circle` at 64 px+. |
| `.avatar--plate-none` | Strips the fallback to fully transparent. Free-floating silhouette. Use sparingly — usually on hero portraits where the surrounding gradient is doing the framing. |
| *(inline)* | `style="--avatar-bg: <oklch-or-gradient>"` — one-off custom plates without polluting `styles.css`. |

Because plates render *behind* the image and JPEGs are fully opaque, **applying a plate to a JPEG is a no-op, not a bug** — the modifier is rendered but invisible. This is why plates are a PNG-only feature in practice: the modifier doesn't fail, it just can't reveal itself through an opaque photo.

**Shape × format compatibility — read this before shipping a new layout**

The matrix that catches the "why does my square avatar look like a floating head" support cycle:

| Format | `.avatar--square` | `.avatar--rounded` | `.avatar--circle` |
| --- | --- | --- | --- |
| Transparent PNG, **no plate** | ⚠ Transparent corners reveal parent surface — looks free-floating | ⚠ Same issue, softened | ✓ Natural head-shot |
| Transparent PNG, **any plate** | ✓ Reads as a brand tile | ✓ Squircle tile | ✓ Brand disk |
| JPEG (baked background) | ✓ Tile | ✓ Squircle | ✓ Disk (background gets cropped) |

The `⚠` rows aren't bugs — they're intentional silhouette renderings that occasionally look right (hero portraits, marketing standouts). Reach for them deliberately; don't fall into them by forgetting the plate.

**Adding a new person to the library — step by step**

1. **Source the photo.** Crop to square, subject centered, ~10 % headroom above the head. Remove the background to alpha (Figma "Remove background", Photoshop "Remove background", or Photoroom). Confirm the photo's licensing/consent fits the target bucket (personas: any; team: internal sign-off; external: explicit per-person terms).
2. **Export the master.** PNG-24 with full alpha, **1024 × 1024 minimum**. Save into the right subfolder under `portraits/`.
3. **Pick the slug** per the rules above. The slug is permanent across the codebase — every consumer references it by name, so a later rename is a multi-file edit.
4. **Run the build script:**
   ```sh
   node scripts/build-people-avatars.js              # all three buckets
   node scripts/build-people-avatars.js personas     # just one bucket
   ```
   The script emits `<slug>-256.png` and `<slug>-96.png` next to the master, warns on missing alpha or undersized masters, and skips derivatives that are already newer than the master. Safe to re-run any time.
5. **Commit the master + both derivatives in the same commit.** CI does not regenerate; if a consumer ships before the derivatives land, every `srcset` entry below the master 404s and the browser falls back to the 1024 PNG (defeating the whole ladder).
6. **If the new person appears in a catalog grid**, add a cell to the **Brand Asset Library → People** subsection of `design-system/index.html`. The catalog is hand-authored — dropping files in without updating the grid leaves them invisible to the next contributor.

**Don'ts**

- **Don't store people photos outside `portraits/`.** No `public/team/`, no component-local images, no inline base64 portraits, no Figma-export drops next to HTML.
- **Don't hand-resize derivatives.** The `sharp` pipeline uses Lanczos resampling at PNG compression level 9 — manual exports drift on bytes and edge quality, and the next contributor can't reproduce them.
- **Don't commit a master without its derivatives.** See step 5 above. If you forgot, re-run the script before pushing.
- **Don't apply color filters, theme tints, or background plates inside the PNG.** Photos are documentary, not decorative. If a layout needs the person on a colored ground, build the ground in CSS behind the alpha mask — that way the same photo works on every brand surface.
- **Don't reuse a real-person photo for a fictional persona, or vice versa.** The folder boundary is also a semantic boundary — `kim.png` must never resolve to a real teammate's face.
- **Don't ship `external/` photos to public surfaces without re-confirming consent.** External-bucket photos carry per-person terms that may have expired since the file was committed; treat the bucket as "ask before each new use."

---

## 06 — Core Components

The component specs have been split into family files under `design/components/`. Each file contains the verbatim spec for a related cluster of components — sizing ladders, anatomy, variant tables, dos and don'ts.

- [Foundations](design/components/foundations.md) — Sizing Ladders, Elevation, Tile vs Card
- [Actions](design/components/actions.md) — Buttons
- [Surfaces](design/components/surfaces.md) — Cards, Screen header, Stat Card, Content tile, Brand tile
- [Forms](design/components/forms.md) — Form Inputs, Hint, Radio, Radio card, Switch, Setting Row
- [Chips](design/components/chips.md) — Scope chips
- [Feedback](design/components/feedback.md) — Alert, Status dot, Spinner, Notifications
- [Imagery](design/components/imagery.md) — Avatars, Badge, Badges, Delta Badges, Swatch, Palette strip
- [Data viz](design/components/data-viz.md) — Data Table, Chart Gradients, Chart Tooltips, Progress, Token ladder, Flow Diagrams, Viewport
- [Navigation](design/components/navigation.md) — Global Nav, Side nav, Steps, Menu, Dropdown, Tooltip, FAB, Tab nav, Command palette, Search trigger
- [Page sections](design/components/page-sections.md) — Hero, Logo Clouds
- [Lists](design/components/lists.md) — Lists, Description List
- [Swap](design/components/swap.md) — Domain Components, Swap widget

---

## 07 — Voice & Tone

LI.FI speaks like a precision instrument, not a hype machine. Confident without being arrogant. Technical without being opaque. Forward-looking without being vague.

### Voice Principles

**Precise & Direct** — Lead with the outcome. Cut filler words. Every sentence earns its place. If you can say it in 6 words instead of 12, do it.

**Technically Credible** — We speak to developers and institutions. Use correct terminology. Don't oversimplify to the point of inaccuracy — our users are smart.

**Quietly Confident** — Let the numbers speak. $50B+ volume, 60+ chains, 500+ integrations. We don't need exclamation marks or FOMO language to make our case.

**Future-Oriented** — We're building infrastructure for where crypto is going: AI agents, RWA, stablecoin payments. Frame everything in terms of what becomes possible.

**Builder-Empathetic** — We understand the complexity of the problem we're solving. Acknowledge the hard parts — we've done the work so our integrators don't have to.

**Never Hype** — No "revolutionary", "game-changing", "paradigm-shifting". No rocket ships. No moon. LI.FI is infrastructure. Infrastructure is serious.

### Copy Examples

**Do:**
- Hero headline: "The Universal Execution Layer."
- Feature callout: "One integration. 60+ chains. Best price, every time."
- CTA: "Start building →"

**Don't:**
- Hero headline: "Revolutionary Cross-Chain DeFi Solution 🚀"
- Feature callout: "Our amazing, game-changing platform supports tons of chains!"
- CTA: "Join the revolution! Get started now!!!"

---

## 08 — Use Case Messaging

Each use case has a distinct audience with different priorities. Adapt the core LI.FI message to what they actually care about — never lead with the technology, lead with the outcome.

| Use Case | Audience | Headline | Supporting Message |
|---|---|---|---|
| Wallets | Wallet dev teams | "Invisible bridging. Native experience." | Drop in LI.FI's SDK and your users get best-route bridging across 60+ chains — without ever leaving your app. |
| DeFi Protocols | Protocol engineers | "One transaction. Any chain. Any action." | Compose multi-step cross-chain workflows — swap, bridge, stake, deposit — executed atomically via LI.FI Composer. |
| Payments | Fintech builders | "Stablecoin rails, built for the real world." | Enable USDC/USDT payments across any chain with best-rate conversion, instant settlement, and enterprise-grade reliability. |
| Neobanks | Crypto banking teams | "Banking superpowers. Self-custodial." | Give your users VISA cards, fiat off-ramps, and multi-chain access — without giving up custody. Powered by LI.FI. |
| RWA | TradFi / institutions | "Any asset. Any chain. Institutional grade." | Access, move, and interact with tokenized RWAs across all chains through a single, audited integration with $1M bug bounty coverage. |
| AI Agents | AI / agent builders | "Let your agents act, not just think." | LI.FI's MCP Server, Agent Skills, and AI-first docs give your agent the ability to execute cross-chain transactions autonomously. |

---

## 09 — Motion Principles

Motion is functional, not decorative. It confirms actions, directs attention, and communicates state. Never animate for the sake of animation.

**Competitive note:** Analysis of 24 brand guides confirms zero competitors have formally documented motion token systems. Wormhole is motion-rich but uncodified. 0x has none. LI.FI's Directional Motion Language below is a defensible differentiator.

### Motion Tokens

| Name | Spec | Usage |
|---|---|---|
| Micro Transitions | `150ms · ease-out` | Hover states, focus rings, chip appearance, button presses |
| Panel Transitions | `220ms · cubic-bezier(0.2, 0, 0, 1)` | Modals, drawers, dropdowns, sheet openings |
| Page Transitions | `300ms · ease-in-out` | Route changes, wizard steps, section scrolls |
| Route Progress | `Stepped · 400ms per step` | Cross-chain transaction progress — each step animates in sequence |
| Skeleton Loading | `Pulse 1.5s · ease-in-out · infinite` | Route fetching, balance loading, quote refreshing |
| Success Confirmation | `Scale 0.8→1.0 · 350ms · spring(1, 0.5)` | Transaction confirmed, route selected, integration complete |

### Directional Motion Language

LI.FI's chevron mark has inherent forward directionality. All motion reinforces this — never reverse or radiate outward.

**Left to Right** — Route visualization, chain bridging progress, step-by-step flows. Always forward, never reversed.

**Top to Bottom** — Page reveals, content loading, list population. Elements enter from top-center and settle downward.

**Expand from Center** — Success confirmations, route found state, completion indicators. Scale from 0.8 to 1.0 with spring easing.

---

## 10 — Sub-Brand System

LI.FI's 6 use cases map to distinct enterprise buyer profiles. A sub-brand color system lets each vertical feel purpose-built while sharing the core LI.FI identity.

**Competitive note:** Only 3 of 24 competitors analyzed have coherent sub-brand systems: deBridge, OKX, and 1inch. Sub-brand systems are underused across DeFi — this is a meaningful differentiator for LI.FI.

| Vertical | Accent | Rationale |
|---|---|---|
| Wallets | `#0047FF` — Execution Blue | Developer-facing, SDK-first. Primary blue is home base — wallet teams speak the same language as the core brand. |
| DeFi Protocols | `#7C3AED` — Protocol Purple | Composability and smart contract depth. Purple has deep DeFi resonance while staying distinct from core blue. |
| Payments | `#10B981` — Payments Green | Settlement, stablecoin rails. Green signals successful money movement and aligns with universal payment UX conventions. |
| Neobanks | `#F59E0B` — Neobank Gold | Financial products, premium banking feel. Gold conveys value and warmth for the consumer-adjacent positioning. |
| RWA | `#0B0D12` — Foundation Ink | Institutional, TradFi-adjacent, serious. The deepest neutral in the palette — zero ornamentation, maximum gravitas. |
| AI Agents | `#00BFA5` — Route Teal | Agentic, forward-motion, autonomous. Teal is the "flow" color — autonomous agent activity is the ultimate expression of flow. |

---

## 11 — Photography Direction

Every competitor uses abstract geometric illustrations. LI.FI's institutional positioning demands a human dimension — real people using financial infrastructure, not floating blockchain nodes.

**Competitive note:** Of 21 brand guides analyzed, zero DeFi/Web3 brands use photography as a primary visual system. LI.FI's enterprise clients (banks, fintechs, RWA platforms) see themselves in photography, not in floating hexagons.

### Approved Styles

**Builders at Work** — Developers and engineers in real environments. Laptops, screens, terminals. Authentic concentration and problem-solving energy — not stock photo smiles.

**Institutional Partnerships** — Professional settings, conference rooms, handshakes. The visual language of B2B enterprise trust — for RWA, neobank, and institutional sales materials.

**Product in Context** — Real devices, real hands, real interfaces. UI shown in context — on a phone, in a wallet, within a trading interface. Product screenshots treated as design objects.

**Global Infrastructure** — Data centers, fiber cables, satellite imagery, urban financial districts. LI.FI is infrastructure — and infrastructure photography signals scale and permanence.

**Color Treatment** — Apply a subtle cool-blue grade on all photos. Reinforces the institutional palette and creates visual cohesion across diverse subject matter. Avoid warm-orange filters.

### Never Use

- Forced diversity stock photos with generic smiles
- Neon-lit "crypto trader" clichés
- Rocket ship backgrounds
- Abstract blockchain node diagrams

---

## 12 — Visual Do & Don't

The most common mistakes that dilute the LI.FI brand identity.

### Layout

**Do:** Light background, precise typography, single restrained CTA, no decorative elements, clear visual hierarchy from headline to supporting copy to action.

**Don't:** Dark backgrounds with neon colors, all-caps screaming copy, emoji overuse, no visual hierarchy, "degen aesthetic."

### Data Display

**Do:** Lead with the key number at large scale, use a single status badge, keep supporting metadata small and muted, one clear primary action.

**Don't:** Stack 4+ competing badges ("BEST RATE", "FASTEST", "FEATURED", "TOP PICK"), add asterisk disclaimers, use approximate language when precision is available, include legal copy inline.

### Copy

**Do:** Precise, specific, outcome-first. "The fastest route. Always."

**Don't:** Vague, hype-driven, maximalist. "MOON ROUTE FOUND!!!" with fire emojis.

### Color

**Do:** Predominantly white canvas with controlled blue accents on interactive elements.

**Don't:** Full dark backgrounds with neon green strokes for DeFi aesthetic — this conflicts with the institutional positioning.

---

## Design System Files

| File | Purpose |
|---|---|
| `styles.css` | Single source of truth: tokens, reset, layout utilities, shared component patterns. **Sole cascade source** — see §02 "LI.FI 1.0 is the only default" for the bug-history that pinned this to one file. |
| `lifi-brand-style-guide.html` | Interactive brand style guide with live component demos |
| `competitor-brand-analysis.html` | Competitive intelligence report with brand positioning analysis |

### CSS Token Reference

```css
/* Brand Colors */
--lifi-sapphire: #0047FF;
--lifi-sapphire-light: #EBF0FF;
--lifi-pink: #00BFA5;
--lifi-pink-light: #E6F8F5;

/* Neutral Scale */
--ink-0: #FFFFFF;
--ink-50: #F7F8FA;
--ink-100: #E9EBF0;
--ink-200: #D1D5DB;
--ink-300: #9CA3AF;
--ink-400: #6B7280;
--ink-500: #4B5563;
--ink-700: #1E2330;
--ink-900: #0B0D12;

/* Semantic */
--success: #10B981;
--warning: #F59E0B;
--error: #EF4444;

/* Gradients */
--grad-brand-text: linear-gradient(135deg, #0047FF, #0090E0, #00BFA5);
--grad-dark: linear-gradient(135deg, #0B0D12, #1E2330);

/* Typography */
--font-sans: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;

/* Spacing (base 4px) */
--sp-1: 4px;   --sp-2: 8px;   --sp-3: 12px;  --sp-4: 16px;
--sp-5: 20px;  --sp-6: 24px;  --sp-8: 32px;  --sp-10: 40px;
--sp-12: 48px; --sp-16: 64px; --sp-20: 80px; --sp-24: 96px;

/* Border Radius */
--r-sm: 4px;  --r-md: 8px;  --r-lg: 12px;
--r-xl: 16px; --r-full: 9999px;
```

---

## 13 — Contributing to the Design System

Rules for adding or changing components in the design system. These apply to anyone — designer, engineer, or AI agent — proposing a change to `styles.css`, `design.md`, or `design-system.html`.

### Audience priority — who the catalog is for

Every visual and editorial decision in the design system answers this ranking:

1. **Designers** — primary. They scan the catalog by *variant name* + a glance at the live preview. They want to recognise components instantly without reading prose. The catalog is their atlas.
2. **Engineering** — secondary. Engineers arrive with the component name in mind and click into Markup / Specs / Source for the implementation detail. They don't need the design framing; they need copy-paste-ready code and accurate token references.
3. **Marketing** — tertiary. Marketing reaches the catalog as a brand reference — what's our button style, what's the accent treatment doing right now. They benefit from legible names, real previews, and palette-accurate demos. They don't read engineering panes.

(AI scrapers and screen readers parse the DOM directly; they're served by structure — `data-pane` keys, `hidden` attribute on inactive panes — not by visual hierarchy. They don't compete for catalog real estate.)

This ordering drives the rules below. Whenever a question comes up — "should this be bigger, brighter, more compact, more verbose?" — answer it from the designer's perspective first, the engineer's second, the marketer's third. Drift comes from optimising for the engineer's needs (more tables! more code blocks!) at the cost of the designer's scanning speed.

### Label shapes — three canonical patterns, no others

The `.ds-doc-card__label` is the dominant element on every card (h3 scale, sans, primary text colour). It carries the variant name plus, when relevant, the canonical CSS class(es). The catalog already converges on three shapes — codify them so future drift gets caught.

**Pattern A — Component card** (one card, one canonical class):

```html
<h3 class="ds-doc-card__label">Variant name <code>.canonical-class</code></h3>
```

The most common shape (~63% of cards). Use it whenever the card documents a single CSS class that the reader will paste into HTML. Examples: `Brand button .btn-primary`, `Color chips .color-chip`, `Switch .switch`.

**Pattern B — Reference / aggregate card** (no single canonical class):

```html
<h3 class="ds-doc-card__label">Reference name</h3>
```

About a third of cards. Use it for cards that document a system, matrix, set of rules, or abstract concept rather than a single class. Examples: `Variant matrix`, `Form anatomy & rules`, `Authoring guide`, `Slots — the parts of every hero`.

**Pattern C — Family card** (one card, two or three sibling classes that share one preview):

```html
<h3 class="ds-doc-card__label">Family name <code>.class-a</code> · <code>.class-b</code></h3>
```

Used sparingly (~7% of cards). Reach for this only when two or three classes share the same preview, anatomy, and ruleset — token-chip + chain-badge use the same shape and recipe; documenting them in two cards would duplicate without adding clarity. Examples: `Token chips & chain badges .token-chip · .chain-badge`, `Layout modifiers --block · --scroll · --equal`, `Eyebrow .eyebrow-code · .eyebrow-word`.

**Separator: middle dot `·` (U+00B7), surrounded by single spaces.** Don't use slash, ampersand, or comma between `<code>` tags. The `·` reads as "these belong together"; `/` reads as "or"; `&` reads as conversational prose, not the precise list these labels are.

#### Forbidden patterns

- **Class-only labels** — `<code>.btn-primary</code>` with no leading human-readable name. Designers don't think in class names; they need a noun phrase to recognise the card while scrolling.
- **All-caps labels** — `BRAND BUTTON .BTN-PRIMARY`. The old eyebrow-style treatment was reverted in the visual-hierarchy fix; sentence case fits the h3 type scale.
- **Slash separator** — `<code>.a</code> / <code>.b</code>`. Use ` · ` instead.
- **Mixed separators in one catalog** — pick `·` once, use it everywhere.

#### Why these three and no others

The audience priority drives this. A designer scanning the catalog reads the **noun phrase** first — that's how they recognise components. The **class in mono** is the engineer's bridge from "this is the thing I want" to "this is what I paste." Reference cards drop the class because there isn't one — the card maps a system, not a primitive. Family cards keep two or three classes because that mirrors how designers actually talk about siblings: *"The token chip and chain chip use the same shell."*

Anything beyond three classes in one label is a sign the card should split — six classes in a heading reads as inventory, not a card.

### Visual separation between sections, components, and variants

The catalog has three nested layers. Each gets its own separation recipe; getting this right is what stops a long page of stacked cards from feeling like a wall. **Don't author dividers, `<hr>`, or extra spacers between cards** — the CSS handles every layer automatically. If you reach for an inline divider, the primitive isn't doing its job; fix it in `styles.css → .ds-doc-card` instead.

| Layer | Markup | Separation |
| --- | --- | --- |
| **Section** (e.g. *Buttons*, *Cards*, *Forms*) | `<section class="section" id="…">` | 240 px section padding (`--pad-section-y-lg` × 2) plus an optional `.section-divider` between sections. Already strong; designers always see a clear page break. |
| **Component group** (within a section — e.g. *Primary actions* / *Secondary actions* inside `#buttons`) | `<h2 class="ds-doc-subsection">…</h2>` | A quiet uppercase-mono eyebrow caption above the first card of the group, with a hairline rule on top. Use only when a section genuinely splits into named groups. Most sections don't need it. |
| **Variant card** (a `.ds-doc-card` card — one variant per card) | `<div class="comp-section ds-doc-card">…</div>` | 80 px vertical gap between adjacent cards plus a quiet 1 px hairline divider in the centre of the gap. Driven by `.ds-doc-card + .ds-doc-card` so the **first** card in a section gets natural breathing room from the section description, and **only adjacent** cards trigger the divider. |

The card divider is the user-facing fix that matters most. Designers scrolling a section like `#buttons` (which can have 5–10 cards) would otherwise lose track of where one variant ends and the next begins. The 80 px gap + hairline gives an unmissable boundary without adding heavy chrome.

**For future component creation:** when you copy the `.ds-doc-card` template into a new section, you don't need to do anything to get the separation — it's automatic. If you're grouping cards under sub-headings within one section, use `<h2 class="ds-doc-subsection">PRIMARY ACTIONS</h2>` (or similar) between groups; the eyebrow style + hairline are wired in CSS. If you find that two adjacent cards still feel "merged", check that they're direct siblings (the `+` selector is direct-sibling-only) and that no rogue wrapper element is breaking the relationship.

Mobile drops the gap to 48 px and print collapses the divider to a tight 32 px stack — the CSS handles those breakpoints too.

### Section chaptering & auto-TOC

The catalog has six "heavy" sections — `#inputs`, `#hero`, `#badges`, `#authoring-guide`, plus a few that will get there as they grow. (The original `#cards` section was split into a Cards top-level sidebar group of sibling sections — `#stat-cards`, `#feature-cards`, `#specialised-cards`, `#card-behavior`, etc. — Apr 2026; same scaling contract, but the chapters became sections so each card family can grow independently. The `#cards-foundation` and `#tile-foundation` cross-link sections were retired May 2026 in favour of inline group intros — the audience-led naming rule lives in `§06 → Tile vs Card`.) A heavy section is one with more than ~6 cards and natural sub-themes (e.g. text inputs vs. selection vs. composition); a designer scrolling top-to-bottom through 13 input cards loses the thread of where they are. The cure isn't paginating the section — that breaks the "everything in one searchable page" promise — it's **chaptering**: an `<h2 class="ds-doc-subsection" id="…">` between each group, plus a small auto-generated nav at the top of the section that links to each chapter.

The pattern composes two primitives the catalog already has:

1. **Chapter marker** — `<h2 class="ds-doc-subsection" id="<section>-<chapter>">` placed as a direct child of the section's `.container`, between cards. Same uppercase-mono eyebrow + hairline visual the existing component-group subsections use; the new bit is the stable `id` so the chapter is anchorable and the auto-TOC can reference it.
2. **Section TOC** — `.ds-doc-section-toc` (CSS in `styles.css`, JS in `shared.js → initDsSectionTocs()`). The JS walks every `<section class="section">`, counts top-level `h2.ds-doc-subsection[id]` chapter markers, and — when there are 2+ — prepends a horizontal `.ds-doc-section-toc` strip composed from `.ds-doc-tab-nav` and `.ds-doc-tab-nav-item`. An `IntersectionObserver` (rootMargin `-80px 0px -55% 0px`) scroll-spies the chapters and toggles `.is-active` on the matching link as the reader scrolls.

A section with zero or one chapter renders no TOC — the strip only appears where there's somewhere to go. The same primitive scales gracefully: as a section grows from 8 cards to 30, the chapter list grows with it and the TOC is the navigation aid.

**Chapter taxonomy is part of the section's authoring contract.** When you add a new card, you also pick which existing chapter it belongs in — the goal is a shallow chapter list (3–6 chapters per section is the target; 8+ is a sign the section should split). Today's chapter sets:

| Section | Chapters |
| --- | --- |
| `#inputs` | Text & numeric · Sizes & label positions · Modifiers · Selection · Composition |
| (Cards group) | Foundation · Stat · Feature · Specialised · Behavior · Action · Accent · Theme · Palette strip — each its own section, see Cards sidebar group |
| (Tiles group) | Foundation · Tile primitive · Stat tiles — each its own section, see Tiles sidebar group |
| `#hero` | Anatomy · Variants · Composition |
| `#badges` | Badges · Delta indicators · Chips |
| `#authoring-guide` | Skill & Workflow · Audience & Why · Typography · Pane Primitives · Structure & Spacing · Page Chrome |

When you create a new section that will plausibly grow past ~6 cards, **author at least one chapter at the start** — even if it's only "Overview" — so the contract is established. Future contributors will see the existing chapter and naturally extend the list rather than appending unstructured cards.

**Why direct children only.** The JS uses `:scope > h2.ds-doc-subsection[id]` so chapter markers nested inside cards (panes can contain their own `.ds-doc-subsection` for sub-content) don't accidentally register as section chapters. Keep chapter markers at the section's container level; sub-content stays inside the card.

**Future-proofing for multi-page split.** If a section ever genuinely outgrows a single page (60+ cards), the chapter ids become route segments — `#inputs/text` becomes `inputs/text.html`, the TOC becomes the page's primary nav, and each chapter ships as its own document. That's not today's problem, but the chapter-id naming convention (`<section>-<chapter>`) is chosen so the migration is mechanical when it arrives. This is the *within-section* split (one giant section → its own page); the *page-level* decomposition of the whole catalog into audience-coherent tabs is specified below in **Multi-page architecture — the audience-page split**, and the two are complementary axes (chaptering still applies inside each new page).

**Decisions deliberately deferred** (so they don't get re-litigated three months from now):

- **Sidebar chapter links.** The DS sidebar (`#dsSidebar`) intentionally stays at component-level — `#inputs`, not `#inputs-text`. Adding chapter-level entries would compete with the in-section TOC and double the sidebar's depth. The two are complementary: sidebar = "find a section", in-section TOC = "navigate within a section". Don't merge them.
- **Stricter validator threshold.** The validator currently warns (W009) at 7+ cards with 0 chapters, and warns (W008) on chapter-id drift. Both are warnings, not errors — the cost of a missing chapter is low (a designer notices on reload), and erring would block legitimate WIP. Only escalate to an error if drift becomes a real pattern.

### Multi-page architecture — the audience-page split (planned migration)

This subsection is the **canonical migration spec** for decomposing the catalog into multiple pages. It is a *plan*, not a description of shipped state — the framing below is deliberately current-vs-target so future readers (and AI consumers) don't mistake the target IA for what's live today. The mechanisms it commits to are `ds-nav.js → TABS` (the page registry) and a new generated **search index** (the cross-page find layer); when phases land, record them in `.claude/skills/lifi-ds-docs/references/bug-history.md` and update this section in lockstep.

**Current state (June 2026).** The catalog is *already* multi-page at the top level: `ds-nav.js → TABS` drives six sibling full-page documents — Components (`design-system/index.html`), Dashboard, Brand (`brand-guide.html` + `competitors.html`), Lab, AI, Personas — each a static HTML file re-using the same chrome (`styles.css` + `page.css` + `doc-shared.css` + `ds-nav.js` + `shared.js`). There is no SPA layer; navigation is plain full-page loads, deep-linked by `#anchor`. The problem is **not** that the DS needs to become multi-page. It's that one tab — Components / `index.html` — has absorbed thirteen sidebar groups (Foundations, Components, Cards, Tiles, Data Viz, Navigation, Libraries, Resources, Composers, Page Sections, Diagrams, Widget, Market Data) and grown to **~900 cards across 106 sections in a single ~42,000-line file**. Everything except Dashboard/Brand/Lab/AI/Personas lives on that one page.

**The migration in one sentence.** Decompose the overloaded `index.html` into a small set of **audience-coherent sibling tabs**, each re-using the exact `ds-nav.js` + shared-chrome pattern the other five tabs already use. The work is *decomposition, not re-architecture* — the chapter-id naming convention chosen above makes the section→page lift mechanical.

**The non-negotiable constraint — "find & reference anything" must survive.** The single-page catalog's one irreplaceable superpower is: one URL, ⌘F finds anything, ⌘K searches everything, an AI reads one file. A naive split shatters that into five places to look and five files to grep. So **the split is gated on one enabler, not on the split itself: a generated cross-page search index.** A build step emits a JSON manifest — one entry per card: `{ id, label, caption, section, page, anchor, scope }` — and ⌘K queries that manifest globally, routing to the right `page#anchor`. The same manifest is the **AI map**: post-split, Claude reads *smaller, scoped* files (better, not worse) and consults the manifest to know which file holds what. Build and prove this **while the catalog is still one page**; once global find works across "virtual pages," the physical split is safe and reversible. This is the make-or-break — if the search index is weak, the split degrades the experience, so no page-extraction phase ships until the index is solid.

#### Target information architecture — seven audience pages

Organize the top-level pages by **audience + stability**, not by component type. Each row is a planned destination; "absorbs" lists the current `index.html` sidebar groups (and existing tabs) that fold into it.

| Page | Primary audience | Absorbs |
| --- | --- | --- |
| **Overview** | Everyone | Authoring Guide / Documentation (the 43-card `#authoring-guide`) + Design-feedback (`#variant-card`, `#variants-rail`). Promoted from a buried section to the DS landing + contribution guide. |
| **Foundations** | Everyone (bedrock) | Colors, Typography, Spacing, Radius, Elevation, Surfaces, Materials, Sizing & Gap ladders, Theming, Spectrum, Devices/breakpoints, Tokens — **plus the three Composers** (Palette / Material / Semantic), which belong with the foundations they edit. |
| **Components** | Designers + Engineering | The 42-item Components group + Cards + Tiles + Navigation + Data Viz + Diagrams. The universal cross-surface primitives. Stays the largest page; chaptered; Charts/Data-Viz is the obvious *next* spin-off if it keeps growing. |
| **Playground** (Product) | **Product team — critical** | The swap / bridge / limit widget (21 items / 75 cards) + Market Data (`#ui-price-chart`, `#ui-token-info`) **plus the playground shell composition** (rails, stage, view-rail, FAB). Catalog page `playground.html` (renamed from `widget.html`, June 2026, when the Widget page was promoted to the top-level Playground); pairs with the live Playground Launchpad destination (catalog docs ↔ live surface). Highest-priority audience gets dedicated real estate. |
| **Assets** | Everyone | Libraries group: Icons · Avatars · Partner Logos · Portraits · Brand Lockup — **plus a future Illustrations** family. Treated as a *library* (search/filter/copy/download), not a catalog section. |
| **Marketing / Web** | Marketing (lower priority — the website is still a concept) | Page Sections (Hero, Logo Clouds, Page Background, Stat Bar) + marketing Card/Tile variants + **Decks / Proposals / Presentations + the `.doc-*` document templates + `co-marketing-guide.html`** — all currently homeless, reachable only via skill invocation. |
| *Brand · Dashboard · Lab · AI · Personas* | Mixed / internal | **Keep as the existing tabs.** Optionally tuck the four internal pages (Dashboard / Lab / AI / Personas) behind a **"Resources" dropdown** in `ds-nav.js` so the primary nav stays ~7 flat tabs. |

The result: primary nav at ~7 destinations + an optional Resources menu, and each page's sidebar shrinks from 15 groups to 2–4 — the core scanning win. Nav order reflects audience priority (Designers > Engineering > Marketing) and the stated criticality (Widget/Playground critical; Marketing/Web lowest, it's conceptual).

#### Scope tags are the routing manifest

Every `.ds-doc-card` already carries `data-scope="foundations | ui | marketing | docs"` (validator E012). **That tag is what routes a card to its destination page** — a script can sort cards to pages by `data-scope` + sidebar group rather than hand-sorting. **A June 2026 audit confirmed coverage is complete**, so the router can rely on `data-scope` as-is: the Components page has **291 card containers and 302 `data-scope` attributes** (every card tagged, plus a handful of section-level tags), and `validate-doc.py` reports **0 E012 errors**. (An earlier survey's alarming "~927 cards vs ~183 tagged" was a measurement artifact — `927` counted `ds-doc-card` substring matches including `__label` / `__caption` / CSS rules, `183` was an excerpt-sample undercount. The real card count is **291**.) Phase 0's tag-audit sub-task is therefore already green; the cross-page search index shipped alongside it (see Phase 0 below).

#### Stay static multi-page — do not go SPA

Static HTML per page is the right substrate and should be preserved: fast first paint per page, shareable deep-link URLs, trivial AI-readability (one file per scope), zero build complexity, and theme/palette already persist across full-page loads via `localStorage` + the inline anti-FOUC `<head>` script. An SPA would trade all of that for nothing a design system needs. The only shared cross-page concern is theme state, which is already solved. The single *new* build artifact is the search-index JSON; everything else is the chrome that already ships.

#### The Assets page — a library, not a catalog section (and the honest load story)

Splitting Assets out is right, but **the justification is categorical, not load time.** A June 2026 survey of the libraries found:

- **Avatars (196), Partner Logos (29), and Portraits (~20) are already `<img loading="lazy">`** — they cost almost nothing on load; moving them saves little.
- **The real load cost is the 113 Icons, inlined as full SVG** (~940 eager DOM nodes, no lazy path) plus the parse weight of a 42K-line document.

So the load argument applies to *icons and the monolith's size*, not avatars. The durable reason to give Assets its own home is that **icons and avatars are reference material, not components** — you don't *compose* an icon, you *look one up and copy it*. A dedicated Assets page earns a library UX the catalog can't afford: in-page search, category filters, click-to-copy SVG, download, and **render-icons-on-scroll** (keeps the "see the exact inline path" behavior without paying 113 SVGs on every load — the one genuine load win, banked here). Reframe: *Assets is a library; that reframing justifies the split, and the icon load fix is the bonus.*

#### Phasing — each phase independently shippable, lowest-risk first

0. **Enablers (ship before any extraction).** Complete + verify `data-scope` coverage (the router). Build the cross-page search-index JSON + repoint ⌘K at it. Prove global find works *while still one page*. Gate: no page extraction until this is solid. **Shipped June 2026:** `scripts/build-search-index.js` scans every page in its `PAGES` list and emits a deterministic `search-index.json` (183 entries at parity with the live DOM walk; the pre-commit hook regenerates + stages it on change). `shared.js → initDsCmdPalette` fetches the manifest, builds the palette index from it (DOM-walk fallback if it fails to load), and routes select to `<page>#<id>` when the entry's page differs from the current one. As each section moves to its own page, add the filename to `PAGES` and re-run; every split page must also carry the `.cmd-palette` + `.search-trigger` chrome (today only `index.html` does).
1. **Assets → `assets.html`.** Safest and most self-contained (reference material, few inbound cross-refs), clearest audience win, banks the icon load fix, and exercises the whole mechanism on the lowest-risk section (new tab, focused sidebar, redirect stubs for `#icons` / `#avatars` / `#partner-logos` / `#portraits`). **Shipped June 2026:** the five contiguous Libraries sections (`brand-lockup` · `iconography` · `avatars` · `partner-logos` · `portraits`) moved verbatim to `design-system/assets.html` (own header, focused 1-group sidebar, its own `.cmd-palette` + `.search-trigger` chrome). `ds-nav.js` gained the **Assets** tab (with a 5-item dropdown); the Components dropdown's stale `#iconography` "Resources" link was repointed to `#fab-menu`. `index.html` carries a before-paint moved-anchor forwarder (load + `hashchange`) so old `#avatars`-style links forward to `assets.html`. The manifest's `PAGES` gained `assets.html`; cross-page ⌘K routing was verified end-to-end (search "brand lockup" on index.html → lands on `assets.html#brand-lockup-card`) and the redirect (`index.html#avatars` → `assets.html#avatars`). **Still a future enhancement:** the 113 icons render inline on `assets.html` (eager) — `index.html` shed that weight, but the on-scroll / sprite icon-render + in-asset search/filter aren't built yet.
2. **Widget → `widget.html`.** Product team's home; the `ui-*` namespace makes the boundary clean. Pairs with Playground. **Shipped June 2026:** the contiguous `#widget` + `#ui-price-chart` + `#ui-token-info` sections (25 cards) moved to `design-system/widget.html` ("Swap Widget" header, focused Widget + Market Data sidebar, its own palette chrome); `ds-nav.js` gained the **Widget** tab between Components and Assets. The widget catalog cards are static previews (no `swap.js` on the catalog — the tail loads only chart/orders scripts), so the tail-copy renders them identically; verified the amount-card + price-chart demos paint with no console errors. **Redirect mechanism generalized this phase:** the per-page inline forwarder was replaced by a single-source, **page-aware** `catalog-redirects.js` loaded in every catalog page's `<head>` (before paint). It holds the full id→page map and *skips anchors whose owning page is the current page*, so one map is safe on every page — no self-redirect loop (verified: `widget.html#ui-amount-card` stays put; `widget.html#avatars` forwards to `assets.html`; `index.html` search "amount card" routes to `widget.html#ui-amount-card`). New split pages inherit the script via the shared head chrome; each phase just extends the map. (Hand-maintained today; a future enhancement could generate the map from the manifest + section structure — note the manifest alone is insufficient because grid-only sections like `#avatars` carry no card.) **Renamed + promoted June 2026:** `widget.html` → `playground.html` when the Widget page became the top-level **Playground** (documenting the shell composition — rails, stage, view-rail, FAB — alongside the widgets it hosts); hash-preserving redirect stub left at `widget.html`, per CLAUDE.md → URL filename ≡ nav label. **Composition section added the same pass:** a new `#playground-composition` group leads the page — a `#playground-anatomy` overview card (the labeled visual map of the whole composition) plus the two playground-specific rail sections (`#playground-rail`, `#variants-rail`) **relocated from `index.html`** (they're playground-specific; the shared app-shell chrome — `.side-nav` / `.drawer-handle` / `.cmd-palette` / `.fab` — stays on Components and is referenced from the map, never duplicated, since Portal shares it).
3. **Foundations → `foundations.html`** (+ Composers). Everyone's bedrock; stable; low cross-ref risk. **Shipped June 2026:** two non-contiguous blocks moved in one pass — the 7 Foundations sections (`brand-colors` · `device-resolutions` · `elevation` · `materials` · `spacing` · `surfaces` · `typography`) + the 3 Composers sections (`material-` / `palette-` / `semantic-composer`), with their two sidebar groups, into `foundations.html` ("Design Foundations" header). The interactive composers are wired by `shared.js` (in the shared tail), so they work unchanged — verified the palette/material composers + 277 colour swatches render, no console errors. `ds-nav.js` gained **Foundations as the FIRST tab** (per the audience-led IA: everyone needs the bedrock); the Components dropdown's stale `#brand-colors` link was repointed to `foundations.html`. The redirect map (`catalog-redirects.js`) gained all 29 foundations anchors (10 sections + 19 cards — `sizing-ladders` / `nested-radius` / `card-gap` / `material-*` / `spectrum` / `theming` etc. are cross-referenced from CLAUDE.md, so they must forward); verified `index.html#sizing-ladders` → `foundations.html#sizing-ladders`. Manifest: 183 entries, now index 138 / assets 1 / widget 25 / foundations 19.
4. **Remainder = Components.** Rename the `index.html` tab to its true scope; spin off Charts/Data-Viz if it keeps growing (within-section chaptering still applies — see the subsection above). **Shipped June 2026:** no rename needed — `index.html` stays the **Components** tab, which is now honest (its remainder is Components · Cards · Tiles · Data Visualization · Navigation · Resources · Design feedback · Diagrams). Data Viz was **kept** on the Components page (spin off only if it grows). `detail-list` + `status-row` (generic primitives that had been authored inside the `#widget` section) were **re-homed** to Components — `detail-list` into `#lists`, `status-row` into `#status-dot` — via a line-range script (extract-range == remove-range to avoid off-by-one; an earlier attempt mis-sliced and ate two neighbour-card openers, caught by the manifest count dropping below 183 and reverted via git). Removed from the redirect map (they're on the home page now). Manifest: index 140 / assets 1 / widget 23 / foundations 19 / marketing 0 = 183.
5. **Marketing / Web.** Stand up the surface for the homeless collateral (proposals, decks, presentations, `.doc-*` templates, co-marketing guide) + the website page-section primitives. **Shipped June 2026 (page-sections scope):** the 4 website Page Sections (`hero` · `logo-clouds` · `page-background` · `stat-bar`) moved to `marketing.html` ("Marketing & Web" header, Page Sections sidebar). These are demo compositions with no `.ds-doc-card` cards (like the asset libraries), so the manifest gains 0 entries — they're reachable via the **Marketing** nav tab + sidebar + the 4 section anchors in the redirect map. `ds-nav.js` gained the Marketing tab after Assets; the Components dropdown's stale `#hero` link was repointed to `marketing.html`. Verified marketing.html renders (tab active), nav intact, redirect `index.html#hero` → `marketing.html#hero`. **Deferred (net-new design, not relocation):** the collateral surface — decks / proposals / presentations / `.doc-*` templates / co-marketing-guide — is its own authoring effort, not part of the mechanical split. **Nav order (final):** Foundations · Components · Widget · Assets · Marketing · Brand · Dashboard · Lab · AI · Personas. Dashboard was repositioned after Brand (Phase 4) via a whitespace-safe line-reorder script — a direct in-place edit had been defeated by the ds-nav block's aligned-column whitespace.

Throughout: **redirect stubs** for moved anchors (the `art.html → lab.html` precedent — `<meta http-equiv="refresh">` + JS `location.replace`), **stable anchors** (don't rename ids during a move), `ds-nav.js → TABS` edits per page, validator updates for the new split thresholds, and a grep sweep of `CLAUDE.md` / `design.md` / the memory dir for `#section` anchors that moved (the atomic-rename discipline in CLAUDE.md already covers this).

**Renaming an anchor (not just moving it) — the `ALIASES` map (June 2026).** The generated `MOVED` map (`build-redirect-map.js`) forwards a stale anchor to its owning PAGE — it can't help when an id is *renamed* (the old id no longer exists on any page, so the regen simply drops it). For a deliberate rename (`#palette-composer` → `#theme-composer`), add the old→new pair to the hand-maintained **`ALIASES`** block in `catalog-redirects.js` (it sits OUTSIDE the `@generated-map` markers so the regen never clobbers it). The forwarder rewrites the stale hash to the new id, then routes through `MOVED` to the owning page — so an old `anyPage#old-id` bookmark lands on `new-page#new-id`. **Watch for an anchor collision when renaming to a generic id:** if the new id already exists on another page, `build-redirect-map.js` reports "N multi-page id(s) excluded as ambiguous" and the bare id won't forward — disambiguate one of the two (e.g. the `#theme-composer` tool vs the `.theme-composer` panel primitive → the panel moved to `#theme-composer-panel`). Reference: `catalog-redirects.js → ALIASES` + the Theme Composer rename.

#### Decisions deliberately deferred (so they don't get re-litigated)

- **Sidebar depth post-split.** Each new page keeps its sidebar at section-level (per the chaptering subsection's rule); in-section TOCs carry within-page depth. Don't add chapter-level sidebar entries.
- **Top-nav grouping vs flat.** ~7 flat tabs is the target; only collapse the four internal pages behind a Resources dropdown if the flat bar feels crowded. Don't pre-build a nested mega-menu.
- **Charts/Data-Viz as its own page.** Left inside Components until it demonstrably outgrows co-location. The value of comparing primitives side-by-side on one page is real; resist over-splitting.
- **Figma-sync interaction.** None required — the token→Figma binding (`design/figma-sync.md`) is deterministic per component and page-agnostic; a component binds to the same token manifest wherever its HTML lives. The split needs no sync change.
- **Search-index build mechanism.** Whether the manifest is generated by a Node script at commit time (like `collect-dashboard-data.js`) or assembled client-side on first load is an implementation choice for Phase 0 — the *contract* (one entry per card, `page#anchor` routing) is what's fixed here.

### Inside-pane vocabulary — seventeen primitives, six families

Every catalog card composes a `.ds-doc-viewport.ds-doc-viewport--tabbed` frame, and the engineer-facing tabs (Markup / Specs / Anatomy / Source) hold tables, code snippets, eyebrows, captions, asides, grids, dividers, headlines, wells, and pinned details. Without a canonical vocabulary, every author invents their own inline-styled label, well, and right-aligned mono detail — and the catalog drifts into 80+ slightly-different uppercase eyebrows, 60+ slightly-different demo wells, three margin conventions, and four ways to spell "side-by-side compare."

The fix is the **inside-pane vocabulary**: seventeen approved primitives, organised in six families. Use the canonical primitive class for every doc element. Containers handle standalone-vs-pane chrome automatically; everything else reads identically anywhere.

#### The seventeen primitives

| Family | Primitive | Recipe | When to use |
| --- | --- | --- | --- |
| **Containers** | `.ds-doc-codeblock` | `<pre>` + 8 syntax token spans | Every code snippet. Drops border/bg/radius inside a pane. |
| | `.ds-doc-table-wrap` + `.data-table` | `<div>` wrap + `<table>` | Every table. Wrap provides horizontal scroll on narrow viewports. |
| | `.ds-doc-aside` | 3px accent left-border · italic · text-secondary | Pull-note inside a pane — emphasis paragraph. Variants: `--muted`, `--warn`. |
| **Prose** | `.ds-doc-text` | body · text-secondary · 640px max-width (drops in-pane) | Every documentation paragraph. Auto-spaces siblings. Modifier: `--sm` (14px). |
| | `.ds-doc-list` | body · text-secondary · 8px item spacing | Every `<ul>` / `<ol>` inside docs. |
| **Labels** | `.ds-doc-eyebrow` | 11px · mono · 700 · text-faint · uppercase · tracking-wider · mb-6 | Mono uppercase kicker — section sub-headings inside a pane (above a group of demos) AND specimen labels above a single demo (one swatch / variant / state). Variants: `--accent` (active), `--ghost` (de-emphasis). May 2026: unified from the retired `.ds-doc-sub` (sans 12px) into one mono primitive — same role, fewer choices. |
| | `.ds-doc-caption` · `--lead` · `--label` · `--centered` | caption · sans · text-muted · left · mt-8 | Quieter sibling of `.ds-doc-text` — annotation around a demo. Modifiers: `--lead` (12 px top), `--label` (labels demo below, mt:0 mb:8), `--centered` (figcaption, margin:0 + center). May 2026 promotion collapsed ~82 inline `style="margin-..."` overrides into these three. |
| | `.ds-doc-swatch-label` | caption · mono · text-muted | Mono label NEXT TO a swatch / sample. Variant: `--on-alpha`. |
| **Inline** | `.code-chip` (or bare `<code>`) | 0.85em · mono · 600 · primary · 7% currentColor bg · no border · 5px radius | Auto-applied to every `<code>` on the site via the bare element selector; explicit class for non-`<code>` hosts. Suppressed inside `.ds-doc-codeblock`. Tier-2 universal (promoted from `.ds-doc-class` May 2026 v10). |
| **Layout** | `.ds-doc-grid` | display:grid · 16px gap · align-items:start | Side-by-side compositions. Modifiers: `--cols-2/3/4`, `--tight` (8px gap), `--loose` (24px gap). Auto-stacks at ≤720px. |
| | `.ds-doc-eyebrow-row` | sibling-spacing 32px · eyebrow→demo gap 12px | Pair an eyebrow caption with a demo. Stack 3-7 inside a Preview pane to show variants without fragmenting into nested sub-viewports. Replaces hand-rolled `<p class="ds-doc-eyebrow" style="margin: ...">` + arbitrary wrapper rows. |
| | `.ds-doc-divider` | 1px hairline · 6% currentColor · my-20 | `<hr>` between sub-sections of a long pane. Modifiers: `--tight` (12px), `--loose` (32px). |
| **Demo** | `.ds-doc-headline` | body-lg · 600 · text-primary · margin:0 | Bigger label BELOW an eyebrow inside a pane. Variant: `--sm` (body size for tighter rows). Pairs with `.ds-doc-eyebrow`. |
| | `.ds-doc-well` | padding:16 · radius:lg · surface-sunk + 6%-ink hairline | Headerless framed sample box for a small visual demo. Surface variants: `--sunk` default, `--raised`, `--elevated`, `--alpha`. Spacing: `--tight` / `--loose`. |
| | `.ds-doc-detail` | margin-left:auto · mono · caption · text-muted · nowrap | Right-pinned mono annotation in a row — paths, versions, dimensions. Place inside a flex row; the auto-margin handles the pin. Variant: `--accent`. |

**The cardinal rule:** if a pane needs a label, divider, grid, aside, headline, well, or pinned detail, reach for one of the sixteen. Never inline-style your own. The primitives evolve once across the system; inline styles drift forever.

#### Picking the right label

The three labels exist because they answer different questions. Don't substitute one for another:

- `.ds-doc-eyebrow` is the **uppercase mono kicker** for everything that reads as a marker — section sub-headings above a group of demos, specimen labels above one swatch, state markers ("Default," "Active," "Sunk"), classifications ("Priority 1," "First/Second/Third"). 11px mono uppercase. Use `--accent` for active state, `--ghost` for de-emphasis. Replaced the older `.ds-doc-sub` sans variant in May 2026 — one primitive, one mental model. **Eyebrows are short labels, never sentences.** Soft cap: 90 chars of rendered text (validator W013). When you have explanatory prose to add to a demo, author it as a sibling `<p class="ds-doc-text">` BELOW the eyebrow — don't smuggle it inside the eyebrow itself. Canonical shapes: bare noun (`Hero eyebrow`), class only (`.btn-primary`), noun + class (`Compact alert · .alert--sm`), class + short purpose (`.swatch--lg · brand identity` or `.eyebrow-code — developer / API sections`), enumeration (`Sizes — sm 32 · default 40 · lg 48 · xl 56` — text after the em-dash contains 2+ `·` separators; reads as a parallel-scan list, can run longer than prose-form). Full bad/good catalog + drift smells in `.claude/skills/lifi-ds-docs/references/writing-style.md → "The pane eyebrow rule"`.
- `.ds-doc-caption` is for **sans-serif annotation around a demo** (12 px, muted, left, 8 px top margin). The quieter sibling of `.ds-doc-text`. Three placement modifiers cover the rhythms used across the catalog: `--lead` (12 px top — when the default 8 px feels cramped under a demo), `--label` (margin-top:0, margin-bottom:8 — caption acts as a LABEL ABOVE the demo it precedes), `--centered` (margin:0 + text-align:center — figcaption under a centered demo cell). The May 2026 promotion swept ~82 inline `style="margin-top:var(--space-12)"`, `style="margin-bottom:6-8px;text-align:left"`, and `style="margin:0;text-align:center"` overrides into these three. Tail cases (margin-top 16+, mixed shorthand, grid-column overrides) stay inline by intent — the modifier ladder is the 80% pattern, not 100%.
- `.ds-doc-swatch-label` is for a **left-aligned mono label** next to a swatch or sample (mono, muted, no transform). Use on swatches, dimensions, samples — anywhere you need a quiet identifier without uppercase transformation.

All three are caption-sized · text-muted/faint, but the *type face* + *transform* + *placement* split them. `.ds-doc-eyebrow` is mono · uppercase · weight 700 · ABOVE its target. `.ds-doc-caption` is sans · no transform · paired with placement modifiers (default: 8 px-top below a demo; `--lead`: 12 px-top; `--label`: ABOVE the demo with a small bottom gap; `--centered`: figcaption under a centered cell). `.ds-doc-swatch-label` is mono · no transform · NEXT TO its target. Pick by where it goes and what type face it should carry.

#### Layout primitives over inline grids

Before authoring `style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-16)"`, check if `.ds-doc-grid--cols-2` fits. The grid primitive bakes in the sensible defaults (16px gap, align-items:start, mobile auto-stack at 720px) and the modifiers cover the common variants — 2/3/4 columns plus tight/loose gap presets. The same applies to dividers: instead of `<div style="height:1px;background:..."></div>`, use `<hr class="ds-doc-divider">`.

#### Picking the right Demo primitive

The four demo-family primitives each answer a different annotation question. Don't substitute one for another:

- **`.ds-doc-headline`** is the bigger label that pairs *below* a `.ds-doc-eyebrow`. Body-lg / weight 600 / primary colour. Use when the eyebrow needs a noun phrase to anchor it (e.g., eyebrow "Priority 1" → headline "Designers"). The `--sm` modifier drops to body size when the row needs a tighter footprint, like the label-shape demos at `#ds-doc-label-shape` where "Brand button .btn-primary" needs to fit on one line beside its inline class chip.
- **`.ds-doc-well`** is the headerless framed sample box. It's lighter than a `.ds-doc-viewport.ds-doc-viewport--stack` (which has a header bar) and meant for small visual samples — surface swatches, elevation demos, alpha state layers, mini cards. The four surface modifiers (`--sunk` default, `--raised`, `--elevated`, `--alpha`) line up with the four canonical surface tokens; pick by what the demo is illustrating. Don't reach for a well when you actually want a labelled viewport — use `.ds-doc-viewport--stack` for those.
- **`.ds-doc-detail`** is the right-pinned mono annotation that lives inside a flex row. Use for file paths (`styles.css:14092`), version numbers (`v4.2`), spec counts (`12 specs`), dimensions (`120 × 36`). The `margin-left:auto` pushes it to the row's right edge without needing flex utilities; caption-size mono in muted colour reads as "supporting detail" without competing with the row's primary text.
- **`.ds-doc-text--sm`** is a modifier on the existing `.ds-doc-text` that drops the size from body (16px) to sm (14px) while keeping the same colour and line-height. Use under a headline or caption for context that's quieter than body prose — typical at "Why" / "When to use" notes inside panes.

The headline primitive specifically replaced the recurring inline pattern `<p style="margin:0;font-weight:600;color:var(--text-primary);">…</p>` (85 occurrences across the catalog before migration). The well primitive replaced the recurring `<div style="background:var(--surface-sunk);border:1px solid …;border-radius:12px;padding:var(--space-16);">…</div>` pattern (~35 occurrences). Both shapes were promoted following the "5+ uses" threshold rule.

#### Code-block syntax tokens

Inside `.ds-doc-codeblock`, wrap syntax tokens in spans for consistent colour-coding across every DS page:

| Span class | Use for | Example |
| --- | --- | --- |
| `<span class="comment">` | Comments — italic, muted | `<!-- TODO --> // comment # comment` |
| `<span class="string">` | String literals — accent-secondary | `"value"`, `'value'` |
| `<span class="tag">` | HTML tag names — accent-primary | `<div>`, `<button>` |
| `<span class="attr">` | HTML attributes — accent-tertiary | `class="…"`, `data-tab="…"` |
| `<span class="value">` | Attribute values when not strings | numeric props, booleans |
| `<span class="keyword">` | JS / CSS / shell keywords — bold | `const`, `function`, `display`, `npm` |
| `<span class="path">` | File paths, URLs — accent-tertiary | `styles.css`, `src/foo.js` |
| `<span class="punct">` | Punctuation, brackets — muted | `{}`, `()`, `;` |

Eight tokens cover HTML, CSS, JS, JSON, and shell — the formats the catalog actually shows. **Don't invent new ones for one-off colours.** If you find a snippet that doesn't fit, reach for a comment span or accept it monochrome.

#### Don't author your own container

No `<div style="background:…;border:1px solid …;padding:16px;">` around a code snippet, no `<div style="border-radius:12px">` around a table, no `<p style="font-size:11px;text-transform:uppercase;...">` for an eyebrow. The primitives own the chrome; you supply the content. The validator's `E008` rule catches the most common drift (inline `style="background:#…"`); a future `W010` rule (proposed) will flag inline-styled patterns that match a primitive heuristic.

#### Extending the system

When a recurring pattern emerges in practice that none of the twelve cover, **promote it to a primitive** before it spreads:

1. Confirm the pattern appears 5+ times across the catalog (not just on the card you're editing).
2. Add the CSS next to the existing `.ds-doc-*` block in `styles.css` — keep the cascade order with the rest of the family.
3. Add a live preview to the `#ds-doc-primitives` card in `design-system/index.html` (preview pane) plus rows in the markup, anatomy, and rules tabs.
4. Update this section of `design.md` and the SKILL reference.
5. Run `python3 .claude/skills/lifi-ds-docs/scripts/validate-doc.py` — exit code 0 = no regressions.

**Don't fork by hand-tuning tokens.** If a primitive feels wrong in a context, it's usually the wrong primitive — pick another, or extend the existing one with a modifier (`--tight`, `--accent`, etc.). Adding a 13th primitive should feel rare; bumping a primitive's modifier set should feel routine.

#### Color-code tokens for code blocks

Inside `.ds-doc-codeblock`, wrap syntax tokens in spans for consistent colour-coding across every DS page:

| Span class | Use for | Example |
| --- | --- | --- |
| `<span class="comment">` | Comments — italic, muted | `<!-- TODO --> // comment # comment` |
| `<span class="string">` | String literals — accent-secondary | `"value"`, `'value'` |
| `<span class="tag">` | HTML tag names — accent-primary | `<div>`, `<button>` |
| `<span class="attr">` | HTML attributes — accent-tertiary | `class="…"`, `data-tab="…"` |
| `<span class="value">` | Attribute values when not strings | numeric props, booleans |
| `<span class="keyword">` | JS / CSS / shell keywords — bold | `const`, `function`, `display`, `npm` |
| `<span class="path">` | File paths, URLs — accent-tertiary | `styles.css`, `src/foo.js` |
| `<span class="punct">` | Punctuation, brackets — muted | `{}`, `()`, `;` |

Eight tokens cover HTML, CSS, JS, JSON, and shell — the formats the catalog actually shows. **Don't invent new ones for one-off colours.** If you find a snippet that doesn't fit, reach for a comment span or accept it monochrome.

#### Overflow behaviour

Code blocks default to `white-space: pre` plus `overflow-x: auto`, so long lines force a quiet horizontal scrollbar (6 px, theme-tinted). Mobile overrides to `pre-wrap` + `word-break: break-word` so phones don't get a permanent scrollbar.

Tables get `overflow-x: auto` on `.ds-doc-table-wrap` and a min-width on mobile (`.data-table { min-width: 520px }`) so columns don't squish into unreadable rows.

#### Code chip — `.code-chip`

Every inline code identifier (class names, modifiers, custom-property names, file paths, etc.) rendered anywhere on the site gets a distinctive visual treatment — a **filled mono pill**. Mono type at 0.85em, weight 600, primary text colour, background tint at `currentColor` 7 %, no border, 5 px radius, hover lifts to an accent-tinted fill. Filled enough to read as a paired tag while scanning; quiet enough to live inside body prose without competing with the surrounding type.

**Universal trigger — bare `<code>` element selector.** Every `<code>` element on every page of the site auto-renders as a chip. No container class required, no opt-in. The chip works on marketing prose, dashboard captions, catalog docs, product surfaces — wherever you put `<code>`. The explicit `.code-chip` class is reserved for the rare case where a non-`<code>` host (a `<span>`, a `<b>`) needs the same styling.

**May 2026 v10 — promoted from tier-1 `.ds-doc-class` to tier-2 `.code-chip`.** The previous name was a catalog-meta primitive (`.ds-doc-*` namespace) and the trigger was a 10-selector container list (`.ds-doc-text code`, `.ds-doc-list code`, etc.). Promoted to tier-2 universal and the trigger expanded to the bare element selector so every `<code>` on the site renders as a chip without container scaffolding. Same hard-migration pattern as the v9 avatar promotion. The old name is fully retired — no aliases.

**Apr 2026 v2 — dropped the border, bumped the bg.** The previous iteration (under the old name) was a quiet outline chip (1 px border at currentColor 14 %, bg at 4 %). The outline carried most of the visual weight — designers' eyes landed on the boxed `.btn-primary` before the noun phrase the heading was actually about. Removing the outline + bumping the bg from 4 → 7 % inverts that: the chip is filled enough to spot, but pairs with surrounding type instead of competing.

**One base recipe, two context overrides:**

| Context | Selector | What changes from default | Why |
| --- | --- | --- | --- |
| **Default** (everywhere `<code>` appears) | `code` element selector or `.code-chip` class | — | The base. 0.85em / weight 600 / primary colour / filled 7 % bg / no border. |
| **Label-context override** | `.ds-doc-card__label code` | font-size 0.72em, weight 500, colour `text-primary 70 %`, vertical-align +0.08em | Inside a 26 px heading, 0.85em / weight 600 is too prominent — the chip competes with the noun phrase. The override shrinks + softens the type while keeping the inherited filled-bg-no-border chrome. |
| **Eyebrow-context override** | `.ds-doc-eyebrow code` | font-size 1em (bumped from 0.85em) | Inside a 12 px uppercase mono eyebrow, 0.85em would shrink the chip to ~10 px and lose legibility. The shared rule's `text-transform: none` + `letter-spacing: 0` ALSO matter here so the chip's class name stays lowercase + tight even though the parent is shouting. |

**Hover state (uniform across contexts):** background lifts to `accent-primary 12 %`, colour to `var(--accent-primary-mid)`. Hover bg bumped from 8 → 12 % when the border was dropped — the chip needed a stronger fill to keep hover feedback visible.

Inside `.ds-doc-codeblock` the chip is **suppressed** — code blocks have their own syntax-highlight token spans (`.comment` / `.string` / `.tag` / etc.) and a chip would compete visually. The suppression rule sets `display: inline`, transparent bg, no border, inherited font.

**Don't add a border back without a designer-side reason.** If a specific context needs a stronger chip, add a context override (e.g. `.my-parent code { font-size: 1em; }`) or a modifier (e.g. `.code-chip--strong`) in `styles.css` rather than changing the base. Document the modifier on the catalog card.

Documented as a card at `#code-chip` in the catalog (Components group, alphabetical between Chips and Dropdown). Full anatomy + recipe lives in the comment block above the rule in `styles.css` (search `.code-chip — universal inline-code chip`). The "Inline `<code>` renders as a chip site-wide" hard rule lives in `CLAUDE.md`; the v10 promotion is recorded there in the class-naming convention migration history.

### Class-naming convention — three tiers, picked by audience + reuse scope

The catalog, product surfaces, and marketing surfaces share a CSS namespace. To keep "where does this primitive live?" answerable at a glance, naming uses three tiers:

| Tier | Prefix | Use for | Examples |
| --- | --- | --- | --- |
| **Catalog-meta primitive** (used to BUILD the catalog itself, never on a product surface) | **`.ds-` prefix** | The card primitive, doc-prose helpers, the catalog's own scope filter / hero meta | `.ds-doc-card`, `.ds-doc-text`, `.ds-doc-list`, `.ds-doc-codeblock`, `.ds-doc-controlbar`, `.ds-doc-avatar-grid`, `.ds-doc-partner-logo` |
| **Universal cross-surface primitive** (used on product, marketing, dashboards, catalog demos alike) | **No prefix** | Form controls, buttons, badges, cards, layout helpers, charts, modals, navs that work everywhere | `.btn-primary`, `.badge`, `.feature-card`, `.input`, `.select`, `.textarea`, `.checkbox`, `.switch`, `.form-card`, `.form-group`, `.field-inline`, `.tab-nav`, `.setting-row`, `.btn-wallet`, `.split-pane`, `.timeline`, `.detail-list`, `.modal`, `.screen-header`, `.action-bar`, `.option-card`, `.drawer-handle` |
| **Product-surface primitive** (lives on product pages — swap, future products — but not on marketing or DS-meta surfaces) | **`.ui-` prefix** | Domain-flavoured primitives that carry product semantics (`token`, `route`, `chain`, `quote`) | `.ui-card`, `.ui-amount-card`, `.ui-amount-pair`, `.ui-token-row`, `.ui-quote-card`, `.ui-quote-list`, `.ui-swap-arrow`, `.ui-route-summary` (June 2026 retires: `.ui-result-state` / `.ui-activity-card` / `.ui-recipient-chip` — zero-consumer first-pass execution-flow stubs, superseded by the mock-aligned `.ui-tx-status` + `.ui-tx-summary--interactive` + `.ui-destination-wallet-card`. May 2026 v17 retires: `.card-select` / `.card-select-grid` — promoted in v14 from `.playground-rail__card-select*` but never adopted outside the catalog; superseded by `.option-card` (preview + title + description) which the rail and the rest of the system already use. May 2026 v15 supersedes: `.ui-rail-handle` → `.drawer-handle` (universal — future FAB drawer / side sheet consumes it). May 2026 v13 supersedes: `.ui-screen-header` → `.screen-header` (universal — modal shell consumes it). Earlier May 2026 supersedes: `.ui-amount-field`, `.ui-token-select`, `.ui-token-pill`, `.ui-chain-row` — early product-only primitives now built from universal recipes: <code>.chip-avatar</code> for the token chooser; <code>.list-item--sm</code> + <code>.avatar</code> for chain picker rows) |

**Why three tiers, not two:** product-surface primitives often carry product semantics that don't belong on marketing or dashboard pages. Putting them in tier 2 (no prefix) inflates the universal namespace with single-context names; putting them in tier 1 (`.ds-`) violates the catalog-meta rule (they ARE used on product surfaces). Tier 3 cleanly separates "product-surface primitive" from both. The split makes it grep-obvious whether a given selector is catalog chrome, universal primitive, or product-only primitive.

**Rule of thumb when adding a new component, in this order:**

1. **"Would I ever use this outside the catalog?"** No → tier 1 (`.ds-` prefix). Almost never the case for normal feature work.
2. **"Would this ever appear on a marketing page or be useful on a non-product surface?"** Yes → tier 2 (no prefix).
3. **Otherwise → tier 3 (`.ui-` prefix).** Default for new product-page features (`.ui-card`, `.ui-token-row`).

**Why `.ui-` and not per-domain prefixes (`.swap-`, `.lend-`):** single namespace simplifies the convention. Domain context comes from composition (which page uses the primitive) and from the catalog section it's documented in (Swap, future Lend), not from the class name. Trade-off: if two product surfaces ever need the same conceptual role with different anatomy, name by anatomy not by role (`.ui-card` is the generic widget shell; `.ui-position-card` is the specialized lending one). Future-problem; don't pre-engineer.

**Promotion rule (tier 3 → tier 2):** if a `.ui-*` primitive gets used or copy-edited for marketing/dashboard contexts, it gets renamed (drop the prefix) as a deliberate refactor. Promotion is opt-in, not automatic. Most product-surface primitives stay tier 3 — `.ui-route-card` won't surface on the marketing homepage, ever.

**Namespace overlap with the `data-scope="ui"` filter:** the class prefix `.ui-` and the catalog scope tag `data-scope="ui"` are independent axes. The prefix is a CSS-naming convention; the scope is a filter taxonomy. A component can be `.ui-token-row` (CSS prefix) AND `data-scope="ui"` (catalog filter) without conflict. Don't conflate them.

**Migration history:**

- **Apr 2026 — form family rename.** `.ds-input`, `.ds-select`, `.ds-textarea`, `.ds-checkbox`, `.ds-switch`, `.ds-form-card`, `.ds-form-group`, `.ds-form-row`, `.ds-field-*` dropped the `.ds-` prefix — those primitives are universal form controls, not catalog-specific. Hard migration, no aliases.
- **Apr 2026 — three-tier convention added.** Tier 3 (`.ui-` prefix) introduced as the swap-widget rebuild started. First `.ui-*` primitives shipped on the new `playground.html` surface.
- **Apr 2026 v6 — universal form helpers.** `.ds-label` → `.form-label`, `.ds-hint` → `.form-hint`. New `.form-error` slot landed in the same shape (sibling of `.form-hint`, danger-coloured caption). Form helpers are universal — they live on real product/marketing pages, not just the catalog — so they belong in tier 2.
- **Apr 2026 v7 — remaining universal families dropped `.ds-` prefix.** `.ds-modal` → `.modal`, `.ds-action-card` → `.action-card`, `.ds-fab` → `.fab`, `.ds-accordion` → `.accordion`, `.ds-callout` → `.callout`, `.ds-divider` → `.divider`, `.ds-search` → `.search`. (`.ds-charts` was already gone.) Around 640 references rewrote across `styles.css`, `shared.js`, `swap.js`, the catalog HTML pages, marketing pages, and the skill references. Hard migration, no aliases.
- **Apr 2026 v8 — full prefix unification.** Two parallel moves in one pass to make `.ds-*` self-enforcing:
  - **Catalog-meta classes unified under `.ds-doc-*`.** Every class that builds the catalog now starts with `.ds-doc-*` — no exceptions. The card primitive `.ds-comp-doc` (which already had `-doc-` in the middle, awkwardly) was renamed to **`.ds-doc-card`** to avoid a "doc-doc" stutter; its BEM children moved with it (`.ds-doc-card__head`, `.ds-doc-card__label`, `.ds-doc-card__caption`, `.ds-doc-card__pane`). Other migrations: `.ds-viewport*` → `.ds-doc-viewport*`, `.ds-controlbar*` → `.ds-doc-controlbar*`, `.ds-sidebar*` → `.ds-doc-sidebar*`, `.ds-section-toc` → `.ds-doc-section-toc`, `.ds-template` → `.ds-doc-template`, `.ds-card-demo` → `.ds-doc-card-demo`, `.ds-tile-demo` → `.ds-doc-tile-demo`, `.ds-layout` → `.ds-doc-layout`, `.ds-content` → `.ds-doc-content`, `.ds-hero-meta` → `.ds-doc-hero-meta`, `.ds-section-label` → `.ds-doc-section-label`, `.ds-scope-notice*` → `.ds-doc-scope-notice*`, `.ds-topnav` → `.ds-doc-topnav`, `.ds-tab-nav*` → `.ds-doc-tab-nav*`, `.ds-link*` → `.ds-doc-link*`, `.ds-menu*` → `.menu*`, `.ds-toggle` → `.ds-doc-toggle`, `.ds-control-*` → `.ds-doc-control-*`, `.ds-color-input/picker` → `.ds-doc-color-input/picker`, `.ds-preset*` → `.ds-doc-preset*`, `.ds-panel-btn-row` → `.ds-doc-panel-btn-row`, `.ds-swatch-label*` → `.ds-doc-swatch-label*`, `.ds-avatar*` → `.ds-doc-avatar*`, `.ds-partner-logo*` → `.ds-doc-partner-logo*`, `.ds-logo` → `.ds-doc-logo`, `.ds-icon-cell/grid` → `.ds-doc-icon-cell/grid`, `.ds-seg*` → `.ds-doc-seg*`, `.ds-code-block` → `.ds-doc-code-block`, `.ds-do-dont` → `.ds-doc-do-dont`.
  - **Chart / data-viz families dropped `.ds-` (became tier-2 universals).** They're meant for production dashboards and reports — only catalog-only currently because no external dashboards had shipped yet. Building external dashboards is the immediate next focus, so they get tier-2 treatment now (same logic that drove buttons / modals / callouts in v7). Migrated families: `.sparkline*`, `.progress*`, `.bar-chart*` plus `.bar-*` / `.bars*` chart parts, `.line-chart*` plus `.line-*` chart parts, `.area-chart*` plus `.area-*` chart parts (incl. `.area-playground*`), `.radial-chart*` plus `.radial-*` chart parts, `.donut*`, `.gauge*`, `.heatmap*`, `.chart*` (incl. `.chart-tooltip*`, `.chart-legend*`, `.chart-grid`, `.chart-tick`, `.chart-title`, `.chart-subtitle`), `.series*` (incl. `.series-1` through `.series-8`), `.flow*` (full diagram primitive), `.logo-cloud*`. ~2.6 million character-deltas across 25 files. Hard migration, no aliases.

- **May 2026 v9 — Avatar promotion (tier-1 → tier-2).** `.ds-doc-avatar*` → `.avatar*`. Same logic as the v8 chart-family promotion: the avatar primitive was filed tier-1 because it shipped only inside the catalog's Brand Asset Library, but production consumers turned up (`index.html` chain grid, `playground.html` chain rows, `page.css .chain-item`) and the tier-1 label became wrong. Migrated names: `.ds-doc-avatar`, `.ds-doc-avatar--sm/--md/--lg/--xl`, `.ds-doc-avatar--square/--rounded/--circle`, `.ds-doc-avatar--ring`, `.ds-doc-avatar-stack`, `.ds-doc-avatar-stack--tight`, `.ds-doc-avatar-label`. **Kept tier-1:** `.ds-doc-avatar-grid` and `.ds-doc-avatar-cell` — those are the catalog's asset-library display containers (parallel to `.ds-doc-icon-grid` / `.ds-doc-icon-cell`), genuinely catalog-meta. Sweep covered `styles.css` + 6 consumer files (~580 references). Side-edit: `design-system/brand-guide.html` page-local `.avatar-grid` / `.avatar-cell` / `.avatar-icon` / `.avatar-label` / `.avatar-meta` (the social-media download tiles) renamed to `.social-avatar-*` to free the namespace. Hard migration, no aliases.
- **May 2026 v10 — Side nav promotion (tier-1 → tier-2) + Tailwind-style extensions.** `.ds-doc-sidebar*` → `.side-nav*` (BEM `__` parts). Same logic as v8 / v9: the catalog's sidebar was tier-1 only because the catalog was its first consumer; a widget rail or settings-drawer rail wants the same shape with no catalog-meta coupling. The promotion split the catalog-coupling under a `.side-nav--sticky` modifier (sticky-to-viewport + scroll-spy + mobile accordion) so the base class is a clean free-standing container. Same pass added new variants beyond the prior surface area: `.side-nav--sm` (sizing-ladder tier — _retired June 2026 → `.side-nav--lg`, the comfortable tier the Portal runs; no `--sm` consumers ever materialized_), `.side-nav--rail` (~64px icon-only column, pair with `.tooltip`), `.side-nav__item--parent` + `[aria-expanded]` + `.side-nav__sub` (nested drill children), `.side-nav__trail` (trailing badge / count slot), `.side-nav__foot` (pinned-bottom user/team chip slot). Migrated names: `.ds-doc-sidebar` → `.side-nav` (+ `.side-nav--sticky` on existing catalog/dashboard usages), `.ds-doc-sidebar-group` → `.side-nav__group`, `.ds-doc-sidebar-title` → `.side-nav__title`, `.ds-doc-sidebar-item` → `.side-nav__item`, `.ds-doc-sidebar-icon` → `.side-nav__icon`. Sweep covered `styles.css` (§3.5 + the consolidated mobile-accordion block that used to live at the bottom of the file), `shared.js`, `design-system/index.html` (catalog rail + catalog `#ds-doc-sidebar` section moved to a sidebar-linked `#side-nav` in the Navigation group), `design-system/dashboard.html`, `design.md` §06. **Kept tier-1:** `.ds-doc-layout` and `.ds-doc-content` — these are catalog-meta layout helpers (one rail + one content column on a long-scroll doc page), not part of the side-nav primitive itself. Catalog-only JS function names (`initDsSidebarAccordion`, `initDsSidebarActive`) kept their `Ds` prefix — they're catalog-meta behaviors that only run on `#dsSidebar`. Hard migration, no aliases.

After v8, **the rule is fully predictable:** `.ds-doc-*` = catalog chrome; no prefix = universal cross-surface primitive; `.ui-*` = product-surface primitive. There are no `.ds-*` classes that aren't `.ds-doc-*`. If you see one, it's drift; rename it.

**Forbidden:**

- `.ds-` prefix on a primitive used outside the catalog. If `<input class="ds-input">` appears on a product page, the prefix is wrong.
- No prefix on a primitive used only by the catalog. If `.callout-rules` only ever renders inside `.ds-doc-card__pane[data-pane="rules"]`, prefix it `.callout-rules`.
- `.ui-` prefix on a primitive used on marketing pages. If `.ui-feature-card` shows up on `index.html`, drop the prefix and promote to tier 2.
- Mixed usage on the same component (some places `.ds-foo`, some `.foo`, some `.ui-foo`). Pick one — the rename is cheap if you commit.

### Documentation typography — sizes, line-heights, tracking

Documentation prose has its own type scale, distinct from marketing/product surfaces. The rationale, codified:

| Element | Size | Line-height | Weight | Tracking | Why |
| --- | --- | --- | --- | --- | --- |
| `.ds-doc-card__label` | **26 px** (literal) | `--lh-h3` (32 px ≈ 1.23) | 600 | `-0.02em` | The variant name. Designers scan this first — bumped Apr 2026 from 24&nbsp;px so the heading carries more presence. Slightly above pure h3 scale; uses a literal because the token system has no in-between step. |
| `.ds-doc-card__caption` | `--text-body` (**16 px**) | `1.55` unitless | 400 | default | Supporting line. Bumped Apr 2026 from 14 → 16&nbsp;px so the caption reads as body prose, not a small note. The label still leads the visual hierarchy at 26 px / weight 600. **No `max-width`** — let the parent container dictate line length. |
| `.ds-doc-text` | `--text-sm` (14 px) | `1.6` unitless | 400 | default | Body prose inside engineer-facing tabs (Markup / Specs / Rules / Source). Below `--text-body` on purpose — those panes are dense reference, not flowing content. |
| `.ds-doc-list` | `--text-sm` (14 px) | `1.6` unitless | 400 | default | Same scale as `.ds-doc-text`; lists inherit the rhythm. |
| `.ds-doc-codeblock` | `--text-sm` (14 px) | `1.55` unitless | 400 | 0 | Mono. Tabbed code, no kerning concerns. |
| `.code-chip` (default — bare `<code>` or explicit class) | `0.85em` relative | `1.4` unitless | 600 | 0 | Inline code identifier site-wide. **Filled mono pill — no border** (Apr 2026 v2): `currentColor 7 %` bg, primary text colour, 5 px radius, em-relative padding. Hover lifts to `accent-primary 12 %`. Tier-2 universal (promoted from `.ds-doc-class` May 2026 v10). |
| `.ds-doc-card__label code` (override) | **0.72em** relative | inherit | 500 | 0 | Inline class chip *inside the label only*. Drops the chip outline, background, and 600 weight from the default rule so the noun phrase leads. Subtle color shift only — still mono, still spottable, but quiet. |
| `.data-table` cells | `--text-sm` (14 px) | default | 400 / 600 | default | Spec tables. Use `tabular-nums` so numeric columns align by digit. |
| `.section-desc` | `--text-body` (16 px) | `--lh-body` (24 px = 1.5) | 400 | default | Section-level prose. Same role as `.ds-doc-card__caption` (a prose-mode description under a heading), so same colour: `--text-secondary` (Apr 2026 — was `--text-muted`, which made section-desc *darker* than the smaller card-level caption — an inverted grade). |

**Two-tier readability — heading + caption are designer-facing, panes are engineer-facing.** The catalog has two reading modes on every card. The HEAD (label + caption) is what designers see while scanning a page of cards — they need to read it like body copy in a magazine. The PANES (markup, specs, rules, source) are what engineers click into for reference — they need to read it like a spec sheet. The two scales are deliberately different: 26 / 16 in the head (designer mode), 14 / 14 in the panes (engineer mode). Don't conflate them — the head is reading material, the panes are lookup surfaces.

**Why `.ds-doc-text` stays at 14 px** — typeset best practices say body should be 16 px+. Documentation *panes* are a deliberate exception: they're dense reference surfaces (specs, anatomy, rules) where the reader is *looking up*, not *reading through*. 16 px makes the panes feel oversized and adds a screen of scroll per card. The existing token system supports both — `--text-body` (16 px) for prose, `--text-sm` (14 px) for dense reference — and the `.ds-doc-*` family commits to sm. The `.section-desc` outside cards and the `.ds-doc-card__caption` at the top of each card both use 16 px because they're prose-mode surfaces.

**Why the label-context chip is quieter** — the default chip in body prose works fine at 0.85em / weight 600. Inside a 26 px heading those values make the chip the loudest element in the line; designers read the boxed class name before the noun phrase. The override shrinks (0.72em) and softens (weight 500, 70 %-mix colour) the type so the chip pairs with the heading instead of competing. The chrome — filled bg, no border — is **inherited** from the default rule (Apr 2026 v2 dropped the border across the board, so there's no chrome difference between contexts; only type weight + size + colour).

**Why `1.6` unitless and not `var(--lh-body)`** — the line-height tokens (`--lh-body` = fixed 24 px, `--lh-h3` = fixed 32 px) are designed to land on the spacing grid. They work perfectly when the font-size matches what the token was authored for (`--lh-body` for `--text-body` 16 px = 1.5 ratio). Applied to `--text-sm` (14 px), `--lh-body` becomes a 24/14 = **1.71 ratio** — too airy. Unitless `1.6` (= 22.4 px at 14 px) gives the comfortable reading rhythm. Use unitless multipliers when the same rule serves multiple font-sizes (or when inline `<code>` chips at 0.85em mix into the line).

**Why label tracking is `-0.02em`** — Geist's default metrics are slightly open. At display sizes the catalog uses `--tracking-tight` (-0.02em) to compensate. Card labels (h3 scale) used to sit at `-0.01em` — fine, but reads as web-default. Tightening to `-0.02em` aligns with display headlines and reads as authored. Subtle, but compounds across 80+ cards.

**Tabular numerals on `.data-table`** — every spec table contains rows like `12px / var(--space-12)` or `0.85em`. With proportional figures, the digits don't column-align and the table feels ragged. `font-variant-numeric: tabular-nums` (plus `font-feature-settings: "tnum" 1`) forces fixed-width digits — column alignment, no ragging. Applied at the `.data-table` level so every spec / anatomy table benefits.

**Known issue — `.section-title` line-height bug at small viewports** — `.section-title` uses `font-size: clamp(--text-h2, 4.5vw, --text-display)` and `line-height: var(--lh-display)` (fixed 64 px). At small viewports the font clamps down to 32 px while the line-height stays at 64 px → 2.0 ratio. This is the exact bug flagged in `CLAUDE.md → Display-type line-height & gradient descenders`. **Not fixed in this typography pass** because it affects every section header on every page, not just docs. Track separately.

### Documentation rhythm — single spacing cascade for the whole header stack

Documentation surfaces have a five-level vertical rhythm: **eyebrow → title → description → (optional chapter marker) → cards**. Each pair has a defined gap, expressed in spacing tokens, and the gaps grow as the relationship between the elements weakens — the eyebrow and title read as a single label-and-heading unit (tightest gap), the title and description read as a heading-and-paragraph pair (medium gap), the description and the first card read as "section header is finished, content begins" (widest gap). This rhythm is the same on every DS surface and every page that uses these primitives.

Pre-Apr 2026 the gaps were uneven and partly undefined — the eyebrow set 16 px below itself, the title set 24 px, the description set *nothing* (relied on the parent layout), and `.ds-doc-card__head` set 16 px. The result was inconsistent breathing between sections: sometimes the gap before the first card was tight (no defined margin → only the card's own padding), sometimes loose (card stacked next to a chapter marker that owned a different rule). The fixed cascade is below.

| Pair | Token | Px | Logic |
| --- | --- | --- | --- |
| `.eyebrow-code` → `.section-title` | `--space-12` | 12 | Tightest pair — the eyebrow is a *kicker* for the title; they read as one unit. |
| `.section-title` → `.section-desc` | `--space-20` | 20 | Heading + paragraph pair. Tight enough to feel paired, loose enough to separate weight 800 / 56 px from weight 400 / 16 px. |
| `.section-desc` → first `.ds-doc-card` (or chapter marker) | `--space-64` | 64 | The widest pair. Marks "section header is done, content begins". Without this, the gap was undefined and the first card sat awkwardly close to the description. |
| `.ds-doc-subsection` → first card under it | `--space-32` (effective via collapse) | 32 | The chapter marker has its own top-margin (40 px) and bottom-margin (16 px); the card's existing `+ .ds-doc-card { margin-top: 32 }` rule wins via margin collapse. |
| `.ds-doc-card__head` `.ds-doc-card__label` → `.ds-doc-card__caption` (gap inside head) | `--space-8` | 8 | The bigger label (26 px) and bigger caption (16 px) need slightly more breathing than the previous 6 px. Still a tight pair. |
| `.ds-doc-card__head` → first pane / preview content | `--space-24` | 24 | The head is a label-and-caption block; 24 px below introduces the live demo or first pane without dragging the head's "this is the card name" away from its content. |
| `.ds-doc-card` → adjacent `.ds-doc-card` (between cards) | `--space-80` (existing) | 80 | The biggest gap on the page. Plus a 1 px hairline divider centred in the gap (see Visual separation). |

**Visual cascade — what the rhythm looks like in CSS:**

```
.eyebrow-code        { margin-bottom: var(--space-12); }    /* 12 — paired with title */
.section-title       { margin-bottom: var(--space-20); }    /* 20 — paired with desc  */
.section-desc        { margin-bottom: var(--space-64); }    /* 64 — paired with cards */
.ds-doc-card__head   { gap: var(--space-8);                 /*  8 — label↔caption    */
                       margin-bottom: var(--space-24); }    /* 24 — head↔panes       */
.ds-doc-card + .ds-doc-card { margin-top: var(--space-80); }/* 80 — card↔card        */
```

**Same role = same colour.** The colour grade follows the rhythm — primary text for headings, secondary text for both prose-mode descriptions, faint for chapter markers and quiet eyebrows. Pre-Apr 2026 the section description was `--text-muted` (darker than the smaller card-level caption at `--text-secondary`), inverting the grade. Both are now at `--text-secondary` because they serve the same role: a one-paragraph prose description sitting under a heading.

| Element | Role | Colour |
| --- | --- | --- |
| `.eyebrow-code`, `.eyebrow-word` | Pre-title kicker (mono uppercase) | `--accent-secondary` |
| `.section-title`, `.ds-doc-card__label` | Heading | `--text-primary` |
| `.section-desc`, `.ds-doc-card__caption` | Prose-mode description | `--text-secondary` |
| `.ds-doc-text`, `.ds-doc-list`, `.data-table` body | Pane prose / lookup surfaces | `--text-secondary` |
| `.ds-doc-subsection` | Chapter marker (mono uppercase, quiet) | `--text-faint` |

**Why these specific tokens.** The spacing scale is octave-based (4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80) so every value lands on the grid; the rhythm uses 12/20/64/8/24/80 — six values on a 12-step scale. The growing-gap pattern (12 → 20 → 64) follows the typographic rule of "tighter pairs read as one unit, looser pairs read as transitions" — the eyebrow IS part of the title's introduction, the description is paired with the title, but the cards below are a *new thing* the section header introduces.

**Don'ts.**

- Don't add per-page margin overrides to these primitives. The rhythm is the system; if a page needs different breathing, the page's content is wrong, not the spacing.
- Don't author `<hr>` or extra `<div style="margin-top: …">` between sections. The cascade owns the gaps.
- Don't change the `.section-desc` colour to `--text-muted` again — it inverts the grade.
- Don't change the `.ds-doc-card__head` `gap` to less than 8 px — the bigger label/caption pair needs the breathing room.

### Pane breathing room — 4-tier cascade for whitespace inside Preview panes

The header rhythm (above) governs the cascade *down to* the first pane. Inside a `.ds-doc-viewport-pane`, a parallel four-tier cascade governs whitespace between examples and variants. Same growing-gap logic as the header rhythm — tighter pairs read as one unit, looser pairs read as transitions between distinct things.

| Tier | Token | Px | Where it lives | Use when |
|---|---|---|---|---|
| **1.** Eyebrow → demo | `--space-12` | 12 | `.ds-doc-eyebrow { margin-bottom }` | Always between an `.ds-doc-eyebrow` kicker and the demo immediately below. Set globally — never per-instance. |
| **2.** Sibling examples in one demo | `--space-20` | 20 | `.ds-doc-stack` (utility class) | When a demo stacks N example rows under a single eyebrow (or no eyebrow): 4 `.field-inline` siblings, 3 `.checkbox` rows, 2 `.form-error` demos. Apply the class to the pane or to a wrapping `<div>`. |
| **3.** Eyebrow-labeled cells | `--space-32` | 32 | `.ds-doc-eyebrow-row` (primitive) | Cells that each carry their own eyebrow + demo. The primitive auto-spaces adjacent rows at 32 px and owns the internal Tier-1 gap. |
| **4.** Zones within one pane | `--space-64` | 64 | `margin-top: var(--space-64)` (ad-hoc) | Between distinct content zones in one pane (compact-zone → composition-zone, foundation-zone → variants-zone). Signals "different kind of thing". |

**Picking the right primitive.**

- **Multiple labeled cells** (each is "eyebrow + demo") → wrap each cell in `.ds-doc-eyebrow-row`. Don't put them in a bare flex/grid — the primitive owns the inter-row + intra-cell rhythm.
- **Multiple unlabeled siblings** (4 `.field-inline`, 3 `.checkbox`) → apply `.ds-doc-stack` to the pane (or wrap the children).
- **Eyebrow + a single demo** → bare `.ds-doc-eyebrow` followed by content. The 12 px Tier-1 gap is the global default.
- **Multi-zone pane** (compact + composition zones) → wrap each zone, separate with `margin-top: var(--space-64)` on the second zone (and onward).

**`.ds-doc-stack` modifiers.**

```
.ds-doc-stack         flex column, gap 20  (default — sibling examples)
.ds-doc-stack--lg     flex column, gap 32  (substantial examples — cards with desc, etc.)
.ds-doc-stack--xl     flex column, gap 40  (cell groups inside one zone — pairs with Tier 3)
```

**Authoring rules.**

- Never inline `style="margin-bottom: var(--space-12)"` on a `.ds-doc-eyebrow`. The 12 px is global; if the demo needs more breathing, you're using the wrong tier.
- Never stack 2+ direct sibling examples in a Preview pane without a flex/grid container. Direct siblings without a gap touch — that's the whole bug this rule prevents.
- Don't mix `.ds-doc-stack` and `.ds-doc-eyebrow-row` on the same children — the wrappers conflict and produce double-margin drift.
- Ad-hoc inline `gap: var(--space-…)` on a custom flex/grid wrapper is fine when the demo's shape is genuinely unique (e.g., the radio atom card's `auto-fit, minmax(280px, 1fr)` cell grid). For pure vertical stacking, prefer `.ds-doc-stack`.

**Bug history (May 2026).** The `.ds-doc-eyebrow` primitive's `margin: 0 0 var(--space-6)` referenced an undefined token. Resolved to `margin-bottom: 0`. Eyebrows touched the demo content below them across every catalog card — designers reported that variant labels "ran into" the components. Fix: bumped to `var(--space-12)` (Tier 1 = 12 px). Same pass introduced `.ds-doc-stack` for the parallel "sibling examples without a wrapper" case (4 `.field-inline` siblings touching, 3 `.checkbox` rows touching, etc.) and applied it to 6 cards that were missing the wrapper: Password trailing-icon, Leading icon, Checkbox, Form error, Text prefix & suffix, Card interaction reference.

#### Ruled rows — opt-in hairline between cells

For most cards, Tier 3's 32 px gap between `.ds-doc-eyebrow-row` cells is enough separation — buttons, inputs, chips, modals all have strong demo silhouettes (filled bg, hard borders, large shapes) so a quiet gap reads as a clear boundary. A small family of cards breaks that assumption: cards whose **demos are themselves small, quiet, mono caps tokens** — the eyebrow family, kbd shortcuts, status dots, color swatches at small size. In those cards the demo and the next cell's `.ds-doc-eyebrow` kicker share too much visual signature (both are small uppercase text); the 32 px gap can't carry the boundary, and the variants read as one continuous stripe of mono caps.

The solution is a parent modifier on the Preview pane — `.ds-doc-viewport-pane--ruled` — that inserts a 1 px hairline divider between every pair of adjacent direct children, with 32 px breathing above and below the line. Same divider color recipe as `.divider` and the inter-card hairline (7%/8% dark/light); same `> * + *` shape as the system-level `.ds-doc-card + .ds-doc-card` rule, applied one tier deeper. The hierarchy reads as a consistent ladder: section padding (240 px) → card hairline (80 px) → ruled-cell hairline (32 + 32 px) → variant-internal spacing.

**Authoring contract.**

- Apply the class to the `.ds-doc-viewport-pane[data-pane="preview"]` itself — not to a wrapper, not to individual rows. Every direct child gets the rhythm automatically; the `> *` selector won't reach grandchildren.
- It's **opt-in**, not default. 90% of cards have strong silhouettes and don't need it. Reach for it only when you can see the merge happening — that's the trigger.
- The modifier composes with `.ds-doc-eyebrow-row` (same 32 px upstream margin survives; the modifier adds the padding + border below the line). Don't combine it with `.ds-doc-stack` on the same pane — pick one rhythm primitive per pane.
- For a single-variant card (one cell), don't apply the modifier. The hairline needs two cells to render (the `+` combinator is adjacent-sibling only).

**Markup.**

```html
<!-- ✓ Multi-variant card with small quiet demos -->
<div class="ds-doc-viewport-pane ds-doc-viewport-pane--ruled is-active" data-pane="preview">
  <div class="ds-doc-eyebrow-row">
    <p class="ds-doc-eyebrow">Variant 1 · purpose</p>
    <demo>...</demo>
  </div>
  <div class="ds-doc-eyebrow-row">
    <p class="ds-doc-eyebrow">Variant 2 · purpose</p>
    <demo>...</demo>
  </div>
  <!-- ...more cells; hairline auto-inserted between each pair -->
</div>
```

**Cascade reference, extended:**

| Tier | Token | Px | Primitive |
|---|---|---|---|
| 1. Eyebrow → demo | `--space-12` | 12 | `.ds-doc-eyebrow { margin-bottom }` |
| 2. Sibling examples | `--space-20` | 20 | `.ds-doc-stack` |
| 3. Eyebrow-labeled cells | `--space-32` | 32 | `.ds-doc-eyebrow-row` |
| **3+. Ruled cells (opt-in)** | `32 + 1 + 32` | 65 (with hairline) | `.ds-doc-viewport-pane--ruled` |
| 4. Zones | `--space-64` | 64 | `margin-top: var(--space-64)` |

**Bug history (May 2026).** The Eyebrow component card shipped with 9 `.ds-doc-eyebrow-row` cells covering in-page · hero · pane surfaces — a deliberately comprehensive family snapshot. Designers reported that variants "ran into each other" because each cell's demo is itself an `.eyebrow-code` / `.eyebrow-word` / `.header-eyebrow` — all mono uppercase caps — visually indistinguishable from the next cell's `.ds-doc-eyebrow` kicker. The 32 px Tier-3 gap couldn't carry the boundary. **Two-part fix:** (1) split the single 9-variant card into three sibling cards (`In-page eyebrow` · `Hero eyebrow` · `Pane eyebrow`), so the family hierarchy reads from the card labels and the 80 px + inter-card hairline does separation work for free between surface families; (2) introduced `.ds-doc-viewport-pane--ruled` for the remaining within-card separation problem, applied to the in-page (5 variants) and hero (3 variants) cards. Same modifier is the canonical answer for any future "small quiet demo" card — kbd shortcuts, status dots, color swatches, token spec rows.

### Every new component must be documented in three places

A component is not "in the design system" until it lives in all three:

1. **Implementation** — `styles.css`. CSS is the single source of truth for styling. Components should use design tokens (`var(--accent-primary)`, `var(--text-primary)`, etc.) so the Theme Composer picks them up automatically.

2. **Written spec** — `design/components/<family>.md` (catalogued from `design.md §06`). Recipe (shape / padding / type / colors), variant table (class → visual → usage), do/don't guidance, and a markup example. This is where designers and engineers go to understand *when* to use the component.

3. **Rendered catalog entry** — `design-system.html`. A live, interactive preview so anyone can see the component in their browser and copy markup directly.

### Always ask before adding a visual preview

When a contributor proposes a new component, **ask explicitly whether to add a visual preview to `design-system.html`** before writing it. Use phrasing like:

> "Do you want me to add a live preview of this component to the design-system.html catalog page, or just keep the CSS + spec in `design/components/` for now?"

A "yes" means: create a new `.comp-section` in `design-system.html` with:

- An `id` matching the component's kebab-case name (so `#chips`, `#accent-card`), enabling deep-links from docs, PRs, and chat
- A **live preview row** — a `.comp-row` containing one instance of every variant at realistic size, using the *actual CSS classes* (not mock markup), so what you see in the catalog is what you get on any page
- A **variants table** — class → visual dot or swatch → usage, rendered with `.data-table` so styles match other catalog entries
- A **spec table** — shape / padding / type / sizes / colors, whatever dimensions are meaningful for the component
- A **usage paragraph** — when to use, when not to use, and the distinguishing relationship to sibling components (e.g. "`.chip--xl` reads as headline-adjacent; default `.chip` reads as metadata")

Place the new section inside the nearest semantic parent (e.g. a new chip goes inside `#badges`; a new card variant gets its own section in the Cards sidebar group, or a new chapter inside an existing one like `#stat-cards`). Add it to the sidebar navigation if the component deserves top-level discoverability.

A "no" is also a valid answer — some components are deliberately internal (one-off page composites, prototype utilities, things not ready for broad adoption). The spec in `design.md` is enough for those, and a preview can be added later when the component graduates.

### Don't skip the ask

It's a recurring failure mode to add a component via CSS + spec doc, ship it, and forget the catalog. Six months later no one knows the component exists. The explicit ask stops this from happening — even if the answer is "no, later", the conversation has happened.

### When you revise a component

Same three-places rule applies for revisions — if you change padding, variants, or usage, update all three:

- `styles.css` — implementation
- `design.md` — spec
- `design-system.html` — preview + tables (if a preview exists)

Mismatches between layers cause silent design drift. Keep them in sync.

### Preview pane layout — two non-negotiable rules

Two layout rules govern every multi-variant catalog card. Both protect the reader from doc-only chrome contaminating their reading of a component. Apply when authoring AND when reviewing.

**1. Stack variants, don't cram them.** Multiple variants — sizes, alignments, chrome modifiers, color variants — **stack vertically at full width**, one labeled `.ds-doc-viewport.ds-doc-viewport--lg.ds-doc-viewport--stack` per variant. Don't author 2- or 3-column grids inside a preview pane: each variant gets ~120–180 px and distorts (text wraps badly, gaps between term and value collapse, embedded controls clip, the reader can't tell whether what they see is real component behavior or an artifact of the cell). Vertical scroll is cheap; horizontal compression is expensive. Side-by-side comparison is reserved for genuinely tiny primitives where each item still fits at its natural size — color swatches, chip ladders, icon ladders, single-line chips. Rule of thumb: if the primitive ever sits at full card / modal / drawer width in production, stack vertically in the catalog too.

**2. Variant labels go ABOVE the demo as `.ds-doc-eyebrow`.** Doc chrome and demo content are separate layers. Variant names ("Default", "Hover state", "Sm / default / lg", "`.detail-list--divided`") belong in a `<p class="ds-doc-eyebrow">…</p>` placed immediately above the demo, never inside the demo's own surface. Mixing the two contaminates the preview: readers see the eyebrow as part of the component pattern, and designers copying the markup carry the doc-only label into real surfaces. Exception: content that's intrinsic to the component being demoed (a tooltip in a tooltip-trigger demo, callout body in a callout demo) is fine — that's the component, not a label about the component.

The rule is symmetric: doc-chrome eyebrow above, component below. Each variant gets its own eyebrow + demo pair, stacked top-to-bottom. (May 2026: this replaced the older `.ds-doc-viewport-header` strip pattern, which was retired as redundant — the strip duplicated what the card's `__caption` already says.) Full bad/good examples in the lifi-ds-docs skill at `.claude/skills/lifi-ds-docs/references/writing-style.md → "Variant layout"` and `→ "Variant labels"`.

### Preview pane size variants — always label with pixel heights

When a Preview pane documents **size variants** (sm / default / lg / xl tiers, or any height-bearing taxonomy — `--sm` / `--lg` modifiers, compact / regular / large, mini / standard / hero, etc.), every reference to a tier name MUST include the resolved pixel height alongside it. The catalog's job is to ground vocabulary in reality; "lg" is an abstraction until it lands as a concrete number.

**A new sub-variant rides the family's full ladder — and the catalog must show EVERY tier.** When you add a sub-variant to an existing primitive family (a monogram avatar alongside the mark / tandem / placeholder; a new chip shape; a new button tone), it inherits the family's complete size ladder and the same `--xs / --sm / --lg / --xl / --2xl` naming — it does NOT get its own reduced ladder. Demonstrate **all** tiers in the catalog, laid out to match the sibling variants' Sizes zone; showing a subset (e.g. 3 of 6) reads as "this variant only supports those sizes" even when the CSS supports the whole ladder. **Bug history (June 2026):** `.avatar--initial` shipped a Sizes demo with only xs / default / 2xl; a designer flagged it as a reduced ladder. The CSS already composed all six tiers (via `--avatar-size` inheritance from base `.avatar`); the fix was a six-tier Sizes zone matching the regular Sizes + Tandem-sizes zones, same `.avatar--*` naming.

**Three places the px callout appears.**

1. **Tier-list caption under the demo.** The `.ds-doc-caption` (or analogous prose) that names the tier vocabulary always wraps each tier name with the height in parens.
   ```html
   <!-- ✓ -->
   <p class="ds-doc-caption"><code>.foo--sm</code> (32 px) · default (40 px) · <code>.foo--lg</code> (48 px) · <code>.foo--xl</code> (56 px) — mirror the <code>.btn</code> ladder.</p>

   <!-- ✗ -->
   <p class="ds-doc-caption"><code>.foo--sm</code> · default · <code>.foo--lg</code> · <code>.foo--xl</code> — mirror the <code>.btn</code> ladder.</p>
   ```

2. **Specs sizing table.** Include a `Height` column next to `Padding` / `Radius` / `Font` / `Pairs with`.
   ```
   | Tier              | Padding (y / x) | Radius | Font            | Height | Pairs with |
   |-------------------|-----------------|--------|-----------------|--------|------------|
   | .foo--sm          | 8 / 12          | 12     | --text-sm (14)  | 32 px  | .btn-sm    |
   | default           | 12 / 16         | 16     | --text-sm (14)  | 40 px  | .btn       |
   | .foo--lg          | 12 / 20         | 20     | --text-body (16)| 48 px  | .btn-lg    |
   ```

3. **Anatomy size-modifier rows.** Each modifier row mentions the resolved height inline.
   ```
   .foo--sm  → Compact tier — padding 8/12, radius 12, height 32 px. Pairs with .btn-sm.
   .foo--lg  → Large tier — padding 12/20, radius 20, height 48 px. Pairs with .btn-lg.
   ```

**Why pixels, not just tier names.** Tier names are vocabulary; pixels are reality. A designer reading "lg" doesn't know if that's 40 px or 56 px without leaving the catalog. Calling out the px lets them eyeball whether a tier fits a layout constraint (does the lg trigger fit under the 64-px navbar? does the sm chip clear a 28-px row height?) without inspecting the DOM. Engineers benefit too — they get the spec-grade value at the front of the card instead of inside the Specs tab.

**When to apply.**

- Any size-variant ladder — buttons, inputs, segmented controls, search-triggers, switches, chips, kbd chips, badges, avatars, sliders, anything with sm/default/lg/xl or analogous modifiers.
- Any height-bearing variant taxonomy that isn't strictly sm/default/lg/xl — compact/regular/large, mini/standard/hero.
- Skip for inherently single-size components (`.fab`, `.callout`, `.modal`, `.search` per `§06 → Component Sizing Ladders → Rules`). The rule is about cross-tier comparison — single-size primitives have nothing to compare against.

**Retroactive sweep.** When you touch a multi-size component's documentation (CSS change, copy edit, validator pass), retrofit missing px callouts in the same edit. Don't accumulate drift — the cost of adding a single height value is much lower than a future sweep across every multi-size card.

**Future validator addition.** A W-class warning is planned: any `.ds-doc-caption` matching a tier-name pattern (`.foo--sm` / `--lg` / `--xl`) without a `(\d+ px)` token alongside it will surface as drift. Until that lands, the rule is documentation-enforced — apply by hand when authoring.

**Bug history (May 2026).** Identified during the `.search-trigger` promotion. The card shipped with `.search-trigger--sm · default · .search-trigger--lg — mirror the .btn ladder` — accurate vocabulary, but the reader had no way to know the heights were 35 / 46 / 50 px without inspecting the DOM. User pushback prompted this rule and a retroactive sweep across every multi-size card in the catalog.

### Slider track-fill — always use `window.lifiSliderFill(input)`

The `.slider-range` track is painted via a `linear-gradient` that reads `var(--slider-pct)`. **Never set `--slider-pct` from a naive `(value − min) / (max − min) × 100` formula in your component code.** Use the canonical helper at the top of `shared.js`:

```js
window.lifiSliderFill(input);          // re-paint one slider
window.lifiInitSliderFills(root);      // wire every .slider-range under root
```

**Why the naive formula is wrong.** WebKit constrains the thumb's center to `(thumb-radius, track-width − thumb-radius)` — the thumb's edges never escape the track. A naive percentage paints the gradient stop at `value%` of the *full* track, but the thumb's actual visible center is inset by half its width on each end. Error compounds toward the extremes: at value=0 or value=max, the fill is off by `(thumb-radius / track-width) × 100%` — about ±5 % on a 200 px panel slider with the default 20 px thumb. The helper reads the thumb diameter from `--slider-thumb-d` (set by the size variants in `styles.css`) and the track width via `clientWidth`, then computes `(insetFrac / 2) + naive × (1 − insetFrac)` so the gradient stop lands exactly on the thumb's visual center at every tier.

**Auto-wiring.** The helper's IIFE runs on `DOMContentLoaded` and walks every `input[type="range"].slider-range` in the document, setting the initial fill and binding an `input` listener (idempotent — re-running is safe via `__lifiSliderWired`). A `MutationObserver` keeps newly-rendered sliders (Material Composer, FAB panels, Spectral picker, future composer widgets) wired automatically. A throttled `resize` handler re-paints every slider when the viewport changes, since the corrected formula depends on `clientWidth`.

**When you do need to call it manually.** Most consumers don't — the auto-wirer covers initial render + input events + resize. Reach for the explicit call when:

- You're rendering sliders dynamically inside a JS component (Material Composer, palette pickers) — call after appending to the DOM so the initial fill paints before the user sees mis-aligned chrome.
- You're MIRRORING a value across two sliders (e.g. inline ↔ modal Composer instances) — setting `.value` on the mirror doesn't fire its `input` event, so call the helper on the mirror too.
- You set `.value` programmatically from a JS setter (e.g. resetting to a preset) — same reason as above.

**Don'ts.**

- Don't inline `style.setProperty('--slider-pct', ((value-min)/(max-min)*100) + '%')`. That's the buggy formula. Use the helper.
- Don't author your own helper that "wraps" `lifiSliderFill` — every wrapper layer is one more place the inset correction could be re-broken.
- Don't set the fill from CSS keyframes or transitions — the helper is JS-driven because it needs `clientWidth`, which CSS can't observe.

**Source.** `shared.js` (top, after the prefers-color-scheme handler — search `lifiSliderFill`). `styles.css → .slider-range` carries the comment block pointing back at the helper as the canonical setter.

**Bug history (May 2026).** Recurring drift across 7+ call sites: Theme Composer's `setSlider()`, Spectral Composer's `setSliderValue()`, Material Composer (which forgot to set the var at all, leaving the fill stuck at the CSS default 50%), the playground globe sliders in `index.html`, the swap-widget playground in `swap.js`, the radial-cluster sliders in `plans.html`, and the lab page motion sliders. Each reinvented the naive formula independently. Consolidated under `window.lifiSliderFill` so the inset math lives in one place; the auto-wirer + MutationObserver mean new sliders work without explicit wiring at all.

### Ground preview content in LI.FI products and Web3 / DeFi

Every piece of content in a preview — labels, descriptions, button text, table rows, settings names, headlines, KPI values, list items — comes from one of two wells, in priority order:

1. **LI.FI products and surfaces.** Jumper, swap widget, route, quote, slippage tolerance, MEV protection, refresh interval, gas estimate, chain list (Ethereum, Polygon, Arbitrum, Optimism, Base, Solana), token list (ETH, USDC, USDT, MATIC), Widget SDK events (`onRouteSelected`, `onTransactionStarted`, `onTransactionCompleted`, `onWalletConnected`), wallet connect, embed code, configuration. Reach here first.
2. **Web3 / DeFi domain** (fall back when LI.FI specifics don't fit). Swap, bridge, gas, mempool, settlement, signature, approval, allowance, validator, sequencer, TVL, 24h volume, liquidity. Generic enough to suit any Web3 catalog card, specific enough that the reader still feels they're in our domain.

**Banned — Tier 3 content.** Lorem ipsum, "Sample text", "Item 1 / Item 2 / Item 3", random people ("Jane Doe"), made-up product names ("Acme", "Widget Inc."), generic SaaS marketing copy ("Boost productivity", "Streamline your workflow", "Best in class"), industry-neutral fluff. The catalog is a Rosetta stone for company vocabulary — every team that reads it learns what we call routes, settings, events, chains, tokens. Drift starts the moment a card ships with placeholder text: designers prototyping with the catalog reach for the demo's text first, and the prototype starts generic instead of brand-aligned.

**System-meta variant labels are exempt.** Names that describe the component's structure (not its content) stay neutral: "Primary / Secondary / Tertiary" for button variants, "Small / Default / Large" for sizes, "Hover / Active / Focus" for states, "Default / Outline / Borderless" for chrome. These are doc chrome about the component itself, not domain content. Don't mix them — putting LI.FI vocabulary where the variant name should go, or putting a variant name where domain content should be, defeats both purposes.

**Bad → good** (real examples from this catalog):

| Was | Should be |
|---|---|
| "Hover for the lift" | "Open in widget" · "View on Jumper" |
| "KPI value" / "Helper context" | "24h volume" / "Routes executed" |
| "Light brand leak" | "Active route" / "Selected chain" |
| "Click to navigate" | "Connect wallet" · "Choose network" |
| "Section title goes here" | "Playground preferences" / "Widget events" |
| "Notification on" | "MEV protection enabled" / "Auto-refresh quotes" |

Apply when authoring AND when reviewing — if a card you're touching has generic placeholders, swap them in the same edit. Full bad/good catalog with vocabulary tables in the lifi-ds-docs skill at `.claude/skills/lifi-ds-docs/references/writing-style.md → "Ground content in LI.FI products and Web3 / DeFi"`.

### The catalog template — `.ds-doc-card`

Every catalog entry uses the **same card primitive** so designers see what they need first (variant name + a glance of the live preview) and engineering finds everything else (markup, specs, anatomy, rules, source) behind a tab strip on the same frame. Without this template, each contributor invents their own ordering and the catalog drifts into a wall of monospace within a quarter.

**Visual hierarchy** — answers the audience priority above:

- **Label** is the dominant element. h3-scale (24 px), sans, weight 600, primary text colour. A designer scanning the catalog reads the variant name first; the inline `<code>` keeps mono so the actual class name (`.badge`, `.btn-primary`) pops within the heading.
- **Caption** is the supporting line. sm (14 px), weight 400, muted text colour. It's prose context for designers who want a second of detail; engineering and marketing skim it. Earlier versions had this inverted (label was a tiny mono uppercase eyebrow, caption was full body weight) — designers couldn't pre-attentively tell one card from another, so the hierarchy was flipped.
- **Tabbed frame** sits below the head. Preview is the active tab on load — that's what designers see; the engineer-facing tabs (Markup / Specs / Anatomy / Rules / Source) are quiet chrome on the same frame.

**Anatomy**

```
.ds-doc-card
  .ds-doc-card__head
    .ds-doc-card__label              variant NAME — h3-scale, sans, primary
    .ds-doc-card__caption            supporting line; ≤140 chars; sm, muted
  .ds-doc-viewport.ds-doc-viewport--tabbed   tabbed frame (Preview + engineer tabs)
    .ds-doc-viewport-tabs                tab strip — role="tablist"
      .ds-doc-viewport-tab               data-tab="preview|markup|specs|…"
    .ds-doc-viewport-pane                data-pane="…"; HTML `hidden` on inactive
```

The page-level **Concise / Detailed toggle** in the catalog header (`.ds-doc-mode-toggle`) reveals every pane at once for engineering scans. Setting persists via localStorage (`lifi-ds-doc-mode`) and cascades to every DS doc page.

CSS lives in `styles.css` (search `.ds-doc-card` for the head, `.ds-doc-viewport--tabbed` for the frame). JS lives in `shared.js → initDsCompDoc()` — wires every `.ds-doc-viewport--tabbed` on the page, not coupled to `.ds-doc-card`. The frame primitive itself is documented in the catalog at `#viewport-tabbed`.

### The skill — `lifi-ds-docs`

The `.ds-doc-card` template is enforced by a **skill** at `.claude/skills/lifi-ds-docs/`. Use it whenever you're adding or migrating a component:

- **`SKILL.md`** — when to invoke, the template overview, the workflow.
- **`references/component-template.html`** — copy-paste skeleton with TODO slots and inline guidance.
- **`references/writing-style.md`** — caption rule (≤140 chars, no marketing voice), prose conventions, before/after examples.
- **`references/procedure.md`** — six-step adding-a-component workflow, edge cases (multi-variant sections, page-local CSS, migration of legacy sections).
- **`scripts/validate-doc.py`** — structural linter; checks every `.ds-doc-card` for missing slots, oversized captions, panel/disc mismatches, raw hex colors, marketing words. Run before committing:

  ```bash
  python3 .claude/skills/lifi-ds-docs/scripts/validate-doc.py
  ```

  Exit code 0 = clean. Non-zero = drift; the output lists every finding with `file:line` references.

The skill **doesn't** write the CSS or the `design.md` spec for you — it documents the live preview. The three-places rule still applies; the skill makes the third place (catalog) consistent and validatable.

### Don't migrate everything in one PR

The catalog has ~25 component sections. Most still use the legacy `<div class="comp-section">` shape (label + prose + tables stacked vertically). Migrating to `.ds-doc-card` is mechanical (the procedure doc walks it) but it's tedious and risky in bulk.

**Migrate as you touch.** When you're already editing a section for some other reason, fold the migration into the same edit. A migration-only PR for one section is fine; a migration-only PR for fifteen sections is asking for a regression nobody catches.

### Tile / card active-state pattern — `.tile.is-active`

The catalog has a shared sticky-active state for any tile-based card. Use it for cards that carry a persistent "selected" state (the active theme card in the brand catalog, an active filter chip, an active item in a settings list, a selected item in a future picker). Distinct from the momentary `:active` (mouse-down) — `.is-active` is sticky.

**The recipe** (lives in `styles.css` near the `.tile` rules — search for `.tile.is-active`):

```css
.tile.is-active,
.feature-card.is-active,
.enterprise-card.is-active {
  background:    color-mix(in oklch, var(--tile-accent) 8%, transparent);
  border-color:  var(--tile-accent);
  box-shadow:    0 0 18px color-mix(in oklch, var(--tile-accent) 14%, transparent);
}
```

Three signals, no hard ring: a tinted background, a full-strength accent border, and a soft 18 px halo at 14 % alpha — strong enough to announce "selected" without dominating the grid. The 18 px blur fits inside the typical 24 px tile-grid gap so the halo never crowds neighbouring cards.

**Tone the colour locally.** The recipe paints from `--tile-accent`, which on a plain `.tile` defaults to `--accent-primary`. Cards that want to light up in a *different* colour (e.g. each brand card lighting up in its own brand) just override `--tile-accent` locally — the shared rule picks up the local value automatically. No duplicated active CSS per card.

**Don't author a custom `.is-active` rule on a tile-based card.** If you find yourself reaching for one, you're either:
- Wanting a different colour → override `--tile-accent` locally instead.
- Wanting a different shape (e.g. a sharp ring instead of a halo) → that's a *different* state visual; stay consistent with the catalog and the rest of the site uses the soft halo.
- Wanting a richer treatment (logo, illustration) → the active state is chrome, not content. Add the richer treatment somewhere else (a modal, a side panel) and keep the card's selected state minimal.

The brand-card composite is the canonical consumer of this pattern — see the next section.

### Brand cards — composite for protocol / brand profile cards

`.brand-card` is a composite component built on top of the universal `.tile` primitive. Use it whenever you need to display a brand identity in a card: name, category, official colour swatches, narrative description, and a primary action. Apr 2026 it ships in the Brand Analysis page (14 protocols + LI.FI 1.0 / 2.0); the same composite is reusable for partner showcases, ecosystem directories, integration directories, internal team-credit pages, etc.

**Anatomy** (twelve named subcomponents, all live in `styles.css` § BRAND CARD):

| Class | Role |
| --- | --- |
| `.tile.brand-card` | Shell — extends `.tile`. Carries inline `--brand-c-*` vars. |
| `.brand-card-header` | Eyebrow + title-link, vertically stacked. |
| `.eyebrow-word` | Category label (universal eyebrow primitive). |
| `.brand-card-title-link` | Clickable title; wraps the `<h3>` + a hover-revealed icon. |
| `.brand-card-title-link__icon` | `open_in_new` glyph; opacity 0 at rest, 1 on hover/focus. |
| `.brand-card-chips` | Flex row of equal-width chips (count flexes 2–3). |
| `.brand-chip` | Single chip — composite of swatch + hex + label. |
| `.brand-chip-swatch` | 56 px tall colour block. |
| `.brand-chip-hex` | Hex code (mono, uppercase, **left-aligned**). |
| `.brand-chip-label` | Colour name (mono, uppercase, **left-aligned**). |
| `.brand-card-source-link` | Inline accent link appended to the body. |
| `.brand-card-foot` | Single full-width `.btn-secondary` action row. |

**Per-card brand identity — the bit that makes selected-state colourful.** Each card carries inline OKLCH custom properties that paint its selected state in the protocol's *own* colour, not the global `--accent-primary`:

```html
<article class="tile brand-card" style="
    --brand-c-light:    oklch(50% 0.26 309);   /* brand primary, light mode */
    --brand-c-dark:     oklch(61% 0.282 309);  /* brand primary, dark mode */
    --brand-c-on-light: #ffffff;               /* contrast-aware text on brand fill */
    --brand-c-on-dark:  #0a0a0a;
  ">
```

The CSS resolves `--brand-c` and `--brand-c-on` from the right pair based on `[data-theme]`, then maps `--tile-accent → --brand-c` so the shared `.tile.is-active` pattern picks up the per-card brand colour. The active button uses `--brand-c-on` for contrast — bright brand fills (L ≥ 65: lime, gold, mint) take near-black text; darker brands take white. The renderer in `competitors.html` computes `--brand-c-on-*` automatically with that L threshold.

**Hex codes verbatim from official sources.** The chip's `hex` value should be the brand's own published value (press kit, brand book, committed theme tokens) — not a value derived from OKLCH conversion. OKLCH drives the composer; chips display the canonical hex. See "Competitor brand sourcing — primary-source rule" below for the source ladder.

**Don't invent chips.** A brand that publishes only two colours (Uniswap pink + accessible pink, Socket yellow + black, Axelar orange + electric blue) gets two chips. The flex row stretches gracefully; honesty about the documented palette beats faux-symmetry.

**Title carries the website affordance.** Wrap the `<h3 class="tile-title">` in `<a class="brand-card-title-link" href="…">` so the brand name itself routes to the brand's homepage. No separate corner icon — the `open_in_new` glyph fades in next to the title on hover. Source citation goes inline at the end of the body description (`.brand-card-source-link`), not the foot — the foot is reserved for the primary CTA.

**Cataloged entry:** `design-system/index.html` § `#brand-card` (under Cards → Specialised). The catalog card has both at-rest and selected demos, full markup pane, specs, anatomy table, rules, and source pointers.

**Reusability outlook.** The chips (`.brand-chip` + parts) are the most independently-reusable subcomponent — color-swatch-with-hex-and-label is a common visual language. If a second surface needs them outside a `.brand-card`, promote `.brand-chip*` to a tier-2 generic primitive (suggested name: `.swatch-chip*`) at that point. Until then, they live with the brand-card spec.

### Competitor brand sourcing — primary-source rule

The Brand Analysis page (`design-system/competitors.html`) uses live OKLCH presets to drive a one-click theme preview across the whole site. The palette behind every competitor card MUST be sourced from the brand's own published assets — not averaged from screenshots, not eyeballed from a hero image, not derived from a third-party brand-fetch site that may be quoting outdated values.

**Source priority** (try in this order — stop at the first one that yields concrete hex codes):

1. **Brand book / press kit / brand-guidelines page** on the brand's official domain — Chainlink (`chain.link/brand-assets`), Wormhole (`wormhole.com/brand-and-press`), Uniswap (`github.com/Uniswap/brand-assets`), and Synapse (`docs.synapseprotocol.com/docs/About/Brand-Assets`) all publish formal palettes. Highest fidelity.
2. **Committed theme tokens in the brand's open-source repo** — Across ships colours straight in `tailwind.config.ts`; Hop ships them in `theme/{light,dark}.tsx` (MUI `palette.primary.{main,light,dark}`). Equally high fidelity, sometimes more comprehensive than the brand-book page.
3. **`:root` CSS variables on the live marketing site** — LayerZero ships a designer-grade token system (`--primary`, `--accent`, `--card`, etc.) with both sRGB and P3 wide-gamut values. Inspect the compiled CSS bundle.
4. **Live-site CSS frequency analysis** as last resort — Axelar doesn't publish a brand-kit page; their hex codes are extracted from the `axelar.network` homepage's CSS frequency table. Note "extracted from live site CSS" in the source citation when this is the only available method.
5. **Logo SVGs** when the brand defines colour exclusively through illustration — Stargate's logo SVG fills are the only canonical hex codes they publish; Synapse's brand SVG gradient stops are the only canonical chroma references in their kit.
6. **Live-site computed-style read** via `scripts/extract-brand-colors.mjs <url>` — a headless-Chrome reader for SPAs whose markup ships no inline hex (a tooled version of step 4). It RANKS by usage, so the top colour is usually a neutral surface; read the chromatic candidates critically and corroborate against a higher tier.

**This methodology is now tooled — see the `lifi-brand-theme` skill (June 2026).** The two scripts split the work the house way: `scripts/extract-brand-colors.mjs` reads a live site's brand colours (above), and `scripts/hex-to-preset.mjs --name … --id … "<hex>" …` does the deterministic hex→OKLCH conversion + dual-mode L derivation + a WCAG-AA contrast report that flags any light-mode accent needing a deeper L. The skill (`.claude/skills/lifi-brand-theme/SKILL.md`) carries the judgment — the source ladder above, the monochrome-brand mono-hue convention, the revision-bump-to-propagate rule, and the verify steps. Reach for it whenever adding a brand as a theme preset.

**Primary-source rule applied to a non-trivial example.** Five competitor palettes were materially wrong before the Apr 2026 v4 refresh:

- **Hop** was tagged "hot magenta" — actually mono-hue purple (h≈309) per their MUI theme.
- **Stargate** was tagged "deep purple + orange" — actually mint + coral + orange per their official logo SVG fills.
- **LayerZero** was tagged "near-monochrome blue + warm accent" — actually saturated lime (#C7F284) on near-black per their `:root --primary`.
- **Axelar** was tagged "sky blue + orange" — orange (#FF4E17) is the brand-anchor "hero ball"; electric blue is secondary.
- **Jupiter** was tagged "neon green + purple" — actually a Saturn-ring gradient (gold #FCC00A → planet-blue #4EBAE9) with spearmint reserved for positive-delta text.

Misclassifications like these silently invalidate downstream analysis (the Color Trends grid, the Wormhole Teal Alert callout, the Differentiation table). When a palette correction lands, sweep the editorial copy in the same edit — a card that says "purple" while the body claims "magenta" reads as carelessness.

**Partner / studio additions (June 2026).** Four brands beyond the competitor set ship as presets and render on the same grid, tagged by relationship (category `Partner` / `Studio`, not a sector) so they read honestly as partners or own-studio. Two had placeholder palettes the canonical source contradicted — the same primary-source lesson:

- **Ledger** was a monochrome cool-grey placeholder — actually Serenity Purple (#D4A0FF) + Safety Orange (#FF5300) on an 80% B&W base, plus Success Green / Error Red, per the brand book (`brand.ledger.com/brand-design/color`).
- **Kast** was an unsourced 3× saturated mint-green — actually a single Mint accent (#1EBA98) on a Black/White/Grey base per the media kit (`kast.xyz/media-kit`); seeded as mono-hue mint at three lightnesses.
- **Rabby** was already accurate (periwinkle #8697FF → #465EED from the logo SVG); carried over, revision-bumped.
- **VilenDesign** was a fabricated magenta/violet/coral "composed" placeholder — actually a single Sky Blue accent (#4AA2EA) on a white/black canvas, read live from `vilendesign.com` via `extract-brand-colors.mjs`; seeded as mono-hue sky-blue.

**Hex → OKLCH conversion.** The composer renders OKLCH (per the `OKLCH for all color work` cardinal rule), but the official brand value is hex. Use the standard sRGB → linear → XYZ → OKLab → OKLCH pipeline (Björn Ottosson's coefficients) — `node` one-shot is fine. Both representations belong in `presets.js`:

- `palette[]` — array of `{ hex, label }` for the OFFICIAL hex codes the brand publishes. Display-only; drives the card's hex chips so designers see the verbatim brand values.
- `dark` / `light` seeds — the OKLCH triples that drive the composer. Hue parity (`h` matches across modes) is enforced per the existing palette grammar rule.

**Hue-mode adjustments.** The OFFICIAL hex's lightness rarely sits in the right window for both modes. Strategy: keep `h` constant; choose `l` based on the canonical band — primary in dark mode lands at `l: 65–80`, primary in light mode lands at `l: 45–55`. Where the official L is already in the right window for one mode, use it directly; for the other, mirror the H and shift L while keeping C in-gamut.

**Chips show only what's documented.** A brand that ships two colours (Uniswap, Socket, Axelar) gets a two-chip card. Never invent a third chip to fill the row — the card's flex layout shrinks gracefully, and the honesty is more useful to designers than a "filler" tone. The COMPOSER preset still has primary/secondary/tertiary/surface (the composer needs all four), but the card's display layer respects the published count.

**Source citation per card.** Every competitor card surfaces a `Source ↗` link in the foot row pointing to whatever primary asset yielded the palette. This isn't decorative — it's the audit trail. If a designer disputes a chip, the source link is the answer to "where did you get that?".

The card markup itself is built on the universal `.tile` primitive plus brand-card-specific parts (`.brand-card-header`, `.brand-card-chips`, `.brand-card-foot`, `.brand-card-source`). The shell (background, border, hover, active state) is shared with `.feature-card`/`.tile` everywhere else on the site, so the catalog reads as a single visual language. See `design-system/competitors.html` (markup + script) and `presets.js` (canonical data + source citations) for the wiring.

---

*Brand Style Guide v2.0 · March 2026 · For internal use. Contact design@li.finance for queries.*
