Acme Wallet
Cross-chain swap embedded directly in the wallet's send sheet. 400k MAU.
The bedrock every surface shares — colour, type, spacing, radius, elevation, surfaces, materials, and the devices they target — plus the live composers that tune the palette, materials, and semantic tokens. Press ⌘K to search the whole system.
Three accents and a 60/30/10 ratio — Ink, Sapphire, Pink. Every Product, Marketing, and Docs surface inherits these tokens.
Usage ratio — 60 / 30 / 10
Surfaces, text, borders
Action — CTAs, links, focus, data
Signature accent
Brand identity — primary trio · .swatch--label-inside
#405CCF
#F7C2FF
#0C0E2E
Semantic — only when meaningful
--success
oklch(63% 0.16 155)
#00A35D
--danger
oklch(66% 0.18 24)
#EC5B59
--warn
oklch(78% 0.17 67)
#FF9E00
--info
oklch(71% 0.15 269)
#7C9BFD
Semantic state pairs — bg / border / fg per tone
Every semantic state ships three paired tokens — --{name}-bg, --{name}-border, --{name}-fg — for alert cards, callouts, toasts, info panels. The pattern: the surface uses -bg, an optional border uses -border, emphasis text + icons use -fg. All three stay in the same hue family so a success alert reads as green end-to-end. Both modes auto-flip via :root + [data-theme="light"].
Success — the bridge route was found and quoted.
Info — this preset stays put across brand-preset switches.
Warn — the rate limit will reset in 30 seconds.
Danger — the transaction was rejected by the wallet.
--success-bg
oklch(0.20 0.05 161)
#001D0E
--success-border
oklch(0.34 0.10 161)
#004726
--success-fg
oklch(0.72 0.17 161)
#00C481
--info-bg
oklch(0.20 0.05 220)
#001B25
--info-border
oklch(0.34 0.10 220)
#00425B
--info-fg
oklch(0.72 0.16 220)
#00BAE9
--warn-bg
oklch(0.22 0.05 85)
#251800
--warn-border
oklch(0.36 0.10 85)
#553700
--warn-fg
oklch(0.78 0.16 85)
#E6AD00
--danger-bg
oklch(0.20 0.05 25)
#290B0A
--danger-border
oklch(0.34 0.12 25)
#691214
--danger-fg
oklch(0.64 0.21 25)
#F14445
/* Reference brand tokens directly. Never hard-code hex. */
background: var(--lifi-sapphire); /* primary action surfaces */
color: var(--lifi-ink); /* highlight / accent runs */
border-color: var(--lifi-pink); /* legacy LI.FI-pink hue */
/* Or use semantic accents that flip with the palette: */
background: var(--accent-primary);
color: var(--accent-secondary);
| Token | Role | Use |
|---|---|---|
| --accent-primary | Brand anchor (20%) | Primary CTAs, focus rings, active tab text, links, selected states |
| --accent-secondary | Gradient sidekick (10%) | Second chart series, brand gradients, motion accents |
| --accent-tertiary | Spot accent (5%) | Third chart series, category tags, marketing emphasis. Never on enterprise surfaces. |
| --success | Semantic — went well | Positive deltas, "route found", confirmation toasts |
| --danger | Semantic — broken or destructive | Delete, disconnect, error toasts, negative deltas, SLA breaches |
| --warn | Semantic — proceed with caution | Capacity meters, rate-limit warnings |
| --text-primary | Neutral ink — anchor | Headlines, hero numbers, primary body |
| --text-muted | Neutral ink — support | Descriptions, captions, eyebrows |
| --text-faint | Neutral ink — chrome | Axis ticks, table headers, placeholder hints |
| --surface-page | Neutral surface | Page background — always present, never noticed |
--accent-primary. Everything else defers. One blue button per page, not four.color-mix — tinted surfaces (e.g. color-mix(in oklch, var(--accent-primary) 12%, transparent)) keep the palette small while giving you 100+ practical values.--success means "something went well", --danger means "this is destructive or broken". If neither is true, don't reach for a semantic color.#6366f1 is a bug waiting to break the next palette swap. Reach for a token.color: #8b5cf6 to force a specific purple, the design system has already lost.--text-* · --icon-*Semantic tokens for prose hierarchy and chrome icons. Theme-aware via the :root / [data-theme="light"] blocks; icon tokens alias text tokens today, overridable per-theme to diverge.
Six tokens drive every grayscale colour in the system. Reach for --text-* from prose / labels / numerals; reach for --icon-* from icon-only chrome (borderless icon buttons, dropdown carets, list-row affordances, navbar utility icons). Both families flip themes via the data-theme attribute on <html> — one token, two contextual values.
Text family · prose hierarchy
var(--text-primary)
Headlines, hero numbers, primary body
var(--text-secondary)
Sub-headlines, supporting copy
var(--text-muted)
Descriptions, captions, eyebrows
var(--text-faint)
Chrome · axis ticks · placeholder hints
Icon family · chrome icons · aliases text today, overridable to diverge
var(--icon-primary) = var(--text-primary)
.btn-icon.btn-borderless
--text-primary; override per-theme to recolour all icon chrome with one edit.var(--icon-muted) = var(--text-muted)
.btn-icon.btn-borderless.btn-muted
--text-muted. Use when an icon should yield to the row's primary content — meta affordance next to a metric, secondary toolbar action.--text-* from prose, labels, numerals. Anything where the consumer is a text node — <h*>, <p>, <span>, <label>, table cells, list-item titles.--icon-* from icon-only chrome. Borderless icon buttons, dropdown carets, list-row affordances, navbar utility icons, status dots that aren't text-adjacent. Don't use --text-* in an icon-only component — if the icon family ever diverges from text, every icon consumer would need a rule update.:root block paints them for dark mode; [data-theme="light"] overrides for light. One token, two contextual values — consumers never branch on theme.:root / [data-theme="light"], not in the consumer rule. Example: --icon-primary: color-mix(in oklch, var(--text-primary) 88%, var(--accent-primary)); tints every borderless icon button with a hint of brand. The consumers automatically pick it up.color: var(--text-primary) in an icon component. Always go through --icon-*. The whole point of splitting the families is that a future divergence is a one-line change.styles.css · search for --text-primary: (canonical definition in :root, mirrored in [data-theme="light"]). The icon family sits immediately after the text family in both blocks.--icon-primary and --icon-muted for rest / muted variants. Any component with icon-only chrome should follow..btn-icon.btn-borderless consumed --text-muted directly. The default flipped to --text-primary + an opt-in .btn-muted variant; on the same pass both colours moved behind --icon-* aliases so future divergence is a token-layer edit.--spectral-1 … --spectral-12Twelve OKLCH hues extending the brand palette for charts, illustrations, decorative blocks. Stays put across preset switches.
Twelve hues at 30° OKLCH spacing — vivid but not garish, perceptually balanced for categorical data. Each cell shows the swatch, token name, named alias, and OKLCH coordinates. Hover any cell for a one-line "use it for" hint.
--spectral-1
oklch(0.72 0.20 20)
#FF8C7A
--spectral-2
oklch(0.74 0.20 50)
#FF9358
--spectral-3
oklch(0.82 0.18 80)
#FFB454
--spectral-4
oklch(0.84 0.20 110)
#C5E866
--spectral-5
oklch(0.78 0.22 140)
#5EEAA0
--spectral-6
oklch(0.76 0.18 175)
#3DD9D9
--spectral-7
oklch(0.78 0.18 205)
#42E2F5
--spectral-8
oklch(0.74 0.20 235)
#67BAFF
--spectral-9
oklch(0.70 0.22 265)
#7B86FF
--spectral-10
oklch(0.66 0.22 295)
#9B5DE5
--spectral-11
oklch(0.72 0.24 325)
#D472FA
--spectral-12
oklch(0.76 0.22 355)
#FF8FCB
Chart subsets
For charts, pick a subset that maximises consecutive-pair distance. The default 8-series mapping (used by --series-1…--series-8) goes blue → orange → green → magenta → amber → cyan → violet → coral.
--series-1…--series-8)Numeric form — for chart libraries, JS loops, programmatic indexing.
/* Bar chart with 6 categorical series */
.bar[data-series="1"] { background: var(--spectral-9); }
.bar[data-series="2"] { background: var(--spectral-2); }
.bar[data-series="3"] { background: var(--spectral-5); }
/* … or use the series tokens directly, which derive from spectrum */
.line[data-series="3"] { stroke: var(--series-3); }
Named form — for prose-readable references where the index isn't meaningful.
/* Decorative block / illustration fill */
.callout--amber {
background: color-mix(in oklch, var(--spectral-amber) 12%, transparent);
border-left: 3px solid var(--spectral-amber);
}
.badge--violet {
color: var(--spectral-violet);
background: color-mix(in oklch, var(--spectral-violet) 14%, transparent);
}
Dark mode (default)
| Token | Alias | OKLCH (L · C · H) | Notes |
|---|---|---|---|
--spectral-1 | --spectral-coral | 0.72 · 0.20 · 20 | Vivid warm red — punchy entry to the wheel |
--spectral-2 | --spectral-orange | 0.74 · 0.20 · 50 | Saturated tangerine |
--spectral-3 | --spectral-amber | 0.82 · 0.18 · 80 | Golden yellow — high L for legibility (yellow exception) |
--spectral-4 | --spectral-lime | 0.84 · 0.20 · 110 | Yellow-green — peak L of the wheel (yellow exception) |
--spectral-5 | --spectral-green | 0.78 · 0.22 · 140 | Bright leaf — vivid, distinct from --success |
--spectral-6 | --spectral-teal | 0.76 · 0.18 · 175 | Clean blue-green |
--spectral-7 | --spectral-cyan | 0.78 · 0.18 · 205 | Vivid ocean |
--spectral-8 | --spectral-sky | 0.74 · 0.20 · 235 | Punchy sky blue |
--spectral-9 | --spectral-blue | 0.70 · 0.22 · 265 | Brand-anchor blue — light mode lands on exact LI.FI Blue · default --series-1 |
--spectral-10 | --spectral-violet | 0.66 · 0.22 · 295 | Rich purple — deepest L of the dark band |
--spectral-11 | --spectral-magenta | 0.72 · 0.24 · 325 | Peak pink-purple — highest chroma in the dark band |
--spectral-12 | --spectral-pink | 0.76 · 0.22 · 355 | Punchy rose — closes the wheel |
Light mode overrides — L drops 0.16–0.26 for contrast against light surfaces; chroma holds or eases slightly (lower L gives chroma headroom before sRGB clipping). Hue identities preserved — coral is coral, blue is blue — and --spectral-9 (blue) anchors to exact LI.FI Blue in light mode (0.44 · 0.23 · 268).
| Token | Dark | Light | Δ L |
|---|---|---|---|
--spectral-1 | 0.72 · 0.20 · 20 | 0.55 · 0.21 · 20 | −0.17 |
--spectral-2 | 0.74 · 0.20 · 50 | 0.58 · 0.20 · 50 | −0.16 |
--spectral-3 | 0.82 · 0.18 · 80 | 0.66 · 0.16 · 80 | −0.16 |
--spectral-4 | 0.84 · 0.20 · 110 | 0.62 · 0.20 · 110 | −0.22 |
--spectral-5 | 0.78 · 0.22 · 140 | 0.55 · 0.20 · 140 | −0.23 |
--spectral-6 | 0.76 · 0.18 · 175 | 0.55 · 0.13 · 175 | −0.21 |
--spectral-7 | 0.78 · 0.18 · 205 | 0.55 · 0.16 · 205 | −0.23 |
--spectral-8 | 0.74 · 0.20 · 235 | 0.50 · 0.20 · 235 | −0.24 |
--spectral-9 | 0.70 · 0.22 · 265 | 0.44 · 0.23 · 268 | −0.26 |
--spectral-10 | 0.66 · 0.22 · 295 | 0.45 · 0.22 · 295 | −0.21 |
--spectral-11 | 0.72 · 0.24 · 325 | 0.50 · 0.24 · 325 | −0.22 |
--spectral-12 | 0.76 · 0.22 · 355 | 0.55 · 0.22 · 355 | −0.21 |
Token shapes — every spectral color exposes both forms simultaneously.
| Form | Pattern | When to reach for it |
|---|---|---|
| Numeric | --spectral-1 … --spectral-12 | Chart libraries, JS loops, anywhere you iterate over an index |
| Named alias | --spectral-coral, --spectral-blue, … | Prose-readable CSS where the hue identity matters more than its position |
Hue lookup — number ↔ name ↔ hue angle
| # | Name | Hue (°) | Where it sits in the wheel |
|---|---|---|---|
| 1 | coral | 20 | Warm red, slight orange shift |
| 2 | orange | 50 | True orange |
| 3 | amber | 80 | Yellow-orange, gold |
| 4 | lime | 110 | Yellow-green |
| 5 | green | 140 | True green |
| 6 | teal | 175 | Blue-green transition |
| 7 | cyan | 205 | Sky-leaning blue-green |
| 8 | sky | 235 | Light blue |
| 9 | blue | 265 | True blue |
| 10 | violet | 295 | Blue-purple |
| 11 | magenta | 325 | Pink-purple |
| 12 | pink | 355 | Soft rose, closes back to coral |
Series mapping — --series-N tokens derive from spectrum so chart colors stay stable across brand-preset switches.
| Series | Spectrum source | Why this index |
|---|---|---|
--series-1 | --spectral-9 (blue) | Default first series — high contrast against 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 |
--accent-primary / -secondary / -tertiary) carry brand identity and shift with the Theme Composer. The 12 extended hues (--spectral-1…--spectral-12) extend the palette for content where the color carries no semantic meaning — categorical data, illustration fills, decorative blocks — and stay put across preset switches. Reach for the layer that matches the intent.--spectral-5 is in the green family but --success (semantic green) lives in the Semantic tier. Don't substitute one for the other — a designer changing the success token won't update charts; a designer changing spectral-5 won't update success states.--spectral-9 + --spectral-8 for two series puts deep blue next to sky blue — the eye reads them as one variable, not two.--series-1 + --series-2 (blue + orange). Earlier iterations had series-1 = brand primary, which restyled chart data on every preset switch. Blue + orange is the universally-distinct pair regardless of active brand.--spectral-N remaps to a darker, slightly more chromatic value in [data-theme="light"] — consumers don't need to handle theme branches manually.styles.css · search Spectrum Palette for the dark-mode definitions in :root; light-mode overrides live in the [data-theme="light"] block further down. Sits next to the brand identity tokens (--accent-primary et al.) — same palette, two layers.design.md §02 → Color → Spectrum (extended brand palette). Per-hue OKLCH choices + light/dark mode philosophy.--accent-primary / --accent-secondary / --accent-tertiary (the 3 identity accents, palette-switchable). The Spectrum 12 + the identity 3 together make up the full brand palette.--success / --warn / --error / --info (semantic), --series-1…--series-8 (chart-curated subset of Spectrum), --ink-* / --surface-* (neutrals).#series-palette) for charts; available for any decorative or illustration use elsewhere via var(--spectral-N).--series-1..12) into a brand-derived ramp — the spectral primitives themselves never move.The surface used to render every colour above — the five sizes, three shape modifiers, chip-wrapped variants, and strip container — lives as its own component at Components → Swatch. Foundations consumes the primitive; it does not own it.
--w-* ladder (Spacing → Surface widths).Common laptop, tablet, and phone viewports — logical size, native resolution, DPR, and the industry breakpoint anchors.
Laptops & desktops
Tablets
Phones · one shared scale (≈0.12× actual)
<!-- One device figure. --dw/--dh = rendered px (logical × 0.12 scale);
data-logical-w = the logical CSS width the ruler reads. -->
<div class="ds-doc-device ds-doc-device--laptop ds-doc-device--notch"
data-logical-w="1280" style="--dw:154;--dh:96">
<div class="ds-doc-device__frame"></div>
<div class="ds-doc-device__label">
<span class="ds-doc-device__name">MacBook Air 13"</span>
<span class="ds-doc-device__res">1280 × 800</span>
</div>
</div>
<!-- Chrome modifiers: --monitor (stand) · --laptop (deck) +
--notch · --tablet (camera) · --phone (island) +
--home (SE) / --punch (Android). -->
Laptops & desktops
| Device | Logical viewport | Native resolution | DPR | Notes |
|---|---|---|---|---|
| QHD display | 2560 × 1440 | 2560 × 1440 | 1 | Common external monitor; a sensible layout ceiling |
| FHD display | 1920 × 1080 | 1920 × 1080 | 1 | The most common desktop monitor worldwide |
| MacBook Pro 16" | 1728 × 1117 | 3456 × 2234 | 2 | Default scaled; notch; “More Space” goes wider |
| MacBook Pro 14" | 1512 × 982 | 3024 × 1964 | 2 | Default scaled; notch; “More Space” ≈ 1800 wide |
| MacBook Air 15" | 1496 × 967 | 2880 × 1864 | 2 | Notch |
| MacBook Air 13.6" (M2/M3) | 1470 × 956 | 2560 × 1664 | 2 | Notch; non-integer scaling |
| MacBook Air 13" (M1) | 1280 × 800 | 2560 × 1600 | 2 | Practical design floor — “laptop and wider” |
Tablets
| Device | Logical viewport | Native resolution | DPR | Notes |
|---|---|---|---|---|
| iPad Pro 11" | 834 × 1194 | 1668 × 2388 | 2 | Portrait; landscape swaps the axes |
| iPad 10.9" | 820 × 1180 | 1640 × 2360 | 2 | Portrait |
Phones
| Device | Logical viewport | Native resolution | DPR | Notes |
|---|---|---|---|---|
| iPhone 16 Pro Max | 440 × 956 | 1320 × 2868 | 3 | Dynamic Island; safe-area top + bottom |
| iPhone 14/15 Plus · Pro Max | 430 × 932 | 1290 × 2796 | 3 | Notch / Island; safe-area insets |
| Pixel 8 | 412 × 915 | 1080 × 2400 | ≈2.6 | Android; punch-hole; gesture inset |
| iPhone 16 | 393 × 852 | 1179 × 2556 | 3 | Dynamic Island |
| iPhone 12–15 | 390 × 844 | 1170 × 2532 | 3 | Notch (12–14) / Island (15); safe-area |
| iPhone SE (2/3) | 375 × 667 | 750 × 1334 | 2 | Home button; no notch; smallest modern target |
| Galaxy S (S22–S24) | 360 × 800 | 1080 × 2340 | 3 | Android; punch-hole; common compact baseline |
Breakpoint anchors — industry reference (Tailwind), not LI.FI tokens
| Name | min-width | Typical target |
|---|---|---|
| base | 0–639 | Mobile-first default — phones portrait |
| sm | 640 px | Large phones landscape / small tablets |
| md | 768 px | Tablets portrait |
| lg | 1024 px | Tablets landscape / small laptops |
| xl | 1280 px | Laptops — our practical floor |
| 2xl | 1536 px | Large laptops / desktops |
| Class | Role | Notes |
|---|---|---|
| .ds-doc-device-stage | Flex-wrap row | Holds every device figure; align-items: flex-end baselines them so heights read |
| .ds-doc-device | One device figure | Column (frame + label); carries --dw/--dh + data-logical-w |
| .ds-doc-device__frame | The screen | Size = calc(--dw × 1px) by --dh; bezel = --text-faint border on --surface-sunk |
| --monitor / --laptop / --tablet / --phone | Chrome modifiers | Stand / keyboard deck / camera dot / Dynamic Island pill |
| --notch / --home / --punch | Sub-styles | MacBook notch / iPhone SE home button / Android punch-hole |
| .ds-doc-device__label | Name + resolution | Name (caption / semibold) over res (micro / mono) |
| .ds-doc-device-ruler · .slider | Viewport-width control | Composes the .slider primitive (.slider-label-row + .slider-range); fill auto-wired by window.lifiSliderFill. The input handler toggles every device's [data-fits]. |
| .ds-doc-device-ruler__meta | Live readout row | Breakpoint .chip-accent1 + a .muted fit-count line |
| .ds-doc-bp-track | Breakpoint scale | Proportional segments base→2xl; the in-range .ds-doc-bp-seg[data-active] tints accent and .ds-doc-bp-marker tracks the value |
| .ds-doc-device[role=button] | Width preset | JS-enhanced: click / Enter / Space snaps the ruler to that device's logical width. Hover / focus-visible raise the accent border + ring. |
--w-* ladder (Surface widths) tops out at 640.swap.css → “Advanced widget tier” and Widget.--w-* (Spacing → Surface widths)..ds-doc-eyebrow label and sit in their own .ds-doc-device-stage, separated by a .ds-doc-divider--loose — the same group-by-axis, divide-between-zones pattern the catalog uses elsewhere..slider primitive, so it inherits the system track / thumb / fill and focus chrome; the live breakpoint is a .chip-accent1 and the fit count a .muted line.env(safe-area-inset-*) so it clears the notch, Dynamic Island, and home indicator.CSS: styles.css — search Device frame — screen-size reference (the .ds-doc-device-* + .ds-doc-device-ruler + .ds-doc-bp-track blocks).
Composed primitives: .slider (the ruler control) · .chip-accent1 (breakpoint badge) · .muted (fit count) · .ds-doc-divider--loose (zone separators).
Ruler JS: the inline IIFE next to this section (reads [data-device-ruler], drives [data-fits] + the active .ds-doc-bp-seg, and JS-enhances every .ds-doc-device[data-logical-w] into a width preset). Track fill via window.lifiSliderFill.
Tokens: --surface-sunk / --surface-raised, --text-faint, --accent-primary / --accent-primary-mid, --space-*, --text-caption / --text-micro, --font-mono.
Spec: design.md → Foundations → Device resolutions & breakpoints.
System tie-in: swap.css → “Advanced widget tier” · Widget · Spacing → Surface widths (--w-*).
--elev-1/2/3 shadow tiers signal depth, --alpha-* overlays signal state, and the “elevation over hard borders” discipline keeps every card / row / panel reading as physical instead of utilitarian. Materials compose these tokens; components consume them directly when a full Material would be overkill.--elev-1 · --elev-2 · --elev-3Three shadow tiers — resting cards, hover state, floating surfaces. Reach for the tier, don’t hand-author shadows.
Three card-shaped surfaces, identical fill, ascending shadow tier. Tier semantics map to UI state: --elev-1 rest → --elev-2 hover → --elev-3 floating (modals, popovers, dropdowns). Don’t skip a tier; use what the state calls for.
Subtle drop + 1px white-rim highlight (dark) / gentle drop (light). The default tier for content surfaces.
Stronger drop + brighter rim. The momentary lift signals interactivity without changing fill color.
Deep drop + brightest rim. Reserved for surfaces that float over the page (modals, popovers, dropdowns).
/* Resting card */
.card {
background: var(--surface-card);
box-shadow: var(--elev-1);
border-radius: var(--space-16);
}
/* State choreography — bump the tier on interaction */
.card:hover { box-shadow: var(--elev-2); }
.modal, .popover { box-shadow: var(--elev-3); }
Three tiers — pick by state, not by aesthetic. Light/dark recipes auto-flip via the [data-theme] override.
| Tier | UI state | Dark recipe | Light recipe |
|---|---|---|---|
| --elev-1 | Resting cards, settings rows, list rows | Subtle drop + 1px inset white-rim highlight | Gentle drop, no rim |
| --elev-2 | Hover, sticky-active, tooltips | Stronger drop + brighter rim | Card-tier drop |
| --elev-3 | Modals, popovers, dropdowns | Deep drop + brightest rim | Modal-tier drop |
The dark-mode trick — a 1px inset 0 1px 0 rgba(255,255,255, 0.04–0.06) rim highlight is what sells the lift on dark surfaces, where standard drop shadows barely register. Light-mode tokens drop the rim and lean on classic Material-style drop shadows instead.
Companion filter-form tokens for masked elements. Each --elev-N ships with a --elev-N-filter sibling that approximates the same visual lift in filter: drop-shadow(…) form. Use when an element has mask-image applied (which clips box-shadow) and the elevation lift must still be visible. The inset rim-highlight layer doesn't translate to drop-shadow and is omitted from the filter form — only the outset lift layers are preserved.
Author single-layer, not chained. Chained filter: drop-shadow() calls compound — the second filter takes the first filter's output as input, blurring the already-blurred result. The visual is a smeared soft-glow that reads weaker than a multi-layer box-shadow with the same numbers (which paints each layer independently and sums cleanly). So a filter token authored as a 1:1 port of a multi-layer --elev-N recipe will self-cancel and render near-invisible. Always collapse the chain to a single drop-shadow() whose opacity sums the box-shadow layers' opacities, at the larger blur radius.
Two patterns, pick by topology. The CSS spec applies filters BEFORE masks, and mask-clip defaults to border-box — which trims the drop-shadow() output back to the masked element's box. Two ways to handle it:
Pattern A — group of masked elements (preferred). Host the filter: var(--elev-N-filter) on the unmasked PARENT; the masked children drop their own shadow. The parent's drop-shadow paints around the children's combined silhouette — cutouts at shared edges read as true holes with shadow rims, and the children keep full perimeter shadows. Canonical example: .ui-amount-pair hosts the filter, the two .ui-amount-card children carry the FAB cutouts. No mask-clip override needed.
Pattern B — standalone masked element (fallback). When the masked element stands alone with no parent to host the filter, apply filter: var(--elev-N-filter) on the element itself AND set -webkit-mask-clip: no-clip; mask-clip: no-clip; so the drop-shadow output isn't trimmed. Diagnostic if shadow still looks invisible: stress-test with filter: drop-shadow(0 4px 12px rgba(255,0,0,0.9)); no red around the card = mask-clip is still trimming.
| Filter token | Use with | Shape |
|---|---|---|
| --elev-1-filter | Masked cards / rows at rest | Value-matched twin of --elev-1. Light mirrors its two outset layers (chained); dark is single (dark --elev-1 has one outset layer + an inset rim that drop-shadow can't replicate) |
| --elev-2-filter | Masked hover state | Chained — mirrors --elev-2's two outset layers (the negative spread is dropped; filter has no spread) |
| --elev-3-filter | Masked floating surface | Chained — mirrors --elev-3; at this large blur the shadow-of-a-shadow can faintly smear (the one real chaining caveat) |
/* Pattern A — masked group, filter on the unmasked parent (preferred) */
.masked-group {
filter: var(--elev-1-filter); /* paints around combined silhouette */
}
.masked-group > .masked-child {
mask-image: radial-gradient(…);
box-shadow: none; /* mask-clip would clip outset paint */
}
/* Pattern B — standalone masked element (fallback) */
.masked-element {
mask-image: radial-gradient(…);
-webkit-mask-clip: no-clip; /* don't trim drop-shadow to border-box */
mask-clip: no-clip;
box-shadow: none;
filter: var(--elev-1-filter);
}
box-shadow: 0 4px 16px ... on a card. The tier is the contract; the recipe is implementation detail that evolves once across the system.--elev-1, hover = --elev-2, floating = --elev-3. Don’t signal interactivity by darkening a border — bump the tier instead.--elev-3 on hover overshoots; the user reads it as “floating away,” not “focused.” Use --elev-2 for hover.background: var(--surface-card) (or another --surface-* token). The shadow alone doesn’t lift; fill + shadow does.data-material="paper"), the tier is baked into the material’s shadow token (--material-paper-shadow: var(--elev-1)). Use --elev-N directly only when authoring outside the Materials API. See Materials.--elev-N-filter when the element is masked — and prefer hosting it on an unmasked parent. mask-image clips box-shadow because per spec, filters paint BEFORE masks, and mask-clip defaults to border-box — trimming outset paint to the element's box. box-shadow is the canonical elevation method; the filter form is its value-matched twin, for masked elements ONLY — never put a filter elevation on an unmasked element (that was the .ui-destination-wallet-card mistake: reverted to box-shadow, the filter token value-matched instead). The companion --elev-1-filter / --elev-2-filter / --elev-3-filter tokens value-match their --elev-N box-shadow twins via filter: drop-shadow(…), and WHERE you put the filter matters: (Pattern A, preferred) when the masked element is one of a group, host the filter on the unmasked PARENT — its drop-shadow paints around the children's combined silhouette and cutouts at shared edges read as true holes with shadow rims; the masked children just drop their own box-shadow. (Pattern B, fallback) when the masked element stands alone, apply the filter on the element AND set -webkit-mask-clip: no-clip; mask-clip: no-clip; so the drop-shadow output isn't trimmed. The inset rim-highlight layer (dark mode) doesn't have a drop-shadow equivalent and is omitted from the filter form; the outset lift layers carry the visual weight. Canonical use: .ui-amount-pair hosts the filter, the two .ui-amount-card children carry the FAB cutouts (see .ui-swap-arrow).drop-shadow() per outset layer at the same offset / blur / opacity (e.g. drop-shadow(0 1px 2px rgba(0,0,0,0.06)) drop-shadow(0 1px 1px rgba(0,0,0,0.04)) for --elev-1 light). Chained drop-shadow() does NOT self-cancel at small (1–2 px) blurs — both layers render (verified June 2026 by an isolated A/B test at 6%/4% AND amplified 40%/30%: the chained square rendered a full shadow matching box-shadow). A prior rule claimed chaining renders "near-invisible" and mandated a single summed drop-shadow() — that was wrong; the single-summed form reads SOFTER (no tight inner layer) and was caught as a visible mismatch against box-shadow cards. The only real caveat is at very large blur radii (--elev-3's 56 px), where the shadow-of-a-shadow can faintly smear. The inset rim-highlight layer has no drop-shadow equivalent and is dropped (dark mode only; imperceptible). Diagnostic when a masked card looks off: compare its computed filter against the same --elev-N in box-shadow form on an unmasked sibling.Definitions: styles.css · search --elev-1: in :root (dark) and [data-theme="light"] (light overrides).
Spec: design/components/foundations.md → Elevation over hard borders.
Cross-reference: Elevation over hard borders below for the system-wide discipline.
Four tinted overlays for state — recessed, inactive hover, active, active hover. Layered on top of any surface fill.
Surface elevation scale (alpha). Four tinted overlays meant to sit ON TOP of a surface fill, not replace it. Use for tracks, recessed bases, hovers, and active states — anywhere the same surface needs to read as a different state without changing palette.
On-alpha text scale — pair with the alpha layer beneath. Text on alpha surfaces uses different tokens than text on solid surfaces because the resolved color depends on what’s below.
/* Recessed track inside a card — alpha-subtle on the card’s fill */
.track { background: var(--alpha-subtle); }
/* Hover state — alpha-dim on the row’s fill */
.row:hover { background: var(--alpha-dim); }
/* Active pill — alpha-raised */
.pill.is-active { background: var(--alpha-raised); }
/* Active & hovered — alpha-overlay (the strongest tier) */
.pill.is-active:hover { background: var(--alpha-overlay); }
/* Text on alpha surfaces — use the on-alpha scale, not --text-primary
directly. The on-alpha tokens are tuned for legibility against
the resolved color of an alpha layer over the page bg. */
.pill-label { color: var(--on-alpha-default); }
| 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 |
| --on-alpha-default | Default text on an alpha layer | Use instead of --text-primary over alpha | |
| --on-alpha-muted | Muted text on an alpha layer | Secondary labels over alpha | |
rgba(). The alpha scale flips correctly between light and dark themes; raw rgba doesn’t. A handcoded rgba(255,255,255,0.18) looks fine in dark mode and disappears in light mode — the tokens solve that.--surface-*) is what defines the base. Set background: var(--alpha-raised) on a child element of the card, not on the card itself.--on-alpha-default / --on-alpha-muted for that case.Definitions: styles.css · search --alpha-subtle.
Spec: design.md §02 → Alpha state layers.
System-wide rule — soft elevation over hard luminance steps. The Paper material systematizes this recipe; reach for the manual three-mechanic version below only when authoring outside the Materials API.
Same content rendered two ways. Left: a hard 1px hairline on a flat fill — reads as utilitarian, “form-control,” spreadsheet-cell. Right: raised fill + --elev-1 + optional 6% hairline — reads as physical, premium, lifted off the page. The right one is the rule. The Paper material bundles this exact recipe; this card documents the manual version for the rare case you author outside Materials.
3 routes · best price via Stargate · ~12s
3 routes · best price via Stargate · ~12s
/* PREFERRED — Paper material bundles this recipe. */
.my-card { background: var(--mat-bg); box-shadow: var(--mat-shadow); border: var(--mat-border); }
/* On the element: data-material="paper" (or rely on a :where() default).
See #material-paper for the full Materials API. */
/* MANUAL — use only when authoring outside the Materials API. */
.card {
/* 1. Fill one step above the parent surface */
background: var(--surface-card);
/* 2. Shadow at one of three elevation tiers */
box-shadow: var(--elev-1);
/* 3. Optional 3-6% hairline — just enough to crisp the corner */
border: 1px solid color-mix(in oklch, currentColor 6%, transparent);
}
Three coordinated mechanics. Cards read as “lifted off the page” through these working together — not through one opaque outline.
| # | Mechanic | Token | Role |
|---|---|---|---|
| 1 | Fill one step above parent | --surface-raised / --surface-card | Lightness step that creates the visual lift |
| 2 | Box-shadow elevation tier | --elev-1 / --elev-2 / --elev-3 | Soft drop + (dark) inset rim highlight |
| 3 | Optional hairline | 1px solid 3-6% currentColor | Crisps the corner radius. Often omitted. |
data-material="paper" + --mat-*) bundles this recipe correctly — fill, shadow, border, hover-shadow choreography all flow together. Manual authoring is for the rare case Materials don’t fit (a one-off prototype, a non-component primitive, or a deliberate departure from the system).--elev-1, hover = --elev-2, sticky-active = --elev-2 + accent halo, modal/popover = --elev-3. The shadow rises with the user’s intent. Materials carry hover via --mat-shadow-hover; manual authoring writes the tier into :hover directly..input, .select, .textarea keep their explicit borders — the form-control convention is universal and users expect a border-as-input-edge mental model. Segmented controls keep their pill track. Badges and chips keep their tinted fills without elevation. The rule is for container surfaces (cards, rows, tiles, modals), not inline form chrome.Recommended path: reach for the Paper material — data-material="paper" + --mat-* consumption.
Manual recipe: the three-mechanic version above. Use only outside the Materials API.
Spec: design/components/foundations.md → Elevation over hard borders — the canonical rule, with bug history.
Tokens: --surface-* in Surfaces; --elev-1 / 2 / 3 in Elevation tiers above.
paper to glass by changing one attribute. Four canonical materials (paper · glass · gradient · halo); the Material Composer tunes their values without authoring CSS. Spec: design.md §02 → Materials.bg field doesn't bake an oklch() literal — it references a surface token (paper.bg = var(--surface-card), glass.bg = var(--surface-panel-glass)). Components consume --mat-* and never reach into --material-*-* directly. Three layers, each owns one concern.data-materialThree layers — definitions, resolved API, consumers. Components read --mat-*; data-material on the element picks which material is active.
Four demos — the full canonical set. Same markup, same dimensions — only data-material differs. The fill, border, shadow, backdrop, and edge highlight all flow from the chosen material. Designers compare materials at a glance; engineers swap them with one attribute change.
Default. Opaque fill, soft drop, 1px hairline.
Translucent + backdrop-blur. Floating panels and marketing tiles.
Brand fill (blue → pink). Hero KPIs, cover tiles.
Accent wash + radial glow. Brand-identity tiles.
/* Materials are values; components read them through --mat-*.
Apply by setting `data-material` on the element. */
<!-- Default (paper) — no attribute needed -->
<div class="card">…</div>
<!-- Switch to glass for a floating panel -->
<div class="card" data-material="glass">…</div>
<!-- Halo — soft accent wash + radial glow; pick accent via --accent-tint -->
<div class="card" data-material="halo"
style="--accent-tint: var(--lifi-sapphire);">…</div>
Components consume the resolved API:
/* In the component CSS — reference --mat-*, never --material-*-* */
.card {
background: var(--mat-bg);
border: var(--mat-border);
box-shadow: var(--mat-shadow), var(--mat-highlight);
backdrop-filter: var(--mat-backdrop);
-webkit-backdrop-filter: var(--mat-backdrop);
border-radius: var(--space-16); /* shape — owned by the component */
}
The resolved API — five tokens. Components consume these; never reference --material-*-* directly inside a component rule.
| Token | Role | Default value |
|---|---|---|
| --mat-bg | Surface fill — solid, translucent, or gradient | var(--material-paper-bg) |
| --mat-border | Border shorthand — width, style, color in one token | var(--material-paper-border) |
| --mat-shadow | Box-shadow — drop shadow, ground contact, ambient | var(--material-paper-shadow) |
| --mat-backdrop | backdrop-filter — blur + saturate, only for glass | none |
| --mat-highlight | Inset edge highlight — fakes a specular rim on dark surfaces | 0 0 0 0 transparent |
The four named materials. Each material's full character is defined as a bundle of --material-<name>-{bg,border,shadow,backdrop,highlight} tokens in :root. May 2026 — raised, sunk, tinted, and frosted retired; frosted's role merged into glass.
| Material | Fill | Use for |
|---|---|---|
| paper | --surface-card opaque + 1px hairline | Stat-cards, feature-cards, action-cards — the default |
| glass | --surface-panel-glass + backdrop-blur | FAB sub-panels, side rails, dropdowns, marketing tiles |
| gradient | linear-gradient(135deg, --lifi-sapphire, --lifi-ink) | Hero KPIs, marketing accent surfaces, cover tiles |
| halo | radial accent glow + 3% accent wash on --surface-card | Brand-identity tiles, partner showcases, ecosystem cards |
Three layers — top to bottom. Each layer is authored once; the layer below references the layer above.
| Layer | Tokens | Lives in | Authored by |
|---|---|---|---|
| Definitions | --material-<name>-{bg,border,shadow,backdrop,highlight} | :root in styles.css | DS contributor when adding a material |
| Resolved API | --mat-{bg,border,shadow,backdrop,highlight} | :root default + [data-material] selectors | The DS framework — once |
| Consumers | background: var(--mat-bg); … | Component CSS in styles.css / swap.css / etc. | Component author |
Why three layers and not one. Without the resolved layer, components would reference material definitions directly (background: var(--material-paper-bg)) — and there'd be no override path. With the resolved layer, the component is material-agnostic; the active material is set via data-material on the element, and the component re-reads --mat-* through CSS variable cascade. Same component renders as paper, glass, or tinted depending on one attribute.
--mat-*, never --material-*-*. The point of the resolved layer is that components stay agnostic. A rule like .stat-card { background: var(--material-paper-bg); } skips the override mechanism and bakes paper in — that's drift.--surface-*, --lifi-*, --accent-*, --elev-* — never bake an oklch() / hex / rgba literal inside a --material-*-* token. The LI.FI 1.0 FOUC bug pattern repeats at this layer if a contributor copies a literal in.:where(<class>) { --mat-bg: …; } block (specificity 0 — data-material overrides). A component that picks per-instance leaves --mat-* at the :root default and sets data-material in the markup.[data-theme="light"] override for the resolved layer. Light/dark flows through the upstream tokens already (--surface-*, --elev-*, --shadow-panel-glass, --lifi-*). If a material reads wrong in one theme, fix it at the definition site or the upstream token.:root and the matching [data-material="<name>"] selector get added in the same edit, plus a row in the matrix in design.md §02 → Materials, plus a new .ds-doc-card in this section.The five layers a card composes — page, shadow, fill, edge, content. How surfaces, elevation, and materials stack.
The same card-shape rendered with each of its layers pulled apart vertically. Designers get a single image that explains why four different foundations — Elevation, Surfaces, Materials, Typography — coexist: each one owns a different layer in the stack. The diagram showcases the glass material — translucent fill plate over a brand-gradient viewport, with the backdrop blur softening the gradient and shadow beneath. Numbered callouts (01–04) sit on the iso plane and reference the four foundation cards below.
--elev-1 rest · --elev-2 hover · --elev-3 floating.
02
Surface
The fill of any plate — the color or character a Material consumes. Glass uses --surface-panel-glass; opaque cards use --surface-card; recessed wells use --surface-sunk.
03
Material
A card’s surface character — opaque, translucent, gradient, or accent-tinted — plus edge styling and (for glass) backdrop filter. The diagram above showcases glass. Other recipes: paper · gradient · halo.
04
Typography
Text and glyphs that sit on top. Color from --text-primary · --text-secondary · --text-muted; sized via the type scale; weight from the role.
A glass card declaration in CSS. Each property maps to one of the four numbered layers in the diagram — the comments below line up with the badges on the iso plane.
<div class="card" data-material="glass">…</div>
.card {
/* 01 · Drop shadow — Elevation owns this; the material just picks the tier */
box-shadow: var(--mat-shadow), var(--mat-highlight); /* glass → var(--elev-2) */
/* 02 · Surface fill — from Surfaces; Material picks the character */
background: var(--mat-bg); /* glass → var(--surface-panel-glass) */
backdrop-filter: var(--mat-backdrop); /* glass → blur + saturate */
/* 03 · Edge — hairline + inset rim, owned by Material */
border: var(--mat-border);
/* Shape — owned by the component, not by the layer stack */
border-radius: var(--space-16);
padding: var(--space-20); /* 04 · Content gets a frame */
}
Swap data-material="glass" for "paper" to get an opaque card — same four layers, same numbered ownership, just a different Material recipe picking the surface character. The diagram’s gradient viewport is what makes the glass effect legible: with nothing to refract, glass would look identical to paper.
Four layers, four foundations. Each layer is owned by exactly one foundation — the diagram exists to make the ownership visible. Material owns the edge styling and the surface character (opaque, translucent, gradient, or accent-tinted), but never the shadow — that always belongs to Elevation.
| # | Layer | Foundation | Token (resolved API) | Real-world role |
|---|---|---|---|---|
| 04 | Content | Typography | color: var(--text-primary) | Text and glyphs the user reads. |
| 03 | Edge | Material | --mat-border · --mat-highlight | Crisps the corner; the rim sells lift in dark mode. |
| 02 | Surface fill | Surface | --mat-bg → --surface-panel-glass | Translucent in glass; opaque in paper. Material picks the character. |
| 01 | Drop shadow | Elevation | --elev-1 / --elev-2 / --elev-3 | State choreography — rest, hover, floating. |
The fifth layer — backdrop, only on glass. When a card sets data-material="glass", a backdrop-filter: blur(...) sits between layer 02 (surface fill) and the page bg behind the card, pulling the page color through the translucent surface. The diagram drops the page-bg plate and the backdrop layer to keep the paper baseline clean — the glass-specific stack is described in the Glass material card at #material-glass.
What each plate is rendered with. Every plate that has a real DS counterpart pulls from a token — the fill plate is --surface-card, the edge plate uses the same hairline + rim recipe a real card carries. A palette switch propagates through the diagram on the next paint.
| Plate | Rendered with | Source of truth |
|---|---|---|
| 01 · Drop shadow | oklch(0% 0 0 / 0.55) · filter: blur(24px) | Synthetic. Real --elev-1 is too subtle to read as a discrete plate at this scale — see Rules. |
| 02 · Surface fill (Glass) | background: var(--surface-panel-glass) · backdrop-filter: blur(20px) saturate(1.4) | Surface token + Glass material — #surfaces · #material-glass |
| 03 · Edge | border + inset 0 1px 0 rim | Material recipe — paper.border + paper.highlight at #material-paper |
| 04 · Content | color: var(--text-primary) | Typography — #typography |
Plate geometry. Each plate is 280×180 px and spaced 100 px apart on the Z axis. The iconic isometric pose is rotateX(58deg) rotateZ(-32deg), applied once on the parent stack with transform-style: preserve-3d. Number badges are children of the same stack so they inherit the iso transform automatically — their digit text is counter-rotated (rotateZ(32deg) rotateX(-58deg)) so it stays upright while the chip body sits on the plate plane.
.card { background: var(--elev-1); } is nonsense; an elevation tier is a shadow recipe, not a fill.paper / glass / gradient / halo) defines how the surface fill renders — opaque, translucent, gradient, or accent-tinted — plus the edge styling and (for glass) backdrop filter. The drop shadow itself is owned by Elevation; the Material recipe just picks which --elev-* tier composes with it. Components consume the resolved --mat-* tokens. See Materials system.--elev-1 / --elev-2 / --elev-3 are tuned to be quiet against the card — they’d be invisible as a stand-alone floating plate at this distance. The diagram exaggerates the shadow into a discrete plate to teach the layer; production cards use the real --elev-* tokens.--alpha-* token painted on a CHILD inside layer 05 — not a new sibling layer to the card. State layers stack inside content; they don’t insert between fill and edge. See Alpha state layers..ds-doc-iso-card only inside the catalog when documenting a related concept (a new material’s anatomy, a tile-vs-card comparison, the swap-widget’s stack). Never on a product or marketing surface.Primitive: styles.css · search .ds-doc-iso-card.
Cross-references: Surfaces (layers 01 + 03) · Elevation (layer 02) · Materials (layer 04 + the bundle) · Typography (layer 05).
Spec: design.md §02 → Materials — the layer-ownership rule lives in the Materials section because Materials is the bundling concept that ties the other foundations together.
data-material="paper"Opaque fill, soft drop, 1px hairline. The default for stat-cards, feature-cards, action-cards, action containers.
Card-shape and tile-shape rendered in paper. Quiet drop shadow, hairline border, neutral fill — the visual default for content surfaces. Light and dark themes flip via the upstream --surface-card and --elev-1 tokens.
Cross-chain settlement across 30+ networks
<!-- paper is the default; the attribute is optional -->
<div class="card" data-material="paper">
<div class="mono">24h volume</div>
<div>$1.24B</div>
</div>
| Token | Value |
|---|---|
| --material-paper-bg | var(--surface-card) |
| --material-paper-border | 1px solid color-mix(in oklch, var(--text-primary) 6%, transparent) |
| --material-paper-shadow | var(--elev-1) |
| --material-paper-backdrop | none |
| --material-paper-highlight | 0 0 0 0 transparent |
raised (deeper drop) or glass (translucent + blur). Paper sits AT the page level; floating sits ABOVE.sunk.Definitions: styles.css · search --material-paper-bg.
Selector: [data-material="paper"] in styles.css after the [data-theme="light"] block.
Spec: design.md §02 → Materials.
data-material="glass"Translucent fill + backdrop-blur + neutral drop. Aliases the canonical Panel Surfaces 3-token recipe — same surface character.
Glass is FAB sub-panels, side rails, dropdowns — anything that floats over content and frosts the page behind. Composes --surface-panel-glass + --shadow-panel-glass + --backdrop-panel-glass, the canonical Panel Surfaces recipe. Visual diff vs. the pre-Materials implementation: zero.
Connected · block 52,830,491
<!-- glass — for floating panels, rails, dropdowns -->
<div class="ds-doc-menu-panel" data-material="glass">
<div>Network</div>
<div>Polygon</div>
</div>
| Token | Value |
|---|---|
| --material-glass-bg | var(--surface-panel-glass) |
| --material-glass-border | 0 solid transparent (no border) |
| --material-glass-shadow | var(--shadow-panel-glass) — inset highlight baked in |
| --material-glass-backdrop | var(--backdrop-panel-glass) = blur(40px) saturate(140%) |
| --material-glass-highlight | 0 0 0 0 transparent (highlight is in --shadow-panel-glass) |
color-mix(--accent-primary 18%) in its light-mode shadow and read as a different surface from the playground rail. Neutral black-only is the contract — the glass fill carries any palette connection on its own.backdrop-filter is GPU-expensive — twenty glass cards on a page hitch. Keep glass for floating chrome, not as a card surface.Definitions: styles.css · search --material-glass-bg.
Underlying recipe (3 tokens): --surface-panel-glass, --shadow-panel-glass, --backdrop-panel-glass — defined in styles.css :root, exposed only through this material.
Consumers: .ds-doc-menu-panel, .cmd-palette__panel, .navbar::before. (.dropdown__panel moved to Paper material in May 2026.)
Spec: design.md §02 → Materials + §02 → Panel Surfaces.
data-material="gradient"Brand gradient fill + minimal chrome + white text. Hero KPIs, marketing accent surfaces, cover tiles where the gradient is the subject.
Gradient is the loudest material — the LI.FI brand gradient at 135° from --lifi-sapphire to --lifi-ink. Use for ONE hero KPI, ONE cover tile, ONE call-to-action surface. Text flips to white automatically (the brand gradient is too saturated for primary-text contrast).
Total value secured · 30+ chains
<!-- gradient — for hero KPIs and one-off accent surfaces -->
<div class="card" data-material="gradient">
<div>Cross-chain TVL</div>
<div>$8.42B</div>
</div>
| Token | Value |
|---|---|
| --material-gradient-bg | linear-gradient(135deg, var(--lifi-sapphire), var(--lifi-ink)) |
| --material-gradient-border | 1px solid color-mix(in oklch, white 12%, transparent) |
| --material-gradient-shadow | var(--elev-2) |
| --material-gradient-backdrop | none |
| --material-gradient-highlight | inset 0 1px 0 rgba(255,255,255,0.18) |
--text-primary.Definitions: styles.css · search --material-gradient-bg.
Brand gradient tokens: --lifi-sapphire, --lifi-ink in :root.
Spec: design.md §02 → Materials.
data-material="halo"Soft accent wash + radial glow at top-right. Promoted from .brand-card — brand-identity tiles, premium feature cards, “about this product” showcases.
Halo reads as a quietly brand-forward card. The base is paper-tier (3 % accent over --surface-card), and a radial gradient anchored at the top-right adds a 12 % accent halo that fades by 70 %. Distinct from tinted (uniform wash, accent-louder) and gradient (full brand fill, hero-status). Shown with three accents to demonstrate the parametric tint.
--lifi-ink — the system default
Custom tint · --lifi-sapphire
Custom tint · --lifi-pink
<!-- Default tint (--lifi-ink) -->
<div class="card" data-material="halo">…</div>
<!-- Override tint per element -->
<div class="card" data-material="halo"
style="--accent-tint: var(--lifi-sapphire);">…</div>
<!-- The .brand-card class wears halo by default via :where() -->
<article class="tile brand-card"
style="--accent-tint: var(--lifi-sapphire);">…</article>
| Token | Value |
|---|---|
| --accent-tint | var(--lifi-ink) default · override per-element |
| --material-halo-bg | radial-gradient(ellipse 85% 65% at 100% 0%, color-mix(--accent-tint 12%, transparent) 0%, transparent 70%), color-mix(--accent-tint 3%, --surface-card) |
| --material-halo-border | 1px solid color-mix(in oklch, var(--text-primary) 6%, transparent) |
| --material-halo-shadow | var(--elev-1) |
| --material-halo-backdrop | none |
| --material-halo-highlight | 0 0 0 0 transparent |
Composer-tunable. Material Composer exposes the tint slider (default 12 %) for the gradient peak and the shadow tier slider. Opacity / blur / highlight are not applicable for halo.
gradient.--lifi-sapphire / --lifi-ink / --lifi-pink / --accent-primary, not raw hex. The LI.FI 1.0 default rule applies — never bake a literal palette value.Definitions: styles.css · search --material-halo-bg.
Composer integration: shared.js → initMaterialComposer() — halo is in MATERIALS, DEFAULTS, APPLICABLE, and applyMaterialTokens.
Default consumer: .brand-card via :where(.brand-card) in styles.css. Spec: design.md §02 → Materials.
data-material="halo" + --tile-accent-* + .is-activeThe full visual recipe behind .brand-card — halo material, per-tile dual-mode accent, and active-state glow — available on any tile.
Three example tiles — partner showcase, integration directory, ecosystem spotlight — all wearing the same recipe with their own accent. The middle one carries .is-active to show the sticky-selected glow; in production, a click handler toggles that class.
Cross-chain swap embedded directly in the wallet's send sheet. 400k MAU.
Now live — native USDC, JUP, and BONK route through the LI.FI router.
RWA settlement layer — tokenised treasuries on six chains, one quote.
Single value across both themes — simplest form. Set --tile-accent directly when the same colour reads well in light and dark.
<article class="tile" data-material="halo"
style="--tile-accent: var(--lifi-sapphire);">
<div class="eyebrow-word">Partner</div>
<h3 class="tile-title">Acme Wallet</h3>
<p class="tile-body">…</p>
</article>
Dual-mode — recommended for branded tiles where the partner / integration / brand has tuned values per theme. Light values stay readable on a near-white surface; dark values gain the lift needed for a dark canvas.
<article class="tile" data-material="halo"
style="--tile-accent-light: oklch(50% 0.18 250);
--tile-accent-dark: oklch(72% 0.16 250);">
…
</article>
Active state — the standard sticky-selected affordance. Toggle .is-active on the article (typically via JS click handler) and the tile lights up in its own accent: bg shifts to an 8 % accent wash, border becomes the full accent, and a soft 18 px glow drops below.
<article class="tile is-active" data-material="halo"
style="--tile-accent: var(--lifi-sapphire);">…</article>
| Layer | What it does | Token / hook |
|---|---|---|
| Surface material | Radial halo at top-right + faint accent wash on base | data-material="halo" → resolves --mat-bg from --material-halo-bg |
| Per-tile accent | Sets the colour the tile reads as — tints halo, paints active glow | --tile-accent (single) or --tile-accent-light + --tile-accent-dark (dual-mode) |
| Material bridge | Feeds the halo recipe with the per-tile colour automatically | .tile rule wires --accent-tint: var(--tile-accent) |
| Hover | 6 % accent wash + bumps elevation to --mat-shadow-hover | .tile:hover rule (built-in) |
| Active | Sticky-selected: 8 % accent bg, full-strength accent border, 18 px accent glow | .tile.is-active rule (built-in) |
paper.--tile-accent is enough. A deep brand value that disappears on dark, or a vivid value that blares on light, needs dual-mode tuning..is-active is a class you toggle via JS to mark a sustained selection (selected theme, active filter, currently-applied preset). Distinct from :active (the mouse-down momentary state). Don't use .is-active for hover..is-active from siblings before adding it to the click target.--mat-bg directly. The material recipe is parametric via --accent-tint — the .tile rule already wires that to --tile-accent. Setting --mat-bg by hand bypasses the system and breaks the composer..brand-card's preview button), pair the --tile-accent-* colours with a --text-on-accent-* companion (near-black for bright accents, white for deep ones). The brand-card's --brand-c-on is the canonical pattern.Token wiring: styles.css · search --tile-accent-light for the dual-mode contract on .tile; :where(.brand-card) for halo as default on brand cards.
Active state: .tile.is-active rule in styles.css (search .tile.is-active,).
Live consumer: design-system/competitors.html#brands — the brand-card grid composed from this recipe (the .brand-card wrapper provides the markup contract + chip / source / active-button details).
Halo material itself: documented at #material-halo above.
--space-* scale every padding / gap / radius pulls from. Sizing ladders sits on top — the sm / default / lg / xl tier rule that every multi-size component graduates through, derived entirely from the token grid. Read tokens first, ladders second.4px grid foundation used everywhere. Tokens --space-1 through --space-128 map to multiples of 4px.
Geometry on grid. Every component dimension — width, height, padding, margin, gap, border-radius — must resolve to a token below. The 4px grid is the law: if a value isn't a multiple of 4 (excluding the 1px / 2px hairline exceptions), it's a bug to fix at the source, not a one-off to tolerate. Components scale predictably across themes, sizes, and viewports only when they share this baseline.
/* Reach for --space-N tokens; never use raw px outside the tokens themselves. */
padding: var(--space-24); /* 24 px — comfortable card padding */
gap: var(--space-16); /* 16 px — flex/grid item gap */
margin-bottom: var(--space-32); /* 32 px — section break */
/* Cluster tokens for closer-grouped items: */
gap: var(--cluster-sm); /* 6 px */
gap: var(--cluster-md); /* 10 px */
| Token | Value | Use |
|---|---|---|
| --space-2 | 2px | Hairlines, dividers, tight icon gaps (border-width only) |
| --space-4 | 4px | Inline element gaps, micro-padding |
| --space-8 | 8px | Compact padding, small chip gap, icon-text rhythm |
| --space-12 | 12px | Card internal gap, dense form rows, button radii |
| --space-16 | 16px | Standard card padding base, button-lg radii |
| --space-20 | 20px | Form group margin-bottom, button-md horizontal padding |
| --space-24 | 24px | Card padding, section padding (small), icon-only btn-xs |
| --space-28 | 28px | Fills the 24↔32 gap — chips, dense touch targets |
| --space-32 | 32px | Card padding (medium), button row gap, icon-only btn-sm |
| --space-36 | 36px | Fills 32↔40 — secondary controls, compact rows |
| --space-40 | 40px | Standard control height (input, btn-md, icon-only btn) |
| --space-44 | 44px | WCAG min touch target, fills 40↔48 |
| --space-48 | 48px | Section padding (medium), btn-lg horizontal padding |
| --space-52 | 52px | Fills 48↔56 — generous control surfaces, icon-only btn-lg |
| --space-56 | 56px | Tile icon container, btn-lg + chrome |
| --space-64 | 64px | Section padding (large) |
| --space-72 | 72px | Fills 64↔80 — token row / dense .list-item--lg heights |
| --space-80 | 80px | Section padding (default) |
| --space-96 | 96px | Hero padding, page top spacing |
| --space-120 | 120px | Hero padding (large) |
| --space-160 | 160px | Hero padding (full-page) |
1px and 2px — only inside border: / border-*: / outline: declarations (hairlines, focus rings). Everywhere else, use a --space-* token.--space-* if the size is intentional, recurring, and on the 4px grid (e.g. a real WCAG 44px touch target). Ad-hoc literals are not the answer.--w-*Seventeen max-widths for floating containers — popovers, menus, modals, panels. Ordinal +32px ladder; --w-N+1 is the next tier.
Seventeen tokens, ordinal naming, uniform +32px step. --w-N+1 is always the next tier up — no skips, no exceptions. Names are deliberately abstract: --w-7 doesn't bake-in "panel" or "rail", so the same tier can host a widget canvas today and a wallet drawer tomorrow without renaming anything. Reserved tiers are claimable for new surfaces; reach for the nearest tier and never write a literal width.
/* Reach for the nearest tier; never write a literal width
for a panel / modal / menu / palette. */
.modal--sm { max-width: var(--w-6); } /* 384px */
.modal { max-width: var(--w-9); } /* 480px */
.modal--lg { max-width: var(--w-12); } /* 576px */
.cmd-palette__panel { max-width: var(--w-10); } /* 512px */
.ui-card { max-width: var(--w-7); } /* 416px */
.ds-doc-menu-panel { width: var(--w-3); } /* 288px */
/* Width tokens compose with --space-* tokens for chrome — the
width-token sets the surface contract, --space-* sets internals. */
.modal { padding: var(--space-24); border-radius: var(--space-16); }
| Token | Value | Suggested surfaces | Currently used by |
|---|---|---|---|
| --w-1 | 224px | Tightest tooltip, pill popover | — available |
| --w-2 | 256px | Compact info popover, mini-menu | — available |
| --w-3 | 288px | Dropdown menus, single-column pickers, FAB sub-panel | .ds-doc-menu-panel |
| --w-4 | 320px | Narrow popover, empty-state text cap | .empty-state__title / __message |
| --w-5 | 352px | Reserved tier — claim for a new surface type | — reserved |
| --w-6 | 384px | Small modal (confirm), compact rail, swap rail | .modal--sm, swap rail (--rail-width) |
| --w-7 | 416px | Widget canvas, content panel, side rail | .ui-card (swap canvas) |
| --w-8 | 448px | Reserved tier — claim for a new surface type | — reserved |
| --w-9 | 480px | Default modal — settings, forms, content modals | .modal |
| --w-10 | 512px | Command palette overlay, wide popover, mid-density modal | .cmd-palette__panel |
| --w-11 | 544px | Reserved tier — claim for a new surface type | — reserved |
| --w-12 | 576px | Large modal, workshop drawer, side sheet | .modal--lg |
| --w-13 | 608px | Reserved tier — claim for a new surface type | — reserved |
| --w-14 | 640px | Reserved tier — claim for a wide panel | — reserved |
| --w-15 | 672px | Reserved tier — claim for a wide panel | — reserved |
| --w-16 | 704px | Advanced-tier Limit receive panel (chart + 7-col Orders) | Limit receive panel |
| --w-17 | 736px | Reserved tier — claim for a wide panel | — reserved |
var(--w-7), not 410px. The token reads as the surface's tier and a future tweak happens in one place. Same rule as the --space-* family — the literal value is implementation detail.--w-N+1 is the next tier up, always. No skips, no exceptions. The +32px step is uniform across the ladder, so "give me one bigger" is a pure increment without checking a table.--w-7 is the swap canvas today; tomorrow it's also a wallet-picker drawer or a quote-detail sheet. Don't rename a tier when a new surface lands on it; just use it.max-width on the element with a code-comment explaining why. The --w-* ladder is for shared, repeating surfaces — workspace-class one-offs intentionally live outside it.--w-12 (576px) and shorten the body" or "this is a bespoke workspace, not a modal." The seventeen tokens are load-bearing; ad-hoc additions dilute the contract. Ask before adding a new --w-* token.--space-*. The width-token sets the container; --space-* sets internal padding, gap, and radius. Both must be tokenized — mixing a tokenized width with a literal padding (or vice versa) re-creates the drift the system is designed to prevent.width: 100% + max-width: var(--w-*). Lets the surface shrink on narrow viewports without a media query, while still capping at the canonical width on desktop.sm · default · lg · xlFive lockstep ladders (height, label, padX, padY, radius) define each tier — every value a token. Canonical reference: #btn-sizes.
Each tier locks together a height, a label size, a horizontal padding, a vertical padding, and a corner radius. Buttons are the canonical reference today; as more components join the ladder (segs, inputs, switches, cards), they'll line up in matching rows.
sm · 32px · dense rails, data tables, toolbar chromedefault · 40px · forms, primary CTAs, comfortable rowslg · 48px · heroes, marketing CTAs, top-of-page rowsxl · 56px · banner / poster CTAs, statement compositions/* Every multi-size component should expose the same 4-tier modifier set */
.btn-sm /* sm — 32px row */
.btn /* default — 40px row */
.btn-lg /* lg — 48px row */
.btn-xl /* xl — 56px row */
/* Match neighbours in a row — same tier across siblings */
<div class="row">
<input class="input--lg">
<button class="btn-primary btn-lg">Submit</button>
</div>
Tier vocabulary. Four named tiers; semantic role per tier.
| Tier | Height | Role | Reach for it when… |
|---|---|---|---|
| sm | --space-32 (32px) | Dense / chrome | Data tables, toolbars, sidebar action rails, dashboard chrome. |
| default | --space-40 (40px) | Comfortable | Forms, primary CTAs, modal footers, settings rows. |
| lg | --space-48 (48px) | Hero | Marketing rows, hero CTAs, top-of-page actions. |
| xl | --space-56 (56px) | Statement | Banner / poster CTAs, deck-scale compositions. |
Lockstep ladders. Five token ladders that scale together per tier. Buttons demonstrate; other components map their own padX / padY when joining.
| Ladder | sm | default | lg | xl | Step |
|---|---|---|---|---|---|
| Height | --space-32 | --space-40 | --space-48 | --space-56 | +8 |
| Label | --text-caption (12) | --text-sm (14) | --text-body (16) | --text-body-lg (18) | +2 (typography) |
| Radius | --space-8 | --space-12 | --space-16 | --space-20 | +4 |
| Pad X (button) | --space-20 | --space-24 | --space-32 | --space-40 | component-specific |
| Pad Y (button) | --space-8 | --space-12 | --space-12 | --space-16 | component-specific |
Math contract. A pill-style component lands on its target tier height when:
/* Pill components with line-height: 1 + 1px transparent border */
height = padY × 2 + line-box + 2px border
/* If the label is bigger than padY math allows, widen the line-box
via calc-based line-height. Buttons use this trick on lg / xl. */
.btn-lg { font-size: 16px; line-height: calc(22 / 16); /* line-box → 22 */ }
Application matrix. Where the ladder is implemented today.
| Component | sm | default | lg | xl | Status |
|---|---|---|---|---|---|
.btn-* | 32 | 40 | 48 | 56 | ✓ canonical |
.seg-* | 32 | 40 | 48 | 56 | ✓ aligned (Apr 2026) |
.input--* | 32 | 40 | ~50 (drift) | missing | partial — lg drifts, no xl |
.switch-* | — | — | — | — | row-height aligned, distinct ladder (width × height) |
.card--* | — | — | — | — | separate --sm / --lg family with own padding scale |
Radius token-binding — the playground per-theme corner channel. Beyond the height ladder above, every multi-size primitive should derive its radius via calc() off a customization token so the playground per-theme Corner sliders scale it in lockstep. Button-shaped primitives bind to --button-radius: .btn-*, .seg-* (wrapper + item + the .seg-menu split picker), .search-trigger, .btn-icon. Card-shaped primitives bind to --card-radius: .alert / .notification / .setting-row and the card family. Still literal — pending: .input--* and .field-inline--* use literal --space-* radii and don't yet track the sliders — they're value-drifting (--sm / --lg sit off the 8 / 12 / 16 / 20 ladder), so they need a value-normalization pass (snap to ladder, add --xl) before binding. The trap to watch: a literal that happens to match the ladder by value (e.g. var(--space-16)) reads correct at the 16px default but silently ignores the slider — .seg-menu and .search-trigger were both rebound from exactly that drift (June 2026).
.input--lg sits next to a .btn-lg. Mixing tiers inside a single row breaks visual rhythm — the row reads as "the small one is wrong" rather than "two sizes."--space-* token (4n only). The four heights — 32 / 40 / 48 / 56 — emerge from padY × 2 + line-box + 2px border. If the math doesn't land cleanly, widen the line-box via calc-based line-height rather than inventing a non-token value..fab, .callout, .modal, .search, the auto-TOC chip strip. They live at one canonical size only.--gap-card-*Five-tier ladder (xs / sm / md / lg / xl) for the gap between card-shaped siblings. Distinct from --cluster-* (inline gaps) and --stack-* (content rhythm). The ladder mapping is swappable — same model as the Theme Composer. May 2026.
One family, five tiers, swappable mapping. The 5 tier tokens (--gap-card-xs through --gap-card-xl) describe POSITION on the ladder — not the use case. Use-case-to-tier assignment is documentation; the tokens themselves are a clean numeric ladder. This is what makes the mapping swappable: a future "card gap composer" can install a Compact or Spacious preset by overriding the 5 tokens at :root, and every consumer flips automatically via cascade — same mechanism the Theme Composer uses to swap brand colours.
Convenience alias. --gap-card (no modifier) resolves to --gap-card-md. Use the explicit -md name in component CSS for clarity; the bare alias is for inline overrides where brevity helps (style="--card-gap: var(--gap-card);").
--gap-card-xs · 4 px · ultra-tight (reserved — no current consumer)
--gap-card-sm · 8 px · compact list (rail cards, dense menus)
--gap-card-md · 12 px · default · quote cards, vertical card stacks
--gap-card-lg · 16 px · comfortable grid (multi-col, breathing room)
--gap-card-xl · 24 px · spacious grid (hero-tier, marketing)
Nested rhythm — wrap a sub-group to tighten without touching the outer container. When a container's gap is correct for separating top-level zones (header → group → footer) but a subset of children wants to read as one coherent set, don't change the container's gap. Drop a stack wrapper around the subset that publishes its own tighter --card-gap. The outer rhythm survives; the inner group feels denser. Canonical example: the swap form's state-content keeps 16 px (header → stack → action footer) while the inner .ui-form-stack runs at 8 px between cards.
Outer 16 px (lg) · inner stack 8 px (sm) · 3 zones
/* Component CSS — publishes a local --card-gap that defaults to a tier. */
.ui-quote-list {
--card-gap: var(--gap-card-md); /* 12 px default */
display: flex;
flex-direction: column;
gap: var(--card-gap);
}
/* Per-instance override — no need to touch the component CSS. */
<div class="ui-quote-list" style="--card-gap: var(--gap-card-lg);">
…
</div>
/* Context-scoped override: */
.compact-area .ui-quote-list {
--card-gap: var(--gap-card-sm);
}
/* Swappable ladder — install a preset by overriding the 5 tier
tokens at :root. Every consumer flips via cascade automatically;
no component re-authoring needed. Same mechanism as the Palette
Composer. */
: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);
}
| Token | Default value | Tier | Typical use case (guidance, not contract) |
|---|---|---|---|
| --gap-card-xs | var(--space-4) · 4 px | Extra-small | Ultra-tight where surfaces nearly join. Reserved for joint-surface compositions; no current consumer in the system. |
| --gap-card-sm | var(--space-8) · 8 px | Small | Compact list — .ui-amount-pair (swap pair with FAB at seam), .playground-rail__theme-cards, dense card menus. |
| --gap-card-md | var(--space-12) · 12 px | Medium (default) | Canonical card list — .ui-quote-list, vertical card stacks. Reach for this first. |
| --gap-card-lg | var(--space-16) · 16 px | Large | Comfortable multi-column grid with breathing room — .theme-card-grid. |
| --gap-card-xl | var(--space-24) · 24 px | Extra-large | Spacious marketing / hero-tier grid — .tile-grid, .enterprise-grid, .uc-grid. |
| --gap-card | var(--gap-card-md) | Alias | Convenience: bare --gap-card resolves to the md tier. Use for inline overrides where brevity helps. |
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 | Local var |
|---|---|---|
| .ui-amount-pair | --gap-card-sm | --card-gap |
| .playground-rail__theme-cards | --gap-card-sm | --card-gap |
| .ui-form-stack | --gap-card-sm | --card-gap |
| .ui-quote-list | --gap-card-md | --card-gap |
| .theme-card-grid | --gap-card-lg | --card-gap |
| .tile-grid, .enterprise-grid | --gap-card-xl | --card-gap |
| .uc-grid | --gap-card-xl | --card-gap |
Swapping the mapping. The ladder is a 5-token bundle; a future "card gap composer" installs a preset by overriding the 5 tokens at :root. Default mapping ships in styles.css; preset overrides go on :root[data-card-gap-preset="…"]. Component CSS stays untouched. See the Markup pane for the preset shape.
--stack-* is for vertical content rhythm (form fields, page sections); --cluster-* is for inline-flex gaps (icon + label pairs, chip clusters); --gap-card-* is for card-to-card spacing. Don't cross the streams — if a quote list reaches for --cluster-md, that's drift to fix at the call site.--gap-card-md (12 px). The other tiers (xs, sm, lg, xl) exist for specific contexts: a tight pair with a FAB at the seam, a compact rail list, a marketing-tier hero grid. Don't reach for --gap-card-lg just because it "looks roomier" without a contextual reason.--space-* underneath. If the value happens to match (e.g., --space-12), reach for --gap-card-md instead so the semantic intent is in the source. The grep grep "gap:.*--space-" *.css on card lists / grids should return nothing. Bypassing the tier to the raw value defeats the swappability — a future preset swap won't reach values authored as --space-12.--card-gap. Pattern: .foo-list { --card-gap: var(--gap-card-sm); gap: var(--card-gap); }. The local var gives consumers a per-instance override path without re-authoring the component (just set --card-gap in an inline style or context selector).--card-gap, not new tier names. If a card list genuinely needs a non-system gap (rare), override --card-gap with any --space-* token at the call site. Don't invent --gap-card-medium-tight — the five-tier ladder is the ceiling. If the same custom value shows up in 3+ call sites, that's the signal to either remap the ladder via a preset or reconsider which tier the consumers should use.:root. A future "card gap composer" UI works this way: it sets :root[data-card-gap-preset="compact"] (or similar) with five new --gap-card-* values, and every consumer flips automatically via cascade. Component CSS is the contract; consumers always read named tiers via --card-gap; the mapping is the variable layer underneath.--stack-* for vertical content gaps (sections, form fields). --cluster-* for horizontal inline gaps (icons + labels). --gap-card-* for card-to-card. Sub-card internal spacing (e.g., padding inside .ui-amount-card) lives in the per-card --amount-card-* tokens, not here.--card-gap. The outer rhythm survives; the inner group feels denser. Canonical example: the swap form's state-content keeps 16 px between header → stack → action footer, while the inner .ui-form-stack (limit-price card + amount pair + expires-in row + alerts) runs at 8 px. The wrapper class is per-page composition — the reusable thing is the wrap-and-publish---card-gap technique, not the wrapper name.--grid-gap-x / --grid-gap-yTwo-axis canvas spacing (Portal, Playground), edited per-theme from the Composer's Spacing section — a sibling of Corners. June 2026.
Two axes, one job each. A content canvas uses two different spacing values for two different jobs — a tight gutter between side-by-side cards (--grid-gap-x, default 16) and roomier breathing between stacked sections (--grid-gap-y, default 24). A single symmetric gap would flatten that distinction, so the system keeps them independent. Both are mode-independent (no light/dark flip).
Composer-driven. Edit per-theme from the Theme Composer's Spacing section (Compact / Standard / Spacious trio + a Custom card with Horizontal + Vertical sliders), painted to the active canvas. Enabled in the Portal + Playground rail composers; the FAB sitewide composer stays palette + corners. Distinct from the card-gap ladder, which is the universal card-LIST gap family.
--grid-gap-x · 16 px · horizontal gutter between side-by-side cards
--grid-gap-y · 24 px · vertical rhythm between stacked sections
Preset trios. Each snaps both axes at once. The active trio derives from the current gaps (no separate storage); a value off the trio grid highlights the Custom card.
Compact · x 12 · y 16
Standard · x 16 · y 24 · default
Spacious · x 24 · y 32
/* :root defaults (styles.css) — mode-independent, no [data-theme] variant. */
:root {
--grid-gap-x: var(--space-16); /* horizontal gutter — grid column-gap */
--grid-gap-y: var(--space-24); /* vertical rhythm — margin / row-gap */
}
/* A canvas grid consumes BOTH axes. */
.portal-kpi-grid {
display: grid;
column-gap: var(--grid-gap-x);
row-gap: var(--grid-gap-y);
margin-bottom: var(--grid-gap-y); /* section rhythm below */
}
/* A canvas with a non-default gutter pins its own; the composer paints
over it inline. */
.playground-canvas {
--grid-gap-x: var(--space-24); /* historical widget gutter */
}
/* The composer paints to the active canvas via the shared helper. */
// shared.js
window.ThemeComposer.applyGapsToScope({ x: 24, y: 32 }, canvasEl);
| Token | Default | Axis | Drives |
|---|---|---|---|
| --grid-gap-x | var(--space-16) · 16 px | Horizontal | Gutter between side-by-side cards/panels → grid column-gap. |
| --grid-gap-y | var(--space-24) · 24 px | Vertical | Rhythm between stacked sections → section margin-bottom + grid row-gap. |
Preset trios + slider ladder. The Custom sliders step the ladder 8 / 12 / 16 / 20 / 24 / 32 / 40 (starts at 8 — cards never touch).
| Trio | x (horizontal) | y (vertical) |
|---|---|---|
| Compact | 12 px | 16 px |
| Standard (default) | 16 px | 24 px |
| Spacious | 24 px | 32 px |
Consumers today. Two canvases. The Portal inherits the 16/24 defaults; the Playground pins --grid-gap-x: 24.
| Canvas | Consumers | Lifecycle |
|---|---|---|
| .portal-canvas | .portal-kpi-grid, .portal-cols, .portal-metric-grid, .portal-detail-cols, .portal-roles-grid, .portal-steps + canvas section margins | Ephemeral (composer-only; resets on reload) |
| .playground-canvas | .widget-stage (form ↔ receive gutter) | Persisted per-theme (p.gaps) + applied on select |
--grid-gap-x → column-gap) and between-sections rhythm (--grid-gap-y → margin-bottom / row-gap) serve different jobs and differ by default (16 vs 24). Don't collapse them to one symmetric gap.--gap-card-* is the universal card-LIST gap family any list opts into; --grid-gap-x/y is the composer-controlled CANVAS rhythm. Portal/Playground canvas grids consume --grid-gap-x/y; everything else uses the card-gap ladder. Don't cross them.--space-* when wiring a canvas grid/section — a bare --space-24 on a canvas section defeats the composer knob. Intra-component cluster gaps (icon + label, inline pairs) stay --space-*; only the canvas grid + section rhythm tokenize to --grid-gap-*.DEFAULT_FEATURES.gaps = false (the bare default stays off); it’s enabled in the Portal + Playground rail mounts (features.gaps: true) and in the FAB gated on a canvas (features.gaps: !!canvas). Gaps are a per-canvas concern: on chrome-only pages (marketing / catalog / launchpad) the FAB has no canvas to scope to, so it omits Spacing (painting --grid-gap-* to :root would be a no-op there anyway). Unlike corners + surface, whose tokens are universal consumers and stay in the FAB unconditionally.features.gaps in its rail composer mount, and — if its default differs from 16/24 — pin a per-canvas default + seed the editor from the canvas's effective gaps (the Playground pattern, so the readout stays honest). Don't add gaps to the FAB without surfacing first.styles.css :root; helpers ThemeComposer.readGaps / ThemeComposer.applyGapsToScope / ThemeComposer.clearGapsFromScope in shared.js; composer theme-editor.js → features.gaps. The durable contract is CLAUDE.md → Canvas grid-spacing system; this card is the rendering.outer = inner + padConcentric-corner rule for nested rounded elements — the outer radius equals the inner radius plus the padding between them.
Concentric corners. When a rounded element nests inside a rounded, padded parent, the two corner curves stay parallel — a constant gap all the way around the bend — only when outer radius = inner radius + padding. Solved for the child, that's inner radius = outer radius − padding: the default radius for any new nested element. Match the radii without accounting for the padding and the curves pinch at the corner — the most common rounded-corner mistake.
Don't — matched radii
outer = inner ignores the padding, so the corner gap pinches around the bend.
Do — radius + padding
outer = inner + padding keeps a constant gap all the way around the bend.
In real components · inner = outer − padding
Inner 32 px, matched to the frame — corner gap pinches.
Inner 20 px = 32 − 12 — curves stay parallel.
/* Concentric corners — a child's radius derives from the parent's
radius and the padding (the gap) between them. One equation: */
/* outer radius = inner radius + padding */
/* inner radius = outer radius − padding */
/* The usual case — choosing a NEW nested element's radius.
--space-16 here is the parent's padding; clamp the floor at 0. */
.child {
border-radius: max(var(--card-radius) - var(--space-16), 0px);
}
/* Worked:
parent 24 + pad 8 → child 16 (concentric)
parent 16 + pad 24 → child 0 (sharp: inset exceeds the radius) */
The relationship. One equation, read from either end.
| Direction | Formula | Reach for it when… |
|---|---|---|
| Frame from content | outer = min(inner + padding, 32) | You know the child's radius and are choosing the container's. Capped so a large padding doesn't over-round the card. |
| Nested element | inner = max(outer − padding, 0) | You know the parent's radius and are choosing a new child's. The default nested radius. |
Worked examples. Inner radius derived as max(outer − padding, 0). Every value a --space-* token.
| Parent radius | Padding | Inner radius | Result |
|---|---|---|---|
| 32px (--space-32) | 12px (--space-12) | 20px (--space-20) | Concentric — the diagram above. |
| 24px (--space-24) | 8px (--space-8) | 16px (--space-16) | Concentric — curves parallel. |
| 20px (--space-20) | 8px (--space-8) | 12px (--space-12) | Concentric. |
| 16px (--card-radius) | 16px (--space-16) | 0px | Sharp — inset equals the radius, the corner is square. |
| 16px (--card-radius) | 24px (--panel-padding) | 0px (clamped) | Sharp — inset exceeds the radius; rounding would crowd. |
Outward — the over-round ceiling. Framing a card around content, the outer radius is min(inner + padding, 32). Below the cap it tracks the content concentrically; past it the card stays calm instead of ballooning. .destination-card is the reference consumer.
| Inner radius | Padding | Outer radius | Result |
|---|---|---|---|
| 16px (--button-radius) | 8px (--space-8) | 24px (--space-24) | Concentric — below the cap. |
| 16px (--button-radius) | 16px (--space-16) | 32px (--space-32) | Concentric — exactly at the cap (the knee). |
| 16px (--button-radius) | 32px (--space-32) | 32px (clamped) | Capped — .destination-card; 48 would over-round. |
outer = inner + padding. Matching the radii (ignoring the padding) makes the gap pinch around the diagonal — the inner corner reads as crowded. It's the most common rounded-corner mistake.outer = min(inner + padding, 32) (ceiling). Choosing a new nested element's radius → inner = max(outer − padding, 0) (floor). Same equation, solved for different unknowns, each clamped at its limit.max(var(--card-radius) - var(--space-N), 0px). When padding ≥ the parent's radius, the child's corners go sharp: the parent's straight edges start before the child's corner, so any rounding crowds the bend.min(calc(var(--inner-radius) + var(--space-N)), var(--space-32)). Once inner + padding exceeds 32, the encompassing card stops tracking the child and reads as a calm rounded card rather than an over-bubbled one — strict concentricity balloons the corner at large padding, and the curves are far apart at that scale anyway, so the cap is barely perceptible. .destination-card (seed 16 + pad 32 = 48 → 32) is the reference. The outward twin of the 0px floor.--nest-radius — the value depends on two per-context inputs (the parent's radius + the gap to it), so the calc() is the API. Pull the parent radius from --card-radius / --panel-radius and the padding from the parent's own --space-*.CLAUDE.md → Nested radius — concentric corners (the durable contract). Spec in design.md → Nested radius; the calc pattern is documented next to the radius tokens in styles.css.--surface-* fill tokens, the light/dark theme system that flips them, and the audience-led naming for cards versus tiles. For shadow / lift / state mechanics see Elevation; for full surface character (paper, glass, gradient, halo — fill + border + shadow + backdrop + highlight, bundled) see Materials.paper.bg = var(--surface-card) doesn't duplicate the color — it composes it. Three-layer stack: surfaces (colors) → materials (compositions, named) → components (consume materials via --mat-*). Each layer owns one concern, no overlap.--surface-*The four --surface-* fill tiers, shown side-by-side for dark + light. Flips automatically via [data-theme] on <html>. This card is the canonical reference for the ladder — share #theming when teammates ask "which surface token at which tier?"
The ladder, both themes side-by-side. Four tiers stack from --surface-sunk (deepest recess) to --surface-card (highest lift). Same token names in both themes; values flip automatically. Each swatch is rendered with its own theme's value — the swatches don't follow the page's current theme, so you can scan both ladders at once even when the rest of the page is in just one mode.
Dark mode ladder · 5 → 8 → 16 → 24 % lightness (rebalanced May 2026 v2)
.panel shell
Light mode ladder · 91 → 100 → 97 → 100 % lightness (card pivots to page)
.panel shell
--surface-page · lifts via shadow
Tier mapping · which token for which job
--surface-sunk — recessed wells, demo containers, "well" treatments. The only tier that goes down from the page.--surface-page — viewport / canvas bg. <body> background; what every other surface sits against.--surface-raised — .panel shells, the playground rail, the swap widget shell, footer bands, raised page-level regions. One step lighter than the page.--surface-card — cards, tiles, paper-material consumers, .ui-amount-card. In dark = 24% (highest tier); in light = pivots to --surface-page (100% white) and lifts via shadow alone — "Material Design approach" to light mode where you can't go above pure white.--glass-base — translucent fill base for the Glass material (dropdowns, command palette, FAB sub-panels). Not part of the solid ladder; see Materials → Glass.Glass — --glass-base is the translucent fill base for floating panels. The full glass recipe (fill + shadow + backdrop-filter) is the Glass material; see Materials → Glass.
<!-- Theme is set on <html>. shared.js writes data-theme from the toggle. -->
<html data-theme="light">…</html>
<html data-theme="dark">…</html>
/* Surface fills consume tokens that flip per theme. */
.page { background: var(--surface-page); }
.well { background: var(--surface-sunk); }
.section { background: var(--surface-raised); }
.card { background: var(--surface-card); }
/* For composed character (paper, glass, gradient, halo) reach for
a Material instead of stacking surface + shadow + border by hand.
See #materials. */
The fill family. Five solid + one glass. Light/dark values auto-flip via [data-theme="light"].
| Token | Dark | Light | Use |
|---|---|---|---|
| --surface-page | oklch(8% 0.015 268) | oklch(100% 0 268) | Page background |
| --surface-sunk | oklch(5% 0.012 268) | oklch(91% 0 268) | Recessed wells, demo containers |
| --surface-raised | oklch(16% 0.016 268) | oklch(97% 0 268) | Footer, raised page-level surface, .panel shells |
| --surface-card | oklch(24% 0.018 268) | var(--surface-page) | Cards, tiles, paper-material consumers — pivots to --surface-page in light so cards lift via shadow alone |
| --glass-base | color-mix(--surface-page, white 8% / 70%) | Glass fill base — consumed by Materials → Glass | |
| --border-subtle | oklch(100% 0 0 / 0.06) | oklch(0% 0 0 / 0.06) | Card borders, dividers |
Light mode caps at --surface-page. The dark surface ladder steps 5 → 8 → 16 → 24% lightness (rebalanced May 2026 v2 — pre-rebalance was 5 → 8 → 10 → 15 with a too-tight 2% page→raised step; v1 went 8 → 14 → 19; v2 settled on uniform 8% steps); in light mode the canvas is already 100% white, so there’s no value “above” it for a card to step up to. --surface-card pivots to var(--surface-page) in light and cards lift via shadow alone (Material Design approach). The pivot lives in one token; component CSS never branches per theme.
[data-theme="dark"] or [data-theme="light"] on <html>. Pre-paint init in shared.js reads localStorage to avoid FOUC.--surface-raised on top of --surface-page; --surface-card on top of either. Don’t skip tiers.--elev-* tier, not a hand-authored shadow. See Elevation → Elevation tiers.var(--font-sans) unless explicitly set to var(--font-mono).--font-sans · --font-monoTwo fonts, two roles. Geist carries content + UI; Geist Mono grounds anything technical — code, addresses, numerics, eyebrows.
--font-sans
Geist · 7 weights
--font-mono
Geist Mono · 4 weights
Geist weights
Geist Mono weights
★ Default weight for the family. Geist 700 anchors every heading in the system; Geist Mono 500 is the default for token labels and eyebrow markers. Reach for those first; pick a different weight only when the content asks for it (a 600 button, a 400 paragraph, a 600 tab strip).
/* Two family tokens — set once on `html`, never override per-component. */
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* Default everything to sans — single declaration on the document root. */
html { font-family: var(--font-sans); }
/* Opt into mono only when the content is technical. */
code,
.mono,
.eyebrow-code,
.ds-doc-codeblock { font-family: var(--font-mono); }
/* Inline token reference inside prose — small surface, mono font, faint
tinted bg so it reads as "reference" without becoming a chip. */
code {
font-family: var(--font-mono);
font-size: 0.92em;
background: color-mix(in oklch, var(--text-primary) 6%, transparent);
padding: 1px 6px;
border-radius: var(--space-4);
}
Family tokens
| Token | Family | Foundry / License | Fallback stack |
|---|---|---|---|
| --font-sans | Geist | Vercel · Open Font License | system-ui, -apple-system, 'Segoe UI', sans-serif |
| --font-mono | Geist Mono | Vercel · Open Font License | ui-monospace, 'SF Mono', Menlo, Consolas, monospace |
Loaded weights
| Family | Weights | Source |
|---|---|---|
| Geist | 300 · 400 · 500 · 600 · 700 · 800 · 900 | Google Fonts |
| Geist Mono | 400 · 500 · 600 · 700 | Google Fonts |
Where each is used
| Surface | Font | Why |
|---|---|---|
| Headings (h1–h3, hero, display) | --font-sans | Editorial voice. Geist's geometric humanist letterforms read confidently at 24–80px. |
| Body, prose, marketing copy | --font-sans | Default. Sans at 16/1.6 is the readability baseline. |
| Form labels, button text, card titles | --font-sans | UI surface. Sans 500/600 keeps controls neutral. |
Eyebrows (.eyebrow-code, .ds-doc-eyebrow) | --font-mono | Mono uppercase signals "this is a section marker, not prose." |
Inline code · token references | --font-mono | Reader sees "reference" not "word." Differentiates --accent-primary from accent primary. |
Code blocks (.ds-doc-codeblock) | --font-mono | Code wants fixed-width — alignment of indentation, columns, char-pos. |
| OKLCH / hex / pixel values | --font-mono | Numerics align column-to-column when fixed-width. |
| Wallet addresses, hashes, IDs | --font-mono | Truncated hex strings stay legible at small sizes. |
| Token name columns in spec tables | --font-mono | .mono / .mono-sm utilities — keeps the leftmost column scannable as a list of identifiers. |
--font-sans (Geist by default) and --font-mono (Geist Mono by default) are each driven by the active theme's font / mono field, painted on apply (e.g. LI.FI 2.0 → Inter + IBM Plex Mono). The scale, weights, and tracking below stay SHARED across brands — only the two families swap. See CLAUDE.md → "Typography axis".<code> in prose is a token reference. When you mention a token name, surface name, or class in copy (--accent-primary, .ds-doc-card, oklch(68% 0.220 268)), wrap it in <code>. The mono font + tinted background makes the difference between "the accent token" (prose) and --accent-primary (reference) visible without parentheses..eyebrow-code, .ds-doc-eyebrow) — that's mono uppercase 500–700, deliberately quiet, anchoring the heading below it. A full mono <h1> reads as a code-snippet headline, not a section title.--lh-* token rules as sans. Geist Mono's loose default line-height fights tables and code blocks — pair with --lh-sm (20px) for caption-tier mono, --lh-body (24px) for body-tier mono, and the matched display tokens above that..mono / .mono-sm utilities exist for table columns. Token-name columns and small numeric columns use them so the column reads as a list of identifiers, not prose. Don't apply .mono to a whole table row or paragraph — it's a per-cell utility.--font-jumperJumper swaps the sans family to Urbanist. Type scale, weight ladder, tracking, and Geist Mono stay shared with LI.FI.
--font-jumper
Urbanist · 7 weights
--font-sans · LI.FI reference
Geist
Same scale, two voices
Urbanist weights
One family swap, everything else shared. Urbanist mirrors Geist's role table 1:1 — same default weights per role (700 headings, 400 body, 500/600 UI), same --text-* / --lh-* / --tracking-* ladders, same Geist Mono for everything technical. Until the brands formally split, Jumper-brand surfaces differ from LI.FI by exactly three levers: theme (the jumper-1 / jumper-2 presets), the logo tokens (--jumper-indigo / --jumper-purple), and this sans family.
/* The Jumper sans token — defined once in styles.css :root, next to
--font-sans. Same system fallback stack. */
--font-jumper: 'Urbanist', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
/* Consume at the BRAND-SURFACE level — a Jumper page/region sets the
family once on its root, never per-component. */
.jumper-surface { font-family: var(--font-jumper); }
/* Current consumer — the lockup sub-product label: */
.brand-lockup--jumper .brand-lockup__label { font-family: var(--font-jumper); }
<!-- Pages that render Jumper chrome load Urbanist alongside Geist: -->
&family=Urbanist:wght@300;400;500;600;700;800;900
Family token
| Token | Family | Foundry / License | Fallback stack |
|---|---|---|---|
| --font-jumper | Urbanist | Corey Hu · Open Font License | system-ui, -apple-system, 'Segoe UI', Arial, sans-serif |
Shared vs differentiated — the brand-overlap contract
| Axis | LI.FI | Jumper | Status |
|---|---|---|---|
| Sans family | Geist | Urbanist | Differentiated |
| Colour palette | lifi-1 (default) | jumper-1 / jumper-2 presets | Differentiated |
| Logo tokens | currentColor mark | --jumper-indigo · --jumper-purple | Differentiated |
Type scale (--text-* / --lh-*) | one ladder | Shared | |
Weight ladder (--fw-*) + roles | one ladder | Shared | |
Tracking (--tracking-*) | one ladder | Shared | |
Mono (--font-mono) | Geist Mono | Shared | |
| Components + spacing + radius + elevation | one system | Shared | |
Loaded weights
| Family | Weights | Source |
|---|---|---|
| Urbanist | 300 · 400 · 500 · 600 · 700 · 800 · 900 | Google Fonts — loaded on pages that render Jumper chrome (this page, Assets) |
font-family: var(--font-jumper) once on its root; components inherit. Mixing Geist and Urbanist inside one surface is the drift this token exists to prevent.family=Urbanist:wght@300..900 to the page's Google Fonts link when its chrome goes Jumper.Five canonical compositions of sans + mono — section headers, KPI stats, inline references, form values, and tab nav.
Section header
// 03 — Foundations
Pages reference var(--accent-primary) and var(--surface-raised), never literal OKLCH.
Mono eyebrow grounds the section as an editorial unit. Sans heading carries the title; mono inline code surfaces token references inside prose.
KPI stat
Mono numerics align column-to-column when stacked. Mono uppercase label sits as the metric's name; sans delta chip carries the directional cue.
Inline references
Quotes route through the 0xb1739…ee01 aggregator on Arbitrum, return cached responses for ~80ms via the quote-cache service, and emit a QuoteReady event with amountOut and gasEstimate.
Mono code differentiates references from prose without parenthetical asides. The reader scans mono runs as "things that exist in the code," sans as "what they do."
Detail list
0x742d35…f8e30xb1739…ee01Sans terms read as questions; mono values read as data. Long hex strings stay legible at fixed-width because every character occupies the same column; numeric amounts use tabular-nums so columns align across rows.
Tab nav + content
Routes are scored by execution time, slippage, and fee. The aggregator returns the lowest-cost path for every quote.
Sans tabs above sans content, with the active tab marked by an accent underline. The catalog's own viewport tabs (the Preview / Markup / Specs strip on every card) use the alternate mono uppercase recipe — same underline mechanic, different type voice for "architecture marker" vs "section nav."
<!-- 1. Section header — .content-tile with mono eyebrow + sans h3 + sans body -->
<div class="content-tile content-tile--sm">
<p class="content-tile-eyebrow">// 03 — Foundations</p>
<h3>Color tokens describe intent</h3>
<p>Pages reference <code>var(--accent-primary)</code> …</p>
</div>
<!-- 2. KPI — real .stat-card primitive: eyebrow + value + delta chip -->
<div class="stat-card">
<div class="stat-card-eyebrow">Volume routed · Q1 2026</div>
<div class="stat-card-value">$1.24<span class="stat-card-unit">B</span></div>
<div class="stat-card-footer">
<span class="stat-card-label">vs. Q4</span>
<span class="chip chip-delta-pos">↑ 38.4%</span>
</div>
</div>
<!-- 3. Inline references — wrap token names / addresses / events in <code> -->
<div class="card">
<p>Quotes route through the <code>0xb1739…ee01</code> aggregator…</p>
</div>
<!-- 4. Detail list — sans dt term, sans dd value; wrap hex / tx hash in <code> -->
<dl class="detail-list">
<div class="detail-list__row">
<dt class="detail-list__term">Recipient</dt>
<dd class="detail-list__desc"><code>0x742d35…f8e3</code></dd>
</div>
<div class="detail-list__row">
<dt class="detail-list__term">Amount</dt>
<dd class="detail-list__desc">1,250.00 USDC</dd>
</div>
</dl>
<!-- 5. Tab nav — .ds-doc-tab-nav above .card content -->
<div class="ds-doc-tab-nav" role="tablist">
<button class="ds-doc-tab-nav-item is-active">Overview</button>
<button class="ds-doc-tab-nav-item">Routes</button>
</div>
<div class="card">
<h4>Bridge volume across 28 chains</h4>
<p>Routes are scored by execution time, slippage, and fee.</p>
</div>
code works because mono lives in two intentional spots (the eyebrow, the references). A surface with mono labels, mono headings, mono buttons, and mono captions reads as a debug console — mono loses its meaning when it's the default voice.code is the default for token references in copy. Wrap token names, class selectors, file paths, hex strings, and OKLCH literals in <code>. The mono font + tinted background makes "the accent token" (prose) different from --accent-primary (reference) without parenthetical asides.--tracking-wider. They're "section signs" that tell the reader "you're crossing an architectural boundary." Don't decorate; the recipe IS the signal.<h1> reads as a code-snippet headline, not a section title. Use sans for hierarchy; pair an uppercase mono eyebrow above the heading if you need the technical signal.Geist (sans) for everything; Geist Mono for code/numbers/eyebrows. Modular type scale shared across Product, Marketing, and Docs.
One ladder, biggest first. Deck scale (256–112px) is for slide covers, single-word reveals, and canvas-fill statements; web & product (80–12px) is everything else. Deck-tier samples use static --lh-* tokens (matching pixel line-heights tuned at 0.94–0.96); web-tier display sizes use unitless literals so they don't overlap when fonts clamp.
/* Type scale tokens — match the catalog's scale, never roll your own px. */
font-size: var(--text-display); /* 56 px display heading */
font-size: var(--text-h1); /* 40 px page title */
font-size: var(--text-h2); /* 32 px section title */
font-size: var(--text-h3); /* 24 px sub-section */
font-size: var(--text-body-xl); /* 20 px oversized lede */
font-size: var(--text-body-lg); /* 18 px large body / xl button labels */
font-size: var(--text-body); /* 16 px prose */
font-size: var(--text-sm); /* 14 px dense reference */
font-size: var(--text-caption); /* 12 px caption / label */
font-size: var(--text-micro); /* 10 px ticks / badges / superscripts */
/* Pair with the right line-height + tracking tokens from --lh-* and --tracking-*. */
| Token | Size | Weight | Tracking | Line-height | Utility | Use |
|---|---|---|---|---|---|---|
| --text-colossal | 256px | 800 (deck override) | --tracking-crunch | --lh-colossal · 240px | .text-colossal · .text-colossal--fluid | Single-word monument — slide canvas-fills, CONNECT.-scale reveals |
| --text-keynote | 192px | 800 (deck override) | --tracking-crunch | --lh-keynote · 180px | .text-keynote · .text-keynote--fluid | Deck headline sized for 16:9 presentation frames |
| --text-statement | 144px | 800 (deck override) | --tracking-tighter | --lh-statement · 136px | .text-statement · .text-statement--fluid | Bold website statements — 2–3 words per line at 1440px |
| --text-poster | 112px | 800 (deck override) | --tracking-tighter | --lh-poster · 108px | .text-poster · .text-poster--fluid | Sub-hero / dense slide content (one step above Hero) |
| --text-hero | 80px | 700 | --tracking-tighter | 1.1 (literal) | — | Page-level hero numbers; canvas-fill |
| --text-display | 56px | 700 | --tracking-tighter | 1.18 (literal) | — | Display headlines, deck titles |
| --text-h1 | 48px | 700 | --tracking-tighter | --lh-h1 · 56px | — | Section titles |
| --text-h2-lg | 36px | 700 | --tracking-firm | 1.1 (literal) | — | Value-display tier — large numeric values (.ui-amount-card, .ui-limit-price-card, .ui-token-info__price). Fills the 32↔48 gap. |
| --text-h2 | 32px | 700 | normal | --lh-h2 · 40px | — | Subsection headings |
| --text-h3 | 24px | 700 | normal | --lh-h3 · 32px | — | Card titles, card-stat numbers |
| --text-body-xl | 20px | 400 | normal | --lh-body-xl · 32px | — | Oversized lede — page-level subtitles, hero intros |
| --text-body-lg | 18px | 400 | normal | --lh-body-lg · 28px | — | Large body — intro paragraphs, .btn-xl labels, fills the 16↔20 gap |
| --text-body | 16px | 400 | normal | --lh-body · 24px | — | Standard body, hint text default |
| --text-sm | 14px | 400 | normal | --lh-sm · 20px | — | Captions, helpers, table cells |
| --text-caption | 12px | 500 | --tracking-wide | --lh-caption · 16px | — | Labels, hints, eyebrows, mono code |
| --text-micro | 10px | 500 | --tracking-loose | --lh-caption · 16px | — | Slider ticks, badge superscripts, dense overlay glyphs, compact data-table headers (.data-table--sm — uppercased + tracked for legibility at this size). Smallest tier on the ladder; reach for it only when 12 px would dominate (annotations to a primary value) |
Families. --font-sans = Geist (everything except code). --font-mono = Geist Mono (code, addresses, numeric values, eyebrows). Mono is institutional; never decorative.
Deck-tier weight override. Hero / Display / H1 sit at 700 — the brand's heaviest editorial weight. The deck tier (.text-poster · .text-statement · .text-keynote · .text-colossal) is the only exception in the scale: it overrides to 800. Reason — at canvas-fill scale (≥112px) the letterforms read as outlines at 700; the +100 weight bump restores the visual presence the composition expects. Authored as a single shared rule on the four utilities + their --fluid siblings; don't fork per-class. Source: styles.css → search .text-poster, .text-poster--fluid,; spec: design.md §03 → Mega Display weight override.
Deck-tier fluid clamps. Every deck-scale utility ships a --fluid sibling — .text-poster--fluid = clamp(48px, 9vw, 112px); .text-statement--fluid = clamp(56px, 12vw, 144px); .text-keynote--fluid = clamp(56px, 16vw, 192px); .text-colossal--fluid = clamp(48px, 22vw, 256px). Use the fluid form on responsive surfaces; reserve the static form for known-fit contexts (a 1920×1080 deck slide, a desktop-only landing hero).
Gradient mega. Pair deck-scale type (≥112px) with .text-gradient-mega — a 110° gradient at 180% horizontal span tuned so the shimmer reads across a single word. The standard .text-gradient compresses badly at this scale.
--text-caption 12px, --text-sm 14px) takes --lh-caption (16px) or --lh-sm (20px). Body text takes --lh-body. Display text takes the matched --lh-display/--lh-hero/etc. Mismatching tiers — e.g. --lh-body (24px line-box) under --text-caption (12px font) — reads at ~1.92 ratio, not 1.5, because the line-box was tuned for a 16px font. Bug history: May 2026, .tooltip--multi shipped line-height: var(--lh-body) on caption-size text; result read as airy and off-tier. Fix removed the override; multi-line variant inherits --lh-sm from the base. Validator E015 now flags this pattern.--lh-* token. When font-size is a fixed token (e.g. .text-keynote, .text-h1), pair with the matching pixel --lh-* (e.g. --lh-keynote: 180px). The pixel line-heights are designed at the right ratio for the static font size.font-size uses clamp(), NEVER use a pixel --lh-* token — the line-height stays fixed while the font shrinks below its static size, dropping the ratio under 1.0 and overlapping lines. Use line-height: 1.18 (display/hero) or 0.95 (≥112px) instead. The bug pattern that lit this rule: --lh-display on responsive heroes.LI.FI, CONNECT., YES.; anything more belongs at keynote (192px) or below..grad spans inside display type need the descender-escape block — display: inline-block; padding: 0.04em 0.08em 0.04em 0; line-height: inherit. Without it, -webkit-background-clip: text slices descenders on g/y/p (most visibly on words like typography, liquidity)..text-gradient-mega, not .text-gradient. The standard gradient compresses at this scale; the mega variant has a wider hue path and a 180% horizontal span tuned for the canvas-fill case.--tracking-tighter (−4%) for h1/display/hero/poster/statement; --tracking-crunch (−5%) for keynote + colossal; normal for body; --tracking-wide / --tracking-wider for caption / eyebrow uppercase. Don't override per-instance.--material-<name>-* tokens directly on :root; the cascade flows through [data-material] selectors so every consumer updates without per-component wiring..material-composerInline tuner for material values — pick a material, drag sliders, watch every consumer repaint. Modal for the full workspace.
Pick a material in the right column, drag sliders, watch every preview surface (and the FAB panel, and dropdowns, and the playground rail) update live. The inline workspace below shows four preview surfaces wearing the active material; the modal expands to a larger tray + saved variants + JSON export.
<!-- Inline tuner — picker chips and slider rows are rendered from
MATERIAL_REGISTRY in shared.js. Markup is just the empty
containers; JS populates them at init (and re-renders the
slider column when the active material changes). -->
<div class="material-composer">
<div class="material-composer-tray" data-mc-preview>
<div class="material-composer-surface"
data-material="paper"
data-mc-preview-surface
data-mc-role="Card">…</div>
<!-- 3 more surfaces (tile, small, panel) -->
</div>
<div class="material-composer-controls">
<!-- Empty — JS renders one chip per registry entry -->
<div class="material-composer-picker" data-mc-picker></div>
<!-- Empty — JS renders only the active material's controls.
data-mc-slider-class adds a class to each <input type="range">
(use slider-range--panel for the inline card; omit for modal). -->
<div class="material-composer-sliders"
data-mc-sliders
data-mc-slider-class="slider-range--panel"></div>
<button data-mc-open-modal>Open full composer</button>
<button data-mc-reset>Reset material</button>
</div>
</div>
<!-- Modal workspace lives at the bottom of the page (id="material-composer-modal").
Same hooks; JS auto-syncs sliders between inline ↔ modal. -->
Registry-driven. Picker chips and slider rows are rendered from a single MATERIAL_REGISTRY array in shared.js → initMaterialComposer(). Each entry declares id + label + defaults + controls (the dials it exposes) + writeTokens(state, root) (how dial values translate into --material-<id>-* token writes). Adding a material is two adjacent edits: add the entry here, add the matching tokens + [data-material="…"] block in styles.css. The init driftCheck() compares the two and console.warns on mismatch.
Markup hooks
| Hook | Role |
|---|---|
| data-mc-picker | Empty container — JS populates with one chip per registry entry |
| data-mc-sliders | Empty container — JS populates with one row per controls entry of the active material; re-renders on switch |
| data-mc-slider-class="…" | Optional class added to each rendered <input type="range"> (e.g. slider-range--panel for the inline card) |
| data-mc-preview-surface | Surface in the tray — JS retags its data-material when the picker switches |
| data-mc-role="…" | Display role on a preview surface (Card · Tile · Small · Panel) — used in the auto-generated label |
| data-mc-open-modal | Button that opens the full-screen composer |
| data-mc-close | Modal close affordance (backdrop, × button, Esc key) |
| data-mc-reset | Restore the active material to its registry-declared defaults |
| data-mc-reset-all | Restore every material (modal-only) |
| data-mc-save | Save the current material's values as a named variant in localStorage |
| data-mc-variants | Container — JS lists saved variants here; click an entry to apply |
| data-mc-export | Copy the full state (all materials + saved variants) to clipboard as JSON |
| data-mc-export-preview | Live JSON preview of what export emits |
Material registry — current shape
| Material | Controls | Defaults |
|---|---|---|
| paper | opacity · shadow · highlight | 100 / 1 / 0 |
| glass | opacity · blur · highlight | 70 / 40 / 15 |
| gradient | opacity · shadow · highlight | 100 / 2 / 18 |
| halo | tint · shadow | 12 / 1 |
--material-<name>-* at :root. Every consumer (cards, panels, the FAB popover, dropdowns, etc.) reads var(--mat-*) via the [data-material] attribute selectors and repaints automatically. No per-component wiring.MATERIAL_REGISTRY; nothing about the material set is hand-coded in HTML. To add a material: append one entry to the registry AND add tokens + a [data-material="…"] block in styles.css. The drift check at init flags forgotten halves.tint + shadow; glass shows opacity + blur + highlight; paper / gradient show opacity + shadow + highlight. No greyed-out theatre — switching materials re-renders the slider column to honest controls.localStorage but the composer NEVER auto-loads them on page init. The user has to click a saved variant to apply it. This is the same brand-protection rule that governs the Theme Composer.styles.css --material-*-* definitions, and adding a card in #materials.styles.css.styles.css, never opens a PR. Export JSON, paste into a commit, run the validator.styles.css · search MATERIAL COMPOSER for the inline workspace + modal styles. The Materials layer itself is documented in #materials (Foundations).shared.js · initMaterialComposer(). Auto-mounted from DOMContentLoaded.design.md §02 → Materials → Material Composer.lifi-mc-variants · array of {name, material, values, createdAt}.#theme-composer (sibling tool — the Theme Composer), #materials (the layer this Composer edits).--success / --danger / --warn / --info) are formula-driven in styles.css via OKLCH relative-color syntax — each derives from --brand-anchor with a per-role L offset, C multiplier, and role-locked hue. Tuning lives in the Theme Composer’s Semantic states section (the standalone Semantic Composer merged in, June 2026): every theme carries its own per-mode coefficients, so a brand palette that would wash a warning out to grey can pull it back into its legible band — and the fix travels with the theme.--successFormula-derived state tokens — per-role L offset / C multiplier / locked hue over --brand-anchor, tunable per theme in the Theme Composer.
Each role’s anchor token is a live function of --brand-anchor: oklch(from var(--brand-anchor) calc(l + L_offset) calc(max(c, 0.12) * C_mult) H) — the 0.12 chroma-input floor keeps a muted or monochrome anchor from collapsing the states to grey. Everything below reads the LIVE tokens — pick or edit a theme in the Theme Composer (the card in the next section, or the floating composer) and every swatch, alert, chip, badge, and button repaints through the cascade. The per-role sliders live in the editor’s Semantic states section.
Success · --success
Route confirmed
Stargate via Polygon → Arbitrum settled in 12s with 0.04 % slippage.
Danger · --danger
Transaction reverted
Insufficient gas on destination chain. No funds were moved.
Warn · --warn
High slippage detected
This route shows 1.8 % price impact. Consider reducing trade size.
Info · --info
New route available
Stargate just added a USDC bridge between Base and Arbitrum.
/* styles.css — formula-driven semantic tokens (excerpt). The four
anchors are LIVE FUNCTIONS of --brand-anchor, not hand-typed
oklch literals. Swap --lifi-sapphire → everything rebalances. */
:root {
/* Brand anchor — feeds the semantic formula */
--brand-anchor: var(--lifi-sapphire);
/* Per-role coefficients — dark-mode defaults */
--sem-success-L-offset: 0.040;
--sem-success-C-mult: 0.770;
--sem-success-H: 165;
/* … danger · warn · info … */
/* Derived semantic anchors — OKLCH relative-color formulas */
--success: oklch(from var(--brand-anchor)
calc(l + var(--sem-success-L-offset))
calc(max(c, 0.12) * var(--sem-success-C-mult))
var(--sem-success-H));
/* Companion bg / border tint from the role token itself */
--success-bg: color-mix(in oklch, var(--success) 14%, transparent);
--success-border: color-mix(in oklch, var(--success) 30%, transparent);
--success-fg: var(--success);
}
[data-theme="light"] {
/* Light mode — same formula, different coefficients */
--sem-success-L-offset: 0.095;
--sem-success-C-mult: 1.000;
}
A theme customizes the coefficients via the optional semantics field on its record — per-mode lo / cm plus role-locked hues. Absent → the formula defaults above.
// theme record (theme-presets) — optional semantics override
semantics: {
hues: { success: 165, danger: 24, warn: 98, info: 264 },
dark: { warn: { lo: 0.100, cm: 1.40 } }, // partial OK — merges over defaults
light: { warn: { lo: 0.280, cm: 1.60 } }
}
Formula-driven, tuned per theme. The semantics live in styles.css as OKLCH relative-color expressions — not hand-typed values. Each role anchor reads the L and C of --brand-anchor, applies a per-role offset (L) and multiplier (C), and locks the hue to a canonical position. The Theme Composer’s Semantic states section edits the coefficients per theme: at :root it writes the --sem-* coefficient vars (the formulas recompute via cascade); on a scoped canvas (Playground widget, Portal brand surface) it re-derives the tokens as resolved literals, since the :root formula can’t re-resolve against a canvas seed.
The doctrine — three channels, three owners
| Channel | Owner | Why |
|---|---|---|
H (hue) | Role. Locked. | Green = success, red = danger. Cultural meaning. Don’t bend. |
C (chroma) | max(Brand, 0.12) × multiplier. | Saturation is the brand’s voice. Per-role mult compensates for perceptual weight (red needs more C); the 0.12 input floor keeps muted/monochrome anchors legible (multiplicative chroma can’t recover from 0). |
L (lightness) | Brand + offset. | Semantic legibility band. Theme-aware (light semantics darker for WCAG contrast). |
Guardrails — slider ranges
Each slider in the Theme Composer’s Semantic-states section is bounded so the formula can’t drift a role outside its legible territory. lo and cm are per-mode; the hue is role-locked and shared by both modes.
| Channel | Range | Rationale |
|---|---|---|
L offset | −0.25 to +0.40 | Covers every anchor comfortably; further would push semantics out of the legible band on either theme. |
C × mult | 0.50 to 2.00 | Per-theme anchors vary in chroma — a low-chroma brand needs >1.3 to lift its states to a legible chroma at all (the ceiling was 1.3 when one global anchor was assumed). Below 0.5 reads Material-muted; very high values may clip in sRGB on chromatic anchors. |
H success | 130° – 180° | Yellow-green → teal-green. Can drift toward emerald, never into teal/blue. |
H danger | 5° – 45° | Pure red → red-orange. Can warm slightly, never into yellow. |
H warn | 60° – 115° | Orange-amber → yellow-green. Spans the warning band; never into green proper. |
H info | 210° – 275° | Sky-cyan → indigo (brand-adjacent). Can pull close to brand-blue hue (268°) without colliding with success or danger. |
Where the values live
| Layer | Home | Notes |
|---|---|---|
| Formula defaults | styles.css :root + [data-theme="light"] | Mirrored in theme-composer-core.js → SEM_DEFAULTS — keep the pair in lockstep when retuning. |
| Per-theme override | theme record → semantics | Optional { hues, dark, light }; saved by the editor, applied on theme pick, carried by Export CSS as --sem-* vars. |
:root paint | --sem-* coefficient vars | Inline vars for the active mode; the stylesheet formulas recompute anchors + companions via cascade. |
| Canvas paint | resolved literals | deriveTokens(seeds, mode, semantics) writes --success & co. inline on the scoped canvas (the relative-color form can’t re-resolve there). |
Browser support
OKLCH relative-color syntax (oklch(from <color> L C H)): Chrome 119+, Safari 16.4+, Firefox 128+. Evergreen-safe by 2026. Older browsers fall back to whatever the cascade can compute — typically the inherited default. If you need older-browser support, ship a @supports not (color: oklch(from red l c h)) fallback block with the hand-typed legacy values.
Do
--warn out so a warning no longer reads as one. Fix it on that theme; the correction saves to the theme record and travels with it.lo / cm are per-mode (the section re-syncs on every mode flip); the hue is shared.--brand-anchor automatically.--sem-* coefficient vars land in :root (dark + hues) and the light block (light lo/cm), exactly the shape styles.css uses.Don’t
--success: oklch(…) literals into styles.css. The formula is the source of truth; direct overrides break the cascade and bypass the brand-link.styles.css formula block (coefficients + oklch(from …) + companions) and theme-composer-core.js (SEM_ROLES / SEM_DEFAULTS) + the editor’s role list..screen-header, .theme-card, .option-card, .seg) plus the shared window.ThemeComposer editor.Theming mini-app — saved-theme list + a drill-in editor tuning every theme axis, from palette and corners to charts and brand font.
Both zones below are the live Theme Composer, hydrated through the shipped code (ds-theme-composer.js) so the documentation can't drift from the component. Every edit is scope-contained to this card — drag a slider and the demo re-derives its own tokens without re-skinning the catalog, and nothing persists. Click a theme in the list to load its seeds into the editor.
List view · theme management
Edit view · meta + the five axes
Every axis renders here for documentation; real mounts gate a subset via features — see the mount matrix in Anatomy.
The editor is one layout-agnostic primitive mounted into any host. The list is compact .theme-card--row cards, one per preset via window.ThemeComposer.renderCardRow(preset, mode, isActive). The edit view is window.ThemeComposer.mount(opts) — the host passes the axis seeds + a scope and owns persistence through onChange (the primitive is storage-agnostic).
// Mount the editor into any host (theme-editor.js). scope decides WHERE
// it paints: null → :root sitewide · element/array → that canvas only.
const controller = window.ThemeComposer.mount({
host: editHost,
palette: { dark, light }, // OKLCH seeds per mode
meta: { name, description },
radii: window.ThemeComposer.readRadii(preset), // {card,panel,button,pill}
gaps: window.ThemeComposer.readGaps(preset), // {x,y}
surface: window.ThemeComposer.readSurface(preset), // {dark,light} L recess
panels: window.ThemeComposer.readPanels(preset), // 'elevated' | 'flat'
scope: [playgroundCanvas], // rail → canvas · FAB → canvas|null · Portal → chrome|brand
features: { gaps: true, surface: true, panels: true }, // palette/corners/meta default on
onChange: (next) => persist(next) // host owns storage; editor is storage-agnostic
});
// controller: setPalette · setRadii · setGaps · setSurface · setPanels · setSemantics · setCharts · setMode · refresh · destroy
<!-- List card — emitted per preset by window.ThemeComposer.renderCardRow() -->
<div class="theme-card theme-card--row" data-theme-card="lifi-1">
<button class="theme-card-select" data-theme-card-select></button>
<span class="swatch-strip"><!-- N accent + surface .swatch--strip --></span>
<div class="theme-card-body">
<span class="theme-card-name">LI.FI 1.0</span>
<span class="theme-card-meta">Legacy Pink · LI.FI Blue</span>
</div>
<button class="theme-card-edit" data-theme-card-edit>…</button>
</div>
This catalog Preview is hydrated by the catalog-only ds-theme-composer.js — it mounts the real editor with scope:[host] + onChange as a no-op, so the doc demo paints only itself and never persists.
The engine stores seeds, never derived tokens. Each saved theme is a flat set of OKLCH seeds per mode; deriveTokens recomputes the full token map on every apply, so derivation logic stays the single source of truth. The non-palette axes (radii / gaps / surface / panels / semantics / charts) are optional — absent means the baseline default.
// Preset record — theme-presets[]
{
id: 'lifi-1', revision: 6,
name: 'LI.FI 1.0',
description: 'Legacy Pink · LI.FI Blue',
// display-only (competitors page / brand cards):
category: 'Cross-chain', website: 'https://li.fi',
source: { name, url }, palette: [ { hex, label }, … ],
// engine inputs — OKLCH seeds per mode (hue-parity: each accent's h matches across modes):
dark: { primary:{l,c,h}, secondary:{l,c,h}, tertiary:{l,c,h}, surface:{l,c,h} },
light: { /* same shape */ },
// non-palette axes (all optional; absent → baseline default):
radii: { card, panel, button, pill }, // button = RENDERED px (paint adds +4 anchor)
gaps: { x, y },
surface: { dark, light }, // per-mode L recess below the page seed
panels: 'elevated' | 'flat',
semantics: { hues, dark, light }, // per-role state-token coefficients (optional)
charts: 'spectral' | 'brand' // categorical series tier (optional; default spectral)
}
// localStorage keys (brand-neutral; pre-rename lifi-* migrated on read)
theme-presets // the preset list (cold)
active-theme // id of the active preset
brand-palette // the hot active seeds
Bundled merge. presets.js ships window.BUNDLED_THEME_PRESETS; store.load() installs them on first visit and appends any new bundled id on later visits — user-created themes are never overwritten. The seed default is DEFAULT_PALETTE (LI.FI 1.0). Nothing auto-applies on cold load (per the LI.FI 1.0 default rule); the composer paints in-session only.
Two views, one stage. The composer toggles data-tc-view="list" | "edit" on the .tc-stage element. The list manages saved themes (compact .theme-card--row cards); the pencil drills into the edit view, which mounts the shared editor pre-filled with the theme's seeds + axis values.
The customization axes
| Axis | Control | Token(s) painted | Mode-dep. | Default |
|---|---|---|---|---|
| Palette | OKLCH L / C / H sliders + hex picker per accent (Accent 1–3 + Background) | --accent-* · --surface-* · --text-* (full deriveTokens map) | Yes | LI.FI 1.0 |
| Corners | Soft / Standard / Sharp trio + Custom sliders + Pill toggle | --card-radius · --panel-radius · --button-radius | No | 16 / 16 / 12 |
| Spacing | Horizontal + Vertical gap discrete sliders | --grid-gap-x · --grid-gap-y | No | 16 / 24 |
| Surface | Canvas-contrast continuous slider | --surface-sunk | Yes | dark 1.5 / light 3 |
| Panels | Elevated / Flat option cards | --panel-surface · --panel-shadow · --panel-border | No | elevated |
| Semantic states | Default / Custom option cards + role swatch strip + L offset / Chroma × / Hue sliders | --success · --danger · --warn · --info (+ companions; --sem-* vars at :root, literals on a canvas) | Yes (lo / cm) | formula defaults (Default reverts) |
| Charts | Neutral / Brand-ramp option cards | --series-1..12 (never --spectral-*) | Yes (per-mode ramp literals) | spectral (fixed) |
| Text | Neutral / Tinted option cards + tint chroma / hue sliders | --text-primary/-secondary/-muted/-faint (rides palette.text) | Yes (per-mode tint) | neutral alpha ladder |
| Typography | Brand-font dropdown (Google Fonts; rows preview each family) | --font-sans + inline font-family on the scope | No | :root default family |
Four mounts · same primitive, different scope
| Mount | Container | Axes (features) | Scope (paints) | Persistence |
|---|---|---|---|---|
| FAB Theme Composer | .theme-composer--floating | palette · corners · surface · semantics · charts · text · font (+ spacing/panels on canvas pages) | content canvas, else :root | preset record + sitewide |
| Playground rail | .theme-composer--flush | palette · corners · spacing · surface · panels · semantics · charts · text · font | [.playground-canvas] | preset record |
| Playground Chrome row | a .theme-composer in a .modal-overlay (the rail Chrome row's edit pencil) | palette · corners · surface · semantics · charts · text · font (no gaps/panels — canvas axes) | [:root] (page chrome) | ephemeral (no-op) |
| Portal Settings | bare panel in .modal-overlay | all (panels = brand only) | chrome [:root] / brand [.portal-canvas] | ephemeral (no-op) |
Interaction behaviours
| Action | Effect |
|---|---|
| Click theme card body | Applies that theme (all axes) to the mount's scope; marks the card active. No drill-down |
Click pencil (theme-card-edit) | Drills to the edit view; mounts the editor pre-filled with name / description / seeds / radii / gaps / surface / panels |
| Slider / picker / option-card change | Live-save: updates state, re-derives + repaints to scope, persists via onChange. No Save button |
| Mode tab (Light / Dark) | Swaps the seeds shown and applies the mode to the page (when modeToggle is on) |
| Type name / description | Updates the card label + panel title on every keystroke (cap 40 / 80) |
| + New Theme | Deep-copies the active palette into a new user theme and drills into edit |
| Reset · Duplicate · Export CSS · Delete | Per-theme actions (store): re-seed from bundle · clone · copy derived :root + [data-theme=light] + radii · remove (user themes only) |
:root one flip later (the "overrides the whole page" leak). Canvas mounts build a local palette; the closure write is guarded if (!canvas).--surface-sunk is a deriveTokens output, so applyPalette re-writes a baseline on every paint — paintSurface() runs last to keep the custom recess. Corners / gaps are not in deriveTokens, so a single paint sticks.--button-radius is the lg-tier anchor; the slider value is written as value + 4 via ThemeComposer.buttonRadiusToAnchor. Don't simplify the offset away.design/theme-composer.md — the canonical architecture / composition / logic map. The per-axis paint contracts live in CLAUDE.md (Theme Composer scope · Radius system · Canvas grid-spacing · Surface contrast control · Panel primitive); this card is the rendering.Flip modifiers live and watch the chart regenerate. The same sample dataset drives every variant — what changes is interpolation, stacking, chrome, and fills.
Uses the same 6-month sample dataset as the area playground so you can A/B the two visualisations on identical data.
Lines only, no fill — compose curve type, dot style, labels, and hover behavior against the same 6-month sample dataset.