# CLAUDE.md — Project-level rules for Claude Code

This file is loaded automatically by Claude Code. Rules here apply to every session in this repo. The first half ("How to work with me" through "Win patterns / miss patterns") describes how I want to collaborate with Claude; the rest is the project's design-system rulebook.

## Who I am

- **Role:** Former Director of Design (Head of Design) at LI.FI — parent company of Jumper.xyz (formerly Jumper Exchange). Laid off June 29, 2026; this repo is now portfolio evidence in an active job search (live deploy: design.vilendesign.com). Career/resume work itself lives in the vilendesign.com repo (`content/profile.md` is the canonical fact base), not here.
- **Team (at LI.FI):** led a 7-designer org — 6 direct reports plus me (product + marketing designers).
- **Career:** 4 startups as founding designer + 1 own startup + 1 corporate (PayPal, senior). Specialties: branding (logos, palettes, typography), Web3 / iOS / Android UX, design systems, marketing collateral (decks, proposals, presentations, banners, merch), light web dev (HTML / CSS / some JS).
- **Current focus:** polishing this design system as a portfolio centerpiece — the LI.FI brand refresh + public website that doubles as an internal Design Systems site remains the flagship body of work it demonstrates.
- **Tooling pedigree:** deep Figma fluency — interlinked component libraries, variables for strings / colors / modes / tokens, modules, annotations.

## How to talk to me

Treat me as your **design peer**, not a junior asking for help. Give me honest, direct feedback that **challenges** my direction, with proposed alternatives — not just critique. Educate while doing: when you implement or recommend something, explain what's happening behind the scenes, including the engineering side. I'm learning every day.

When I ask "how should I do X?" — give me **options + tradeoffs + your recommended pick + why**, not a single decision. The rationale is part of the value; I'm learning as much as I'm building. This is the inverse of the default "one strong rec for exploratory questions" pattern — please follow this version on my account.

I welcome **proactive architectural suggestions** on the design-system level and on documentation best practices. Speak up.

## Cardinal rules

1. **Stay in scope.** Do exactly what I asked. Don't bundle helpful side-edits, don't scaffold for the future, don't add variants / states / sections / prose I didn't request. If you spot something out-of-scope worth flagging, mention it as a *follow-up suggestion* — never bake it into the diff. (I end up manually deleting unrequested additions, and it erodes trust fast.) **At the CSS-cascade level this means: when tweaking a value that lives on a SHARED primitive (an `.app-shell`-level token like `--rail-width`, a base-component default, anything multiple surfaces inherit), apply the change as a *scoped override on the consumer* (`body.portal-body { --rail-width: var(--w-4); }`) — not by editing the shared default.** I iterate fast on one surface; a shared-default edit silently changes every other consumer (and not-yet-converged surfaces). A scoped override is reversible and blast-radius-contained. Only edit the shared default for a deliberate system-wide decision, and surface it as such. (Validated June 2026 on the Portal rail width — I thanked Claude for scoping it.) **And scoped to the SPECIFIC consumer, not a shared parent.** When a token has multiple consumers (e.g. `--canvas-top` drives BOTH `.app-canvas`'s chrome padding AND `.app-rail`'s `top` position), a scoped override on a shared ancestor (`body.portal-body { --canvas-top: ... }`) still moves every consumer downstream — the body-level scope satisfies "not the shared default" but still affects every descendant that reads the token. The right move is a per-consumer property override on the ONE thing you want to move: `.portal-view__inner { padding-top: var(--space-32); }` pushes the content header without touching the rail. **Test before overriding a shared token:** *how many things downstream consume it, and do I want all of them to move?* If "just this one," reach for a consumer-specific property (or a narrower scope), NOT a token override at a shared parent. (Validated June 2026 on `--canvas-top` — three corrections in one session before the lever moved off the shared token onto `.portal-view__inner`; the rail kept getting dragged each time I tried to push only the header.) Sibling discipline: "Tokenize before excepting" + "Override → named modifier" below.
2. **OKLCH for all color work.** No HSL. No hex unless you're quoting an existing token. Palette explorations use OKLCH ramps.
3. **Light mode by default.** Design and review for light mode first unless I explicitly say otherwise.
4. **Design-system documentation consistency is sacred.** When adding or modifying a component, never let documentation visual / structural style drift from prior entries. Use the existing template / primitive (e.g. `.ds-doc-card` for the LI.FI catalog) and run the validator before claiming done. Pattern stability beats pattern improvement during a single addition.
5. **Verify before declaring done — and verify VISUALLY, never from the code alone.** Do NOT say a task or fix is "done", "working", "applied", or "fixed" based on the source, a `getComputedStyle` read, or a matched-rules walk by itself. **Code lies constantly:** a class is applied but overridden by specificity or source order; a token is undefined and `var()` silently resolves to 0 (or the initial value); a rule resolves correctly in the cascade yet the result is invisible, clipped, the wrong size, or behind another element; an `<img>` SVG ignores the `currentColor` you set. **For anything observable in the browser, LOOK at it** — `preview_screenshot` (or the equivalent), confirm with your eyes that the change is actually visible and correct, THEN report. A computed-style / DOM read is *necessary but not sufficient*: it proves a rule *resolved*, not that the result *reads on screen*. If a change isn't browser-observable, exercise it the closest way that proves the OUTCOME (run it, render it, test the behaviour) — never infer success from "the edit looks right in the diff." If you genuinely cannot verify, say so explicitly ("I changed X but haven't visually confirmed it") rather than implying done. **This is a cardinal rule because "it's done" has repeatedly meant "nothing visibly happened"** — the recurring failure this exists to kill. (Validated June 2026: the brand-book chapter `.panel` classes were "applied" per the matched-rules walk but didn't *read* on lifi-1's compressed surface ladder; only a screenshot caught it. Sibling: [[screenshot-before-defending]] — that's the *reactive* case (user contradicts a claim); this is the *proactive* one (verify before the first "done").)

## Git workflow — commit autonomy, push triggers, multi-chat hygiene

Negotiated May 2026. Goal: fewer micro-prompts, never silent surprises. The framework below supersedes any older memory or convention that says otherwise.

### Commit (autonomous, default ON)

Commit without asking when **all three** are true:

1. **The work is a coherent unit.** TodoWrite list reaches all-completed, the validator passes, the preview renders (when applicable). Mid-flow ("let me try X… ok actually do Y") never commits the abandoned half — fold it into the same unit or hard-reset what's authored. A unit is whatever should land or revert as one thing. **Scope the unit at the FEATURE level, not the micro-edit — don't commit too often (June 2026).** When iterating on ONE surface across several turns — a polish arc where each turn nudges the same component (reorder an element, then resize a slot, then tune a gap, then clean its docs) — those facets are ONE unit; they should land (and would revert) together. Hold the commit while the arc is still moving and land it ONCE when the surface settles, OR when the user says "commit." Committing each micro-edit as it lands turns one revert target into four and reads as commit-spam. The diagnostic: *if undoing "this change" means reverting N commits I made in a row on the same component, those N should have been one.* When unsure whether the arc is done, don't silently auto-commit each step — say "this feels like a complete unit — commit, or keep going?" and let the user call it. (Validated June 2026: four identity-tile polish commits in consecutive turns were squashed back into one after the user flagged the cadence — see [[feedback_commit_cadence]].)
2. **The diff is under the split threshold.** **≤ 8 files OR ≤ 500 lines.** Above either, pause and ask whether to split into multiple commits (one per concern). The threshold is a soft signal — still ask if the diff feels like multiple revert targets even at 4 files.
3. **No foreign-WIP collision** (see multi-chat protocol below).

Commit message: **`DS: <subject> — <details>`** style (matches the existing log). HEREDOC for body. `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` line stays (updated June 2026 from the older `Claude Opus 4.8 (1M context)` trailer — use the current model's name; older log entries keep theirs). **Never** `git add -A` / `git add .` — always explicit paths.

### Push (manual trigger, default OFF)

**Only "push" or "push it"** maps to `git push origin main`. No other phrasing fires push — not "ship", not "publish", not "looks good", not "land it". If both commit + push together is wanted, say "push it"; if commit-only, the auto-commit triggers above handle it.

Tightening this trigger is deliberate. "Ship" / "publish" used to fire push (older memory rule, retired May 18 2026). Push is the one truly irreversible action; the literal command word is the only safe trigger.

**A push to `main` PUBLISHES A LIVE PUBLIC WEBSITE, not just a git ref.** This repo is deployed: Cloudflare Pages project `li-fi-design-system` → **https://design.vilendesign.com**, wired to GitHub `main`, so **every push auto-deploys in ~30–60 s**. There is **no build step** — the Pages project serves this repo's files raw (build command empty, output dir `/`), which is why a new root `foo.html` is live at `/foo` the moment it lands, and why a broken file is live just as fast. As of July 2026 the middleware's `GATE_OPEN=true` stand-down flag is active, so the site serves **publicly with no password** — the Portal, the DS catalog, personas, and partner-org data included. Treat "push" as "publish to the internet": it is the moment the work becomes externally visible, so the pre-push checks above (read `git log origin/main..HEAD`, sanity-check foreign commits) are guarding a live site, not just a branch. Full hosting/gate/rotation detail — and the only place it's canonical — is [`DEPLOYMENT.md`](DEPLOYMENT.md); **read it before answering anything about hosting, deploys, domains, or access**, rather than inferring from the absence of a `vercel.json` / `netlify.toml` / `.github/workflows` (there are none — the config lives in the Cloudflare dashboard plus `functions/`).

**Pull-rebase before push.** When the cue fires, always run `git pull --rebase origin main` immediately before `git push`. This protects against the multi-chat race: another chat may have pushed to main while I was working, and a bare `git push` rejects with "non-fast-forward" — at which point the wrong reflex (a `--force` push) would clobber the other chat's commit. If `git pull --rebase` produces conflicts, **abort the rebase** (`git rebase --abort`) and stop — don't auto-resolve. The conflicts are evidence that two sessions edited the same code paths and the merge needs deliberate eyes. **Exception — conflicts ONLY on generated artifacts** (`dashboard-data.json` / `search-index.json` / `catalog-redirects.js` / `widget.html`): resolve by REGENERATING from the mid-rebase tree (the generator's output IS the canonical content), continue, then re-run all three generators post-rebase and amend — see [`git-workflow.md → "GENERATED-ARTIFACT-only conflict"`](.claude/skills/lifi-ds-docs/references/git-workflow.md). **Second exception — a both-append docs conflict** (two sessions appended INDEPENDENT sections at the same EOF of an append-only log like `bug-history.md` — zero semantic overlap): READ both sections to confirm independence, then resolve keep-both (concatenate, incoming then yours), continue, and surface what you kept — it's a positional collision, not a code-path one (validated in `git-workflow.md → both-append docs conflict`). Genuine code conflicts still abort.

**A rebase does NOT fire `.githooks/pre-commit` — regen the generated artifacts before pushing (June 2026).** `git pull --rebase` replays local commits verbatim, so any hook-generated snapshot they carry (`dashboard-data.json` · `search-index.json` · `catalog-redirects.js` · `widget.html`) still describes the PRE-rebase tree — silently stale against the incoming commits it now sits on top of. After any rebase that pulled in catalog / CSS / component work, re-run all four generators (`collect-dashboard-data.js` · `build-search-index.js` · `build-redirect-map.js` · `build-widget-page.js`), check `git diff -- dashboard-data.json search-index.json catalog-redirects.js widget.html`, and commit the refresh as its own small commit BEFORE pushing. (`widget.html` only needs the regen when the rebase pulled in `playground.html` changes — see its derivation note below.) Validated June 2026: rebasing the withdraw-fees arc onto a parallel machine's 10-commit mini-rail arc left `dashboard-data.json` + `catalog-redirects.js` stale (both sides had touched catalog pages); the regen landed as `532141c` ahead of the push.

**Push publishes EVERY local commit on the branch — including a parallel chat's committed-but-unpushed work (June 2026).** The commit-hygiene rules below guard against *sweeping* foreign work into MY commit; this is the mirror at push time. `git push origin main` sends the whole branch, so any commit a parallel chat landed on local `main` ahead of `origin` ships too — not just mine. **Before pushing, run `git log origin/main..HEAD --oneline` and read what's actually about to go out;** if a foreign commit is in the list, NAME it to the user (subject + sha) so publishing it is a seen decision, not a surprise. It's usually not a blocker — a parallel chat's *committed* work is normally meant to ship — but a still-WIP-feeling commit that merely happens to be committed deserves the heads-up before it's live on origin. Validated June 2026: a `push` published my orders-price-sync commit (`5c9bde5`) alongside a parallel chat's receiving-wallet-reorder (`67db10d`) that had been committed to local main; both were surfaced before pushing.

**Sanity-check a foreign commit before publishing it — not just NAME it (June 2026).** When the push list includes a parallel chat's commit you didn't author or verify — especially a LARGE one (many files) or one that landed FRESHLY (after the user's "push" instruction, so they haven't seen it) — run a cheap pre-push check IN ADDITION to naming it: `node --check` every touched JS file + an integrity grep for whatever this session's work depended on (e.g. after a rename sweep, grep that the foreign commit didn't reintroduce the old symbols). `git push` is the irreversible action; spending ten seconds confirming a foreign commit isn't grossly broken before it goes live is cheap insurance — you're publishing it under the shared branch, so its breakage becomes everyone's. If the check fails, STOP and surface (don't push broken foreign work); if it passes, push + report what you checked. Validated June 2026: a `push` would have published a parallel chat's 28-file engine-extraction commit (`0269403`, `theme-composer-core.js`) that landed AFTER the "push" cue; before publishing, `node --check` passed all 7 composer JS files + a grep confirmed the debrand stayed clean (no old `lifi*` symbols reintroduced) — then pushed.

**For a CSS / non-JS foreign commit, there's no `node --check` — and an absolute brace-balance count is the WRONG tool (June 2026).** A `{`/`}` count over a large `styles.css` reliably comes out *off-by-one or more* because of braces inside comments (`/* … { … */`) and `content:`/`mask`/`url()` strings — so a "balanced=False" result is almost always a pre-existing false positive, not the foreign commit's breakage. Don't reject a push on the absolute count. Instead: (a) compare the **delta** — brace balance at the foreign commit's parent vs. at the foreign commit (`git show <parent>:styles.css` vs `git show <sha>:styles.css`, count each); if the delta is 0 the commit is brace-neutral, and (b) confirm the commit's own diff adds/removes braces evenly (`git show <sha> -- styles.css | grep '^[+-]'` → sum `{`/`}` on `+` vs `-` lines). For touched HTML, run the catalog validator (`validate-doc.py --html <page>`) — that's the CSS/HTML analog of `node --check`. Validated June 2026: a CSS-only foreign commit (`16afbf1`, identity-tile reorder) tripped a naive `balanced=False` (3232/3231) that was identical at its parent and brace-neutral in its diff — a pre-existing false positive; validator passed 0-errors on all 3 touched catalog pages, so it published safely.

**Cross-machine: your local commits may ALREADY be on origin — verify before pushing (June 2026).** The mirror of the rule above, for the multi-Mac setup. The OTHER machine can pull your pushed work and build on top, so by the time you go to push from THIS machine, `origin/main` may already CONTAIN your session's commits (carried up inside the history the other machine pushed). `git log origin/main..HEAD` can then be **empty** even though you have commits that *feel* unpushed — and the only local-ahead commit may be a **parallel chat's**, not yours. So on a "push" cue, don't reflex-push: run `git merge-base --is-ancestor <my-sha> origin/main` for each of your session's commits (exit 0 = already upstream) AND `git log origin/main..HEAD` to see what's genuinely local-ahead. If your work is already on origin and the only local-ahead commit is foreign, **there's nothing of yours to push** — pushing would publish another chat's (possibly unfinished) work. Also expect the branch to **reconcile itself mid-check**: `origin/main..HEAD` can flip from `1` to `0` between two commands when another machine/chat syncs local `main` while you investigate — re-read the state fresh rather than acting on the first snapshot. Validated June 2026: a "check your commits and push" turn found both session commits (`83ecdd1` Portal corners, `e6a4417` doc-sync) already ancestors of `origin/main` via the other machine's history; the lone local-ahead `7f0e093` was a parallel chat's persona-connect-card commit and reconciled away on its own — net: nothing to push, and reflex-pushing would have shipped the parallel chat's work.

**Rebase / conflict recovery scenarios — see [`.claude/skills/lifi-ds-docs/references/git-workflow.md`](.claude/skills/lifi-ds-docs/references/git-workflow.md).** When `git pull --rebase` conflicts (after `git rebase --abort`), match the situation to one of the validated recoveries in the reference: a parallel chat *superseded* your unpushed work · a foreign genuine commit sits *beneath* yours · you *renamed a file* a parallel chat was editing · you *rebased a symbol rename* onto their pre-rename work · a parallel chat's *stopped `rebase -i`* blocks your commit (wedged `--continue` after a clean resolution + the hook contaminating the pick with your working-tree state — June 2026) · two sessions *claimed the same registry ID* (TC-check / B-number / `vN` races — published-wins, renumber the unpublished side incl. its in-flight commit message via `.git/rebase-merge/message` — June 2026) · a parallel session shipped the *SAME feature* and you must *port your design deltas onto their canonical base* (re-apply fresh, NOT cherry-pick — the deltas modify the same restructured surface; user arbitrates the divergence — June 2026). All are [[surface-DAG-impossible-ops]]-class — surface the situation + plan to the user before executing.

**Pull-rebase exception — confirmed-clean upstream (May 2026 refinement).** When `git fetch origin main` shows local is `0` behind, the pull-rebase is a strict no-op — but git refuses to enter pull-rebase with a dirty working tree, forcing a stash dance that itself can create transient index issues (see [`bug-history.md → \`git stash push -- <paths>\` — index inconsistency`](.claude/skills/lifi-ds-docs/references/bug-history.md)). In this case, **skip the pull-rebase and push directly.** The race window between `git fetch` and `git push` is small; if origin moves in that window, the push rejects with "non-fast-forward" — a safe failure (no data loss; recover by re-fetching and retrying). The defensive intent of "always pull-rebase" is preserved by the fetch step alone:

```sh
git fetch origin main
git rev-list --left-right --count origin/main...HEAD   # if "0 N", origin is clean
git push origin main                                    # push directly; pull-rebase no-op skipped
```

This shortcut is for the specific case where: (a) a fetch just confirmed 0 behind, AND (b) the working tree has uncommitted WIP that would block pull-rebase. When origin HAS moved or the tree is clean, follow the normal pull-rebase-then-push flow.

**Foreign-WIP blocks pull-rebase.** If the pull-rebase refuses with `cannot pull with rebase: You have unstaged changes` because foreign-WIP files from a parallel chat sit in the working tree, **stash those files by explicit path**, then rebase, push, and pop:

```sh
git stash push -m "foreign-WIP from parallel chat — auto-stash for push" -- <foreign-paths>
git pull --rebase origin main
git push origin main
git stash pop
```

The scoped `-- <paths>` is non-negotiable. A bulk `git stash` (no paths) would sweep my just-authored work AND the foreign WIP into the same stash, blending the two by the time of `pop`. With explicit paths, only foreign edits get stashed — my commits are already in the index (committed before the push attempt), so the working tree is left clean for the rebase.

**If the stash pop conflicts**, surface to the user immediately. The parallel chat may have committed a change to the same path while my push was in flight; the conflicts are evidence and need deliberate eyes — don't auto-resolve. `git stash list` keeps the stash entry around for inspection.

**Push-time multi-chat tree scenarios — see [`.claude/skills/lifi-ds-docs/references/git-workflow.md`](.claude/skills/lifi-ds-docs/references/git-workflow.md).** Two validated cases beyond the stash-by-path recipe: the foreign file set *grows mid-push* while a parallel chat writes (switch to a one-shot `git stash push -u`); and a parallel chat *committing mid-session resolves* your entanglement (re-read `git status` + `git diff` at each commit boundary — don't carry a stale "deferred" assessment). [[multichat-wip-separation]]

**A parallel chat's manifest-protection stash can SWEEP your uncommitted work — see [`.claude/skills/lifi-ds-docs/references/git-workflow.md`](.claude/skills/lifi-ds-docs/references/git-workflow.md).** When uncommitted work you verified vanishes from the working tree mid-session while sibling edits survive, run `git stash list` FIRST — a parallel chat's "protect ⌘K manifest" stash may hold it (shared with their files). Recover surgically (`git checkout stash@{0} -- <your-file>`, leaving theirs stashed) or re-apply from your own context; commit promptly. Commit-time twin: after stashing the foreign page to commit, a residual `search-index.json` diff is YOUR OWN entries if your file is a `build-search-index` PAGES page (`portal.html` is) — commit it, don't abort. [[multichat-wip-separation]] [[surface-DAG-impossible-ops]]

### Rollback protocol — undoing a commit

The right `git` command depends on whether the commit has been pushed. Reach for these by reflex:

- **Unpushed commit** (still local) → `git reset --soft HEAD~1`. Keeps the changes staged for re-work; nothing is lost. Use when "undo that last commit" means "I want to re-do it differently."
- **Pushed commit** (already on origin/main) → `git revert <sha>`. Creates a new commit that inverts the bad one; non-destructive, preserves history. Use when "revert that" or "that commit was wrong" comes after a push has already landed.

Never use `git reset --hard` on a pushed commit, never use `git push --force` to clean up history — both stay on the Hard refusals list. The destructive variants exist when the user explicitly asks for them (rare); reflex moves never go there.

**Caveat — a parallel chat may have committed ON TOP of your commit (June 2026).** `git reset --soft HEAD~1` / `--amend` target the current HEAD, which is only *yours* if nothing landed after it. **Before any reset/amend, run `git log --oneline -3` (or `git reflog`) and confirm HEAD is actually yours** — otherwise you undo *their* commit (their changes land staged in your index, masquerading as yours). Recovery + the companion "don't scramble to fix the hook's foreign-WIP generated-file sweep" lesson: [`.claude/skills/lifi-ds-docs/references/git-workflow.md`](.claude/skills/lifi-ds-docs/references/git-workflow.md). [[multichat-wip-separation]]

### Multi-chat hygiene (because work happens in parallel chats)

Another chat's WIP is the failure mode this protocol prevents. Before any commit:

1. **`git status` + scan modified files.** Classify each as **mine** (touched via Read / Edit / Write in this conversation) or **foreign** (modified but not by me in this chat). **"I edited this file" does NOT mean every diff line in it is mine (June 2026):** with 2+ parallel chats in flight, a file you touched can ALSO carry a second chat's hunks — and a foreign-marker grep calibrated to the one parallel feature you KNOW about can't clear it (a house-org-vocabulary scan cleared `portal.css`, which carried a different chat's mini-rail rework; `git add portal.css` swept it). The reliable certificate is per-file diff EXTENT: compare `git diff --stat <file>` against the size of your own known edit BEFORE `git add` (a 38-line append showing 145 changed lines = foreign hunks present → hunk-classify first), and re-check `git show HEAD --stat` after committing. Full diagnostic + the classifier's "rail"-matches-"trailing" substring trap: `bug-history.md → "TWO parallel features in one file"`.
2. **Stage selectively.** `git add <specific paths>` — only mine. Pre-commit hooks may auto-stage derived files (e.g. `dashboard-data.json` from `collect-dashboard-data.js`); that's fine because they derive from my changes. **The hook lives at `.githooks/pre-commit`** (configured via `core.hooksPath`), NOT `.git/hooks/` — so `ls .git/hooks/pre-commit` returning nothing does NOT mean "no hook." And it **re-runs the generators even inside a pathspec-scoped `git commit -- <my-path>`** — staging `dashboard-data.json` only when its content FINGERPRINT actually changed (`collect-dashboard-data.js --hook`, since `63622f8`: exit 10 = real change, staged; exit 0 = metadata-only churn, writes nothing) — so a pathspec commit does NOT keep a genuinely-changed derived snapshot out of the commit (fine; it's derived), and the "leaves the rest of the index untouched" guarantee below holds only for files the hook doesn't touch. The trap: if a parallel chat left a STALE staged `dashboard-data.json`, the hook's regen + your pathspec commit produce an `MM` tangle (staged ≠ HEAD ≠ working). Recover, once the working tree matches HEAD, with `git restore --staged dashboard-data.json`. (Validated June 2026 — personas transfer-history commit.) **Second trigger, same recovery (June 2026):** the identical `MM` tangle is also self-inflicted by a `git stash pop` — when you stash a parallel chat's WIP by path to protect an independent commit, then commit (the hook regenerates + stages `dashboard-data.json`), the later `pop` re-applies the stash's index snapshot on top of the now-moved HEAD and re-stages the *pre-commit* (stale) `dashboard-data.json`. No parallel chat staged anything — the stash → commit → pop order did it. The diagnostic tell is that the staged copy carries the OLD `lastCommitHash` while the working copy already matches HEAD; confirm with `git diff --quiet HEAD -- dashboard-data.json`, then `git restore --staged dashboard-data.json`. (Validated June 2026 — ui-amount-card `--sm`/`--lg` retirement follow-up: stashed a parallel chat's `styles.css`/`swap.css` elevation WIP by path, committed a catalog doc fix, and the pop re-staged the stale snapshot.) **Third trigger — clean single-chat pathspec commit (June 2026): the COMMIT can be fine and only the INDEX stale.** A pathspec commit whose hook regen staged a genuinely-changed `dashboard-data.json` recorded the FRESH snapshot into HEAD, but left the index entry at the stale pre-commit blob (the pathspec machinery restores index entries for paths you didn't name) — `MM` with nothing actually wrong. So on any post-commit `MM`: **verify HEAD first** (`git show HEAD:dashboard-data.json` meta vs working copy) before assuming the commit shipped stale data; if HEAD + working tree carry the fresh regen, the index alone is stale → the same `git restore --staged dashboard-data.json` closes it. (Validated June 2026 — the Site-preview `.host-nav` commit `d32e0d6`.)

   **Pre-check the staged index — `git add <mine>` does NOT clear a foreign entry already staged there.** `git add <my-paths>` only ADDS my paths; it doesn't unstage what a parallel chat already put in the index. A bare `git commit` then commits the **whole index**, sweeping that foreign entry into my commit under my message. Before committing, run `git diff --cached --name-only` (or read the `git status` first column for pre-staged `A` / `R` / `M` entries — a staged rename like `RM index.html -> home.html` is the canonical trap) to see EVERYTHING staged, not just what I added. If a foreign entry is present, scope the commit explicitly with a pathspec: `git commit -m "…" -- <my-path1> <my-path2> …` — a pathspec commit records only the named paths and leaves the rest of the index untouched, so the foreign work stays staged-but-uncommitted exactly as found. Recovery for an unpushed mistake: `git reset --soft HEAD~1`, then the pathspec commit. (Validated May 2026 — a parallel chat's pre-staged `index.html → home.html` rename got swept into a DS commit despite a clean `git add` of only my 4 files; recovered via `reset --soft` + pathspec commit.)

   **Cross-page generated artifacts entangle with foreign WIP — defer the manual regen (June 2026).** **Three of the four** hook-regenerated artifacts — `search-index.json` (`build-search-index.js`), `catalog-redirects.js` (`build-redirect-map.js`), AND `dashboard-data.json` (`collect-dashboard-data.js`) — derive in part from **every** catalog page, so regenerating any of them while a parallel chat has uncommitted WIP on a DIFFERENT catalog page sweeps that foreign page's state into your output, even though your own change touched only one page. **`dashboard-data.json` is NOT exempt** (corrected June 2026 — it was previously documented here as "safe because it derives from my changes"; that was incomplete): its `componentSections` is a `.comp-section` tally over `design-system/index.html`, and it's the SNEAKIEST vector — a foreign section added there *without* a `.ds-doc-card` passes a `search-index`/`redirects`-only neutrality check (those index `.ds-doc-card`s + anchors, not `.comp-section`s) yet still bumps `componentSections`. The tell: the regenerated artifact's diff carries entries for ids/pages you never touched (e.g. `index.html` card entries while you only edited `playground.html`). Clean move: **commit only your authored page, `git restore` the regenerated artifact back to HEAD, and DEFER the regen until the foreign WIP lands** — a clean regen then reflects only `main` + your change. Don't ship a cross-page artifact whose content describes an uncommitted foreign page. (Validated June 2026 — the `#ui-receive-panel` catalog card on `playground.html`: a parallel chat's uncommitted `index.html` put 4 foreign `tables-rich` / `table-panel` / `persona-connect-card` entries into the `search-index.json` regen; committed `playground.html` alone, restored the index, deferred the regen. NB: the `.githooks/pre-commit` hook only fires when `core.hooksPath` is set — on a fresh clone where it's unset the hook does NOT auto-regenerate these, so a stale generated file can ship silently. **`npm install` now wires this automatically** via the `postinstall` script in `package.json` (`git config core.hooksPath .githooks`), so any fresh clone is armed the moment dependencies install. The manual `git config core.hooksPath .githooks` stays the recovery path — run it if `npm install` was skipped, if the config got unset, or if you're investigating a "hook isn't firing" report on a teammate's machine.)

   **The `git restore` recipe above is DEFEATED by the hook when armed — stash-by-path is the commit-NOW answer (June 2026).** "Commit your page, `git restore` the artifact, defer" only works if you actually DEFER. If you must commit NOW with `core.hooksPath` set (the armed default), `git commit` re-runs `build-search-index.js` / `build-redirect-map.js` against the WORKING TREE — which still holds the foreign WIP — and re-stages the contaminated artifact, re-clobbering your `git restore` before the commit object is written. The restore never survives. The clean commit-now path is the [[multichat-wip-separation]] stash-by-path pattern (above, for push / pull-rebase) applied to the pre-commit hook: **stash the foreign catalog pages by explicit path** (`git stash push -m "…" -- design-system/<foreign>.html …`), commit (the hook now regenerates from a tree holding ONLY your change; if your change is manifest-neutral — avatar cells, a no-`id` eyebrow, anything the builders don't index — the regen produces ZERO diff, so nothing contaminated stages), then `git stash pop`. **Confirm the generated artifacts are clean BEFORE staging** after the stash: run `build-search-index.js` + `build-redirect-map.js` and check `git diff --quiet -- search-index.json catalog-redirects.js` (both are byte-stable, so any diff is real); for the dashboard run **`collect-dashboard-data.js --hook`** and read the exit code — 0 = no content change (writes nothing, tree stays clean), 10 = real change (it rewrote the file; inspect the diff for foreign entries before staging). **A `dashboard-data.json` regen is NEVER diff-quiet** (timestamp + `lastCommitHash` + rolling-cadence churn on every run) — don't gate it on `git diff --quiet`; CLASSIFY the diff: `componentSections` / entries for pages you didn't touch = contamination, timestamp / hash / cadence = benign churn (full entry: `bug-history.md → "dashboard-data.json regen ALWAYS diffs — classify the diff"`). **Don't run the dashboard collector BARE for this check** — a bare run always rewrites its volatile metadata (timestamps, commit cadence, changelog), so `git diff --quiet -- dashboard-data.json` false-positives on every commit; if you did run it bare and the diff is metadata-only, `git restore dashboard-data.json` (the hook's fingerprint would have skipped it — validated June 2026, the Portal org-wide-withdraw commit's check produced a 120-line metadata-only diff that was correctly restored, not committed). (A `search-index`/`redirects`-only check is exactly what let a foreign `.comp-section` ride into a commit — see `bug-history.md → "dashboard-data.json is a cross-page contamination vector too"`.) Validated June 2026 — an avatar-library commit (4 marks + new `avatars/payments/` + catalog cells) landed clean while a parallel chat's uncommitted `.profile-tile → .identity-tile` rename sat in `index.html` / `personas.html` / `styles.css`: stashed the three, confirmed zero manifest diff, committed the 7 avatar paths, popped. [[multichat-wip-separation]]
3. **Collision = stop.** If a file has BOTH my edits AND modifications I didn't make (the "file modified since read" linter notes, or someone else's intervening edit), pause and ask. Don't merge instincts.

   **Distinguish done-but-uncommitted from mid-development BEFORE picking a resolution.** Two collision shapes need different default responses:

   - **Done-but-uncommitted** — modifications to existing files only, no new untracked files in the working tree. The parallel chat finished its work and is between commits. Interleaved-hunk staging (per the resolution paragraph below) is appropriate after user approval.
   - **Mid-development** — modifications PLUS new untracked files supporting the area being edited (e.g., a new `*.js` introduced to back the markup I'm touching, or sibling helper files appearing alongside an HTML refactor). The parallel chat is genuinely mid-flight; the shape of their work may still change. Default to **wait for parallel chat to commit, then resume** — surface the situation, hold off on edits, spawn the planned work as a follow-up task chip if it's worth tracking. Authoring on top of WIP-with-untracked-files is fragile (the foreign work's API surface may shift), and interleaved-hunk staging is the wrong tool for mid-development collisions — its target case is "work that's done, just between commits." (Validated June 2026 — Orders panel dropdown-wiring session: `git status` showed `playground.html` modified PLUS new untracked `orders-data.js` + `orders-list.js`, both supporting the kebab area I was about to edit. The parallel chat had refactored the static rows into a JS renderer mid-stream; waiting for their commit was cleaner than authoring on top of an evolving renderer.)

   **Protect already-made independent work with a scoped commit — the sweep runs both directions (June 2026 refinement).** The "hold off on edits" default assumes you haven't started. If you've ALREADY made an independent, verified change to a file the parallel chat is ALSO editing (a fix to one CSS rule while they churn a *different* region of the same file), leaving it uncommitted is risky: a parallel chat's `git add <file>` sweeps YOUR uncommitted hunks into THEIR commit. The rest of this section guards against *me* sweeping *theirs*; this guards against *them* sweeping *mine*. When your change is independent (disjoint region) + verified + the file is contested, prefer a scoped `git commit -- <file>` (or the interleaved-hunk `git apply --cached` below) to PROTECT it, rather than holding it exposed. (Validated June 2026 — the menu-standardization session: a verified `.menu-item` button-reset, left uncommitted per a deliberate "hold everything" choice, was swept into a parallel chat's seg-radius commit `4c00e2e`. The fix survived intact but landed under their message — a provenance smudge that a scoped commit would have avoided. The user made an informed hold choice and the outcome was benign; the lesson is that "protect via scoped commit" is the lower-risk default when the work is already done.) **The verification pass is itself an exposure window (June 2026, second instance).** Re-hit on the directional network-title change: I made the edit, got approval to commit-only-my-hunks, then ran a browser verification pass *first* — and the parallel chat committed during that window, sweeping my uncommitted title hunks into its empty-state commit `091ac5f` (same benign outcome — work intact, landed under their message). The sharpened rule: once you've decided to commit-scoped, commit **before** the verify pass — the dev server serves the working tree, which equals the committed state, so verify the committed result. A verify-then-commit reopens exactly the window the scoped commit is meant to close.

   **Interleaved-hunk resolution (when the user approves staging only mine).** The stash-by-path pattern above separates *whole foreign files*. When foreign WIP is interleaved with mine in the **same** file — two features touching the same files in disjoint regions — path-separation can't help, and `git add <file>` would sweep the foreign hunks into my commit. After the user approves "stage only my hunks," the non-destructive move is **content-classify hunks and `git apply --cached` only mine**:

   ```sh
   # 1. classify each hunk by distinctive markers UNIQUE to my work (the new
   #    class / function names I authored). Positive-match beats negate-on-
   #    foreign-keyword: a keyword like "review" false-matches "preview", and
   #    camelCase ("getReviewModel") defeats \bword\b boundaries. Match what's
   #    unmistakably mine, not what's unmistakably theirs.
   # 2. reassemble the diff header + only my hunks into one patch
   # 3. dry-run, then stage to the INDEX only (working tree untouched):
   git apply --cached --check my-hunks.patch && git apply --cached my-hunks.patch
   git diff --cached | grep -i '<foreign-marker>'    # leak-check: must be empty
   git commit -m "…"                                 # commits index = my hunks only
   ```

   `git apply --cached` stages to the index without touching the working tree, so all foreign WIP stays uncommitted in the tree for the other chat. Disjoint hunks apply independently by old-side line number, so a subset applies cleanly against HEAD. Avoid `git add -p` — it's interactive and can't be driven reliably. Always leak-check the staged diff for a foreign marker before committing. (Validated May 2026: mode-nav work cleanly separated from a parallel chat's interleaved Review-screen WIP across 3 shared files this way.)

   **Edge case — foreign HEAD movement mid-staging.** If the parallel chat lands a commit between your `git apply --cached` and your `git commit`, your index gets reset (git ties the index to HEAD; HEAD moves → previously-staged hunks revert to "unstaged"). Symptom: `git status` shows files you thought you'd staged now appearing under "Changes not staged" again, and `git diff --cached --stat` is empty. Recovery: **re-stage from scratch** — re-`git add <path>` for pure-mine files, re-run `git apply --cached --recount <patch>` for mixed files. The working-tree content of your work is unchanged; only the index pointer moved. Line numbers in your hunk patches may have shifted if the foreign commit touched the same files, so regenerate `git diff <file>` and rebuild the patch before re-applying. (Validated May 27 2026: parallel chat's `.hint → .muted` v20 commit landed between my staging and commit of `.empty-state` work; re-staged across 9 files in one pass.)

   **Edge case — interleaved hunks WITHIN a single `@@` block.** When your `+` addition and a foreign `-` deletion fall close enough together that git combines them into one hunk (canonical case: sibling entries 4 lines apart in a sidebar / nav / list), `git apply --cached` of that hunk applies BOTH halves — you'd commit the foreign deletion alongside your addition. Four options at that point: **(a) skip the hunk and land it in a follow-up commit** — cleanest when deferring is cheap; **(b) hand-author a sub-patch** with only your `+` lines + adjusted context — risky, easy to get the line numbers wrong; **(c) ask the user; (d) stage a CONSTRUCTED BLOB** — when you know your own edits verbatim (you just made them via Edit in this session), build the staged content deterministically: `git show HEAD:<file>` → re-apply ONLY your replacements (assert each anchor is unique) → `git hash-object -w` → `git update-index --cacheinfo 100644,<blob>,<file>`. No line-number risk, no patch arithmetic, working tree untouched (the foreign half stays for the other chat). **Prefer (d) over (b) whenever your edits are replayable from context; fall back to (a) when they aren't.** (Validated June 2026 — the setting-row consolidation: a `theme-composer.md` bullet edit and a parallel chat's adjacent vignette rewrite merged into one hunk; the constructed blob staged HEAD + my three replacements cleanly.) (2nd validation June 11 2026 — the accordion retirement: after a parallel chat's push-protocol stash/discard/restore churned `styles.css` mid-turn, the blob staged HEAD + the one retirement replacement around the restored vignette WIP; also the right tool for whole disjoint-region files, not just merged single hunks — see `git-workflow.md → "The working tree can mutate BETWEEN consecutive commands"`.) (Validated May 27 2026: the `<a href="#empty-state">` sidebar entry skipped because its hunk also contained the parallel chat's `<a href="#hint">` deletion; the entry landed in a follow-up commit after the parallel chat's sidebar move shipped.)

   **Edge case — `git commit -- <pathspec>` DEFEATS `git apply --cached` staging.** After surgically staging only your hunks via `git apply --cached`, run `git commit -m "..."` **WITHOUT** a pathspec. A pathspec changes commit semantics from "commit the staged index" to "commit everything HEAD-vs-worktree for the named paths" — silently bypassing the index and sweeping every foreign hunk in those files under your message. The pathspec FEELS like extra safety ("commit only these paths") but is the trap: it overrides the protection the index was providing. **The staged index alone is the protection** — once you've used `git apply --cached`, the only safe commit is a bare `git commit`. Recovery if you hit it (unpushed): `git reset --soft HEAD~1` (the parallel chat's WIP comes back into the staged index alongside yours), `git restore --staged <contested-file>`, re-`git apply --cached` your hunk patch, then `git commit` with no pathspec. The leak-check (`git diff --cached <file> | grep <foreign-marker>`) is empty AFTER the surgical staging but the pathspec commit goes around it; ALWAYS verify post-commit with `git show HEAD --stat -- <contested-file>` against the staged stat — if the line count diverges from `git diff --cached --stat`, the pathspec swept foreign work in. (Validated June 5 2026 — `ds-footer.js` extraction: parallel chat had ~1,200 lines of doc-preview WIP staged in marketing.html; my 45-line footer hunk staged cleanly via `git apply --cached`, then `git commit -m "..." -- <12 paths>` swept their 1,200 lines under my message, recovered via soft-reset + re-stage + clean bare `git commit`.)
4. **Wrap-up report.** Every commit response ends with a line of the form `Committed: <paths>. Left untouched: <paths>` so the state of every other chat's work is visible at the seam.

**Escape hatch:** if a session is genuinely single-chat and the protection isn't wanted ("just commit everything in `git status`"), say so for that turn.

### Hard refusals (never autonomous, even with full commit trust)

These always need explicit user confirmation, even when the rest of the framework says "go":

- `git push --force` (any force-push)
- `git reset --hard`, `git clean -fd` on uncommitted work I didn't author
- `git rebase -i` / amending pushed commits
- `--no-verify` / `--no-gpg-sign` to skip hooks
- Branch deletion, tag deletion, history rewrites
- Anything that affects shared refs beyond a normal `git push origin main`

When one of these is the literal right answer, I'll explain why and ask before doing it.

### Documentation surface vs. canonical source

When a policy / framework / spec gets a visual rendering in the catalog (e.g., `ai.html#git-workflow` rendering the git rules above), **the canonical source stays in the file that's loaded into every session** — usually `CLAUDE.md`, sometimes `design.md` or a per-skill `SKILL.md`. The visual rendering is the display surface, not the definition.

**Why this matters.** Drift creeps in fast when there are two sources of truth. The rule evolves in `CLAUDE.md` (loaded into every session at first prompt), the catalog visual goes stale, contributors who scan the catalog see the stale version, the system's behavior says one thing and the docs say another. Same trap that compromised the dashboard's caption-overrides narrative until I grep'd the real distribution.

**Contract for documentation-as-rendering.**

- The catalog/visual page **explicitly points at the canonical source** at the bottom of the section — a "Source of truth: `<filepath> → <section>`" footnote so readers know which one wins on disagreement.
- When the canonical rule changes, **refresh the rendering in the same commit** (or flag it as a follow-up if the rendering needs design work).
- **Never let the catalog become the source.** If a contributor wants to evolve the rule, the canonical file is what they edit; the rendering follows.

Same pattern applies to the design-system dashboard (`dashboard-data.json` is the rendered snapshot, the live codebase is the source) and to skill SKILL.md → catalog pairings.

### Maintaining CLAUDE.md itself + `.claude/` reference docs

**CLAUDE.md is loaded into every session — keep operative rules inline, relocate the archaeology.** The canonical-source-vs-rendering rule above applies to *this file*: a rule's bug-history chronology, the validated war-stories, and the `vN` migration ladders are *renderings* (the evidence that produced the rule), not the rule itself. Keep the **operative every-time instruction** inline; relocate the narrative — verbatim — to its tracked reference (`bug-history.md`, `git-workflow.md`, etc.) behind a one-line dispatch pointer that still NAMES the scenario so it stays discoverable. **Verify the target is already mirrored (or move the text there) BEFORE trimming** — never trim toward a dangling anchor, and never relocate a paragraph that's the canonical *source* of a pointer elsewhere (e.g. the `.btn-alpha` border-slot narrative, which `bug-history.md` points *to*). Lossless by construction; the always-loaded file gets denser without losing anything. (June 2026 — a ~4.1k-token pass: relocated git rare-scenario recoveries → `git-workflow.md`, consolidated duplicated bug-history chronologies to one-line pointers, and collapsed the retirement ladder v10–v22 to a name-list + pointer, keeping v24 inline because it carries live contracts.)

**Repo docs under `.claude/` MUST live under `.claude/skills/…/` to be tracked.** `.gitignore` ignores `.claude/*` and re-includes only `.claude/skills/` (+ `**`) — so a new reference/doc placed at a top-level path like `.claude/references/…` is **silently gitignored**: it never commits, and any pointer to it from a tracked file dangles for teammates and your other Macs. Put shared `.claude/` docs alongside `bug-history.md` at `.claude/skills/lifi-ds-docs/references/` (the only tracked references area). Confirm with `git check-ignore <path>` before pointing a tracked file at a new `.claude/` location.

## CLAUDE.md — 12-rule template

These rules apply to every task in this project unless explicitly overridden.
Bias: caution over speed on non-trivial work. Use judgment on trivial tasks.

### Rule 1 — Think Before Coding
State assumptions explicitly. If uncertain, ask rather than guess.
Present multiple interpretations when ambiguity exists.
Push back when a simpler approach exists.
Stop when confused. Name what's unclear.

### Rule 2 — Simplicity First
Minimum code that solves the problem. Nothing speculative.
No features beyond what was asked. No abstractions for single-use code.
Test: would a senior engineer say this is overcomplicated? If yes, simplify.

### Rule 3 — Surgical Changes
Touch only what you must. Clean up only your own mess.
Don't "improve" adjacent code, comments, or formatting.
Don't refactor what isn't broken. Match existing style.

### Rule 4 — Goal-Driven Execution
Define success criteria. Loop until verified.
Don't follow steps. Define success and iterate.
Strong success criteria let you loop independently.

### Rule 5 — Use the model only for judgment calls
Use me for: classification, drafting, summarization, extraction.
Do NOT use me for: routing, retries, deterministic transforms.
If code can answer, code answers.

### Rule 6 — Token budgets are not advisory
Per-task: 4,000 tokens. Per-session: 30,000 tokens.
If approaching budget, summarize and start fresh.
Surface the breach. Do not silently overrun.

### Rule 7 — Surface conflicts, don't average them
If two patterns contradict, pick one (more recent / more tested).
Explain why. Flag the other for cleanup.
Don't blend conflicting patterns.

### Rule 8 — Read before you write
Before adding code, read exports, immediate callers, shared utilities.
"Looks orthogonal" is dangerous. If unsure why code is structured a way, ask.

### Rule 9 — Tests verify intent, not just behavior
Tests must encode WHY behavior matters, not just WHAT it does.
A test that can't fail when business logic changes is wrong.

### Rule 10 — Checkpoint after every significant step
Summarize what was done, what's verified, what's left.
Don't continue from a state you can't describe back.
If you lose track, stop and restate.

### Rule 11 — Match the codebase's conventions, even if you disagree
Conformance > taste inside the codebase.
If you genuinely think a convention is harmful, surface it. Don't fork silently.

### Rule 12 — Fail loud
"Completed" is wrong if anything was skipped silently.
"Tests pass" is wrong if any were skipped.
"Done / applied / fixed" is wrong if you only checked the code — **verify it VISUALLY first** (Cardinal rule 5). A resolved rule is not a visible result.
Default to surfacing uncertainty, not hiding it.

## Stack defaults

I'm not loyal to any framework. My design-taste anchors are **Tailwind, Shadcn, MUI, Material Design, Apple HIG** and other major design systems. Light engineering visibility on my side — verify build / runtime details against the repo before assuming, and explain the "why" when you use them.

For the LI.FI repo specifically: **Vite ^8** dev server, **npm** package manager, vanilla HTML pages with shared CSS / JS files (no React / Vue framework). Verify on the personal machine after cloning.

## Win patterns / miss patterns

- **Repeat:** real-time visual feedback tools (the OKLCH Theme Composer is the reference). When proposing color / type / spacing tooling, lean toward live-preview interactives over static decisions.
- **Never repeat:** doc style drift when adding new components — it has cost me manual rework. Use the canonical primitive every time.

## Design System

### The bundled default brand is the only default — never auto-apply any other preset

**Hard rule, no exceptions.** The CSS `:root` block in `styles.css` is the sole source of first-paint colours, and it always renders the DEFAULT brand — **LI.FI 1.0**, the approved LI.FI Brand Book (Figma `Sa2BL51sITY5vsfq8AXnQu`, FINAL deck — Core colours node `508:2`, Typography `531:67/106`). Five colours: **Sapphire `#405CCF`** (action — links/CTAs/data, ~30%) · **Pink `#F7C2FF`** (signature, ≤10%) · **Ink `#0C0E2E`** (surfaces + text, ~60%) · **Paper `#FAFBFF`** (light surface) · **Slate `#5C6070`** (secondary text on light). Typography **Geist + Geist Mono**. (June 2026 — this approved refresh became THE brand and the default; it supersedes BOTH the legacy blue/Figtree direction AND the interim cobalt "LI.FI 2.0" review preset `lifi-classic` — both retired. The book refined the action colour cobalt `#3B43D6` → Sapphire `#405CCF`.)

**Token names track the palette (renamed June 2026).** The brand-seed tokens are named for the official palette: `--lifi-sapphire` = **Sapphire** (primary), `--lifi-pink` = **Pink** (secondary, was Signal Pink), `--lifi-ink` = **Ink** (tertiary, was Midnight Ink) — mapped to roles via TC2's SEED_MAP. This replaced the prior misleading legacy names (`--lifi-blue` / `--lifi-teal` / `--lifi-pink`) in a single atomic Scheme-B sweep; the `--lifi-` brand-seed prefix is kept to distinguish seeds from role tokens (`--accent-*` / `--surface-*` / `--text-*`). The 9-step ramps stay `--midnight-*` / `--sapphire-*` / `--signal-*` — internal scale names, no product consumers yet.

- `--lifi-sapphire:  oklch(52% 0.18 269)` (both modes) — Sapphire `#405CCF`. (June 2026: the dark lift L0.66→L0.52 was dropped so the filled-CTA label auto-flips to white AND passes AA on the dark page — white-on-L0.66 was 2.86:1, on native L0.52 it's 5.14:1. `--lifi-sapphire-mid` follows the seed at runtime (deriveTokens = seed+10L → L0.62 in both modes now; static dark literal lowered 0.76→0.62 to match, no FOUC); accent TEXT on the dark page stays AA at 5.16:1. The seed↔mid split is the lever for "white CTA label" vs "legible accent text on ink": both ride L0.52/L0.62 now and both pass AA.)
- `--lifi-pink:  oklch(88% 0.1 322)` (both modes) — Pink `#F7C2FF`
- `--lifi-ink:  oklch(66% 0.063 276)` (dark) / `oklch(24% 0.066 276)` (light) — Ink
- surfaces: **Ink dark page** (`oklch(18.5% 0.063 276)` ≈ `#0C0E2E`) / **Paper light page** (`oklch(99% 0.007 277)` ≈ `#FAFBFF` — lightened L98→L99 June 2026, designer call). The light **card** tier is fixed pure white (`oklch(100% 0.0021 277)`), so white cards lift ABOVE the pale-blue paper page via a 1% tonal step + shadow (the 2.0 model — page ≠ card in light). Hue ~276 (ink) / 277 (paper).
- text: **tinted opaque ladder** (the `text`-tint axis IS active on the default brand now — Slate `#5C6070` secondary on light; support tones `#A9AEDC` / `#6A70A0` on ink). This activates `check-theme-tokens.py` TC5 — the `--text-*` literals are the tinted derivation, not neutral-alpha.
- **Official 9-step ramps (June 2026)** — the three brand colours also exist as full hand-tuned scales in `styles.css :root`: **`--midnight-1…9`** · **`--sapphire-1…9`** · **`--signal-1…9`** (the exact 2.0 Brand Book hexes, Figma `409:75`, converted to OKLCH; anchors coincide with the seeds — sapphire-6 = `--lifi-sapphire`, signal-2 = `--lifi-pink`, midnight-9 = Ink). They are **static LI.FI-1.0 constants** (NOT `deriveTokens`-derived, NOT per-brand) — naming deliberately avoids the neutral `--ink-0…900` alpha ladder. Documented in `brand-guide.html #color` (ramp displays + the surface scale) and synced to the Figma **Foundation 2.0 [Library]** colour collection. No product consumers yet (a tints/shades layer is an opt-in follow-up); reach for them when a brand tint/shade is genuinely needed rather than hand-mixing.

**The brand book rendering.** `design-system/brand-guide.html` is the themeable LI.FI Brand Book — a rebuild of the Figma FINAL deck, wrapped in `[data-palette-canvas]` so the Theme Composer can re-skin it; pinned to light mode (a brand book is a fixed paper-and-ink DOCUMENT artifact — the `.doc-*` light-pin precedent), so the composer switches the *palette*, never the mode. It renders the default brand by default. The Figma colour sync (`lifi-figma-color-sync` → `export-color-tokens.mjs`) was re-run June 2026 for the new default + the 9-step ramps → the **Foundation 2.0 [Library]** mirror is current (70 tokens). Still tracked: the deep design.md §02 color-chapter rewrite to the new brand (use-case signals, brand gradient, spectral anchor, two-layer system); and the Brand Book file's own self-contained local colours collection isn't re-synced yet (only the Foundation Library was).

NO OTHER preset is ever auto-applied — LI.FI 1.0 (`lifi-1`), Jumper, partners, competitors all stay *selectable only* via the **Theme Composer**, as in-session changes. The page never starts with a non-default preset on load, regardless of what's in localStorage.

**Why this rule.** The brand must look the same to every visitor on every load. Auto-restoring a composer pick from localStorage (a) flashes the page default → picked theme (FOUC), and (b) makes the brand silently shift for any visitor who once tried the composer. Both are unacceptable on a brand surface.

**Contributing.** When adding any palette-related code, follow these constraints:

- **CSS `:root` defaults must always be the DEFAULT_BRAND_ID preset's OKLCH values** (currently `lifi-1` rev 11 — `scripts/check-theme-tokens.py` TC2 enforces the seed mirror, TC5 the text-tint mirror, TC7 the font mirror). Don't introduce `--lifi-*` tokens that paint a different brand by default.
- **Don't read `brand-palette` or `active-theme` from localStorage in any pre-paint or DOMContentLoaded path** that mutates `:root` (these are the Theme Composer's keys, debranded June 2026 from `lifi-brand-palette` / `lifi-active-preset` — migrated on read). The composer's `applyPresetById` is the only function that should mutate `:root` style — and it only fires on user click.
- **Don't add stylesheets that redefine the brand palette** (e.g., the retired `lifi-design-system.css` that flashed 2.0 hex values before `styles.css` cascaded). One cascade source = `styles.css`.
- **Don't add a "remember and restore" feature for the composer without explicit brand-review approval.** The current behaviour — composer change applies in-session, doesn't survive reload — is the correct default per this rule.

**Where the rule is enforced.**

- `styles.css` `:root` block — DEFAULT-brand OKLCH values (search `--lifi-sapphire:` for the canonical block).
- `shared.js` bootstrap IIFE — sets `data-theme` only; explicitly does NOT read `brand-palette` from localStorage.
- `shared.js → initBrandPalette()` — always starts from `DEFAULT_PALETTE` (single-sourced from `DEFAULT_BRAND_ID` = `'lifi-1'`); the saved palette in localStorage is never auto-applied. Additionally (June 12 2026) it RESETS `active-theme` to `DEFAULT_BRAND_ID` on every load — `active-theme` is written by every pick (sitewide AND canvas-scoped) but nothing re-applies it, so a persisted pick would make every composer/rail active-highlight lie about what's rendered (the bug the June 11 flip exposed: the rail highlighted LI.FI 1.0 while the page rendered the 2.0 default).
- `presets.js` — `lifi-1` carries the canonical default seeds (rev 11: Sapphire action / Pink signature / Ink fields / Paper light surface, Geist + Geist Mono, text-tint + semantics axes). The legacy blue/Figtree `lifi-1` and the cobalt `lifi-classic` "2.0" preset were both retired June 2026.
- `design.md §02 → "LI.FI 1.0 is the only default"` — full spec with the bug-history that prompted this rule (the no-auto-apply machinery; the section predates the June 2026 default flip).

### Jumper sibling brand — inside the DS until the formal split, three differentiation levers

Jumper (jumper.xyz, the consumer-app sibling) lives **inside this design system** — same components, same dev + design resources — until the brands formally split (months out, June 2026). Differentiation is exactly **three levers**; everything else is shared:

1. **Theme presets** — `jumper-1` / `jumper-2` in `presets.js` (selectable, never auto-applied; the LI.FI-1.0-default rule above is unaffected).
2. **Logo brand tokens** — `--jumper-indigo` (logo body + wordmark: `#31007A` light / `#653CA2` dark) + `--jumper-purple` (accent spark + lockup label: `#8700B8` light / `#B49BDA` dark), defined in `styles.css` (`:root` dark + `[data-theme="light"]`), mode-paired to Jumper's own published tokens. **These are brand CONSTANTS, not composer accents** — never wire logo fills to `--accent-*`, and never bake the hex into a consumer (the literals live only as `var()` fallbacks inside the asset files so `<img>`/downloads stay brand-correct). **One deliberate exception — the playground site-PREVIEW host nav (June 2026):** that Jumper mark is a *mockup* showing the widget under a themed host site, so it FOLLOWS the active theme (spark → `--accent-primary`, body → `currentColor`/`--text-primary`) and re-tints with the canvas. The rule still holds for every SHIPPING surface — the `logos/jumper/*.svg` assets, `.brand-lockup`, and downloads keep the `--jumper-*` constants. The carve-out is scoped to the inline preview SVG only (`playground.html` + the `#host-nav` catalog preview); spec: `design/components/navigation.md → Host nav`.
3. **Typography** — `--font-jumper` (Urbanist; same fallback stack as `--font-sans`). The swap happens at the **brand-surface level**, never per-component; type scale, weights, tracking, and `--font-mono` stay shared. **Pages consuming the token statically must load Urbanist themselves** (Google Fonts `family=Urbanist:wght@300..900` — currently `design-system/foundations.html` + `assets.html`); unloaded pages silently fall back to system sans. **The Site-preview host nav no longer pins `--font-jumper` (June 2026)** — its tabs are seg-items reading `--font-sans`, so the bar's font (like its colours + logo) follows the active theme; the standalone `.host-nav--jumper` font pin + its `initRailNavPreview` `ensureFontLoaded('urbanist')` lazy-load + the `index.html` static Urbanist `<link>` were all retired (atomic, the pin was inert once the tabs became seg-items). **Applying a Jumper THEME paints the brand's in-widget UI font via the Theme Composer's typography axis** (and needs no per-page load) — **both `jumper-1` and `jumper-2` carry `font: 'inter'`** (changed from Urbanist June 2026, designer's call, to match the official Foundation "Colors (Jumper)" widget mocks, which typeset their UI in the `UI (Inter)` styles — the canonical source); applying either lazy-loads Inter + paints `--font-sans` on the scope. **`--font-jumper` (Urbanist) is a SEPARATE concern** — the lockup / marketing constant, NOT the in-widget UI font; it stays Urbanist regardless of the preset's UI-font axis. (So picking a Jumper brand now renders the nav + widget UI in Inter; lockups always Urbanist.)

**Lockups compose the shared `.brand-lockup` primitive** + the `.brand-lockup--jumper` modifier, which owns exactly three label deltas (Urbanist · accent-2 colour · its own optical baseline nudge — the JUMPER wordmark's baseline sits at 18/25 of its box vs LI.FI's 36/48; see `bug-history.md → Lockup label "sits low"`). Don't fork further. Sub-products (JumperPass / JumperScan / JumperLearn) render as the JUMPER wordmark + a quiet label — never typeset the compound name as one wordmark.

**Where everything lives.** Assets: `logos/jumper/` (mark · horizontal · stacked; token fills with light-mode fallbacks) and `logos/lifi/` (monochrome `currentColor`). Catalog: `design-system/assets.html#brand-lockup` (both families, per-row + batch SVG export via `shared.js → initBrandLockupExport`'s per-brand `BRANDS` map) + `#logo-guidelines` (the formal brand-book layer: clear space ½H, minimum sizes 16/20/48px, colour application — values are proposed defaults pending designer ratification). Typography: `design-system/foundations.html#jumper-typography` (incl. the shared-vs-differentiated brand-overlap contract table — the extraction checklist for the eventual split). Spec: `design/components/imagery.md → Brand lockup`.

### Audience priority — Designers > Engineering > Marketing

The design system has a fixed audience ordering, and every visual / editorial decision answers it:

1. **Designers** — primary. They scan the catalog by *variant name* and a glance at the live preview. They want to recognise components instantly without reading prose.
2. **Engineering** — secondary. They arrive with the component name in mind and click into Markup / Specs / Source for implementation detail.
3. **Marketing** — tertiary. They use the catalog as a brand reference (button style, accent treatment, palette).

(AI scrapers parse the DOM directly — they're served by structure, not visual hierarchy.)

When a question comes up — "should this be bigger / brighter / more compact / more verbose?" — answer it from the designer's perspective first, the engineer's second, the marketer's third. Drift comes from optimising for engineering convenience (more tables, more code blocks, more prose) at the cost of designer scanning speed.

The visual hierarchy on every `.ds-doc-card` card reflects this: the **variant name** (`.ds-doc-card__label`) is the dominant element (h3-scale, sans, primary text colour), the caption is the supporting line (sm, muted), and the engineer-facing detail (Markup / Specs / Anatomy / Rules / Source) sits behind a tab strip on the same frame. Full rationale in `design.md §13 → Audience priority`.

### Visual separation between sections, components, variants — automatic

Three nested layers, each with its own separation recipe. **Don't author dividers, `<hr>`, or extra spacers between cards** — the CSS handles every layer.

| Layer | Markup | Separation |
| --- | --- | --- |
| **Section** (Buttons / Cards / Forms / …) | `<section class="section" id="…">` | 240 px section padding (already strong) plus an optional `.section-divider`. |
| **Component group** (within a section — *Primary actions* / *Secondary actions*) | `<h2 class="ds-doc-subsection">…</h2>` | Quiet uppercase-mono eyebrow with a hairline rule above. Use only when a section genuinely splits into named groups. |
| **Variant card** (each `.ds-doc-card`) | `<div class="comp-section ds-doc-card">` | 80 px gap + 1 px hairline divider between adjacent cards. Driven by `.ds-doc-card + .ds-doc-card`. |

The variant-card divider is the one that matters most — long sections like `#buttons` (5–10 cards) used to feel like cards merged into each other. The 80 px gap + hairline gives an unmissable boundary without heavy chrome.

If two adjacent cards still feel merged, check they're direct siblings — the `+` combinator is direct-sibling-only. Mobile drops the gap to 48 px; print collapses the divider; the CSS handles those breakpoints. Full rationale in `design.md §13 → Visual separation` and the comment block above `.ds-doc-card + .ds-doc-card` in `styles.css`.

### Section chaptering & auto-TOC — when a section grows past ~6 cards

Heavy sections (currently `#inputs`, `#cards`, `#hero`, `#chips`, `#authoring-guide`) are **chaptered** with `<h2 class="ds-doc-subsection" id="<section>-<chapter>">` markers placed as direct children of the section's `.container`. The auto-TOC (`shared.js → initDsSectionTocs()` + `.ds-doc-section-toc` in `styles.css`) renders a horizontal chapter strip at the top of any section with 2+ such markers and scroll-spies as the reader scrolls.

**Authoring contract — when adding a new card:**

- **If the target section is chaptered**, pick the chapter before authoring and place the card after that chapter's marker (and before the next chapter's marker, if any). Today's chapter ids:
  - `#inputs` → `inputs-text` · `inputs-sizes` · `inputs-modifiers` · `inputs-selection` · `inputs-composition`
  - `#cards` → `cards-stat` · `cards-feature` · `cards-specialised` · `cards-behavior`
  - `#hero` → `hero-anatomy` · `hero-variants` · `hero-composition`
  - `#chips` → `chips-pills` · `chips-delta` · `chips-themed`
  - `#authoring-guide` → `auth-skill` · `auth-audience` · `auth-typography` · `auth-panes` · `auth-structure` · `auth-chrome`
- **If the section isn't chaptered yet but has more than ~6 cards** after your addition, chapter it in the same edit. The validator warns (W009) when a section has 7+ cards and 0 chapters — heed the warning. Authoring guide is in `references/procedure.md → Adding chapters to a growing section`.
- **Never add a one-off chapter to fit a single new card.** Refine the card's scope to fit an existing chapter, or wait until at least two cards share the new theme before adding a chapter.

Chapter ids must follow `<section>-<chapter>` shape, kebab-case (W008 in the validator). Chapter markers nested inside a card's pane don't register as section chapters — the JS uses `:scope > h2.ds-doc-subsection[id]` so only direct children count.

Full pattern + the future multi-page migration path in `design.md §13 → Section chaptering & auto-TOC`. The primitive itself is documented in the catalog at `#section-toc`.

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

| Tier | Prefix | Use for |
| --- | --- | --- |
| 1 | `.ds-*` | Catalog-meta primitives that build the DS itself (`.ds-doc-card`, `.ds-doc-text`, `.ds-doc-list`, `.ds-doc-codeblock`, `.ds-doc-controlbar`, `.ds-doc-layout`, `.ds-doc-partner-logo`). Never used on product surfaces. |
| 2 | *(none)* | Universal cross-surface primitives — used on product, marketing, dashboards, and catalog demos alike. Examples: `.btn-primary`, `.chip`, `.code-chip`, `.input`, `.select`, `.textarea`, `.checkbox`, `.switch`, `.form-card`, `.form-group`, `.field-inline`, `.tab-nav`, `.feature-card`, `.accent-card`, `.tile`, `.slider`, `.setting-row`, `.btn-wallet`, `.split-pane`, `.timeline`. |
| 3 | `.ui-*` | Product-surface primitives — narrower than universal; live on product pages (swap, future products) but not on marketing or DS-meta surfaces. First domain established Apr 2026 with the swap-widget rebuild: `.ui-card`, `.ui-amount-card`, `.ui-token-pill` (retired → `.chip-avatar`), `.ui-token-row`, `.ui-quote-card`, etc. (`.ui-screen-header` was here too until the v13 promotion to `.screen-header` — see migration history.) |

When adding a new primitive, ask in this order: *(a)* would this ever be used outside the catalog? If **no**, tier 1 (`.ds-*`). *(b)* Would this ever appear on a marketing page or be useful on a non-product surface? If **yes**, tier 2 (no prefix). *(c)* Otherwise tier 3 (`.ui-*`) — product-surface only.

**Why `.ui-` and not per-domain prefixes (`.swap-`, `.lend-`):** single namespace simplifies the convention. Domain context comes from composition (which page uses the primitive) and from the catalog section it's documented in (Swap, future Lend, etc.), not from the class name.

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

**Migration history.** Fifteen rounds dropped or unified `.ds-*` / promoted `.ui-*` primitives to universal. Latest: **v15 (May 2026)** — Drawer-handle promotion `.ui-rail-handle` → `.drawer-handle`, with the universal primitive owning chrome only (consumers own positioning + state binding). **Full v1–v15 detail** with sweep-scope, file refs, and the architectural notes: `.claude/skills/lifi-ds-docs/references/bug-history.md → Class-naming convention — migration history`.

After v15, **the rule is now self-enforcing and predictable:**

| Sees | Knows |
| --- | --- |
| `.ds-doc-foo` | Catalog / documentation primitive — lives in `design-system/*.html`, never on production pages. |
| `.foo` (no prefix) | Universal cross-surface primitive — buttons, forms, modals, callouts, charts, flows. Use anywhere. |
| `.ui-foo` | Product-surface primitive — `.ui-card`, `.ui-token-row`, etc. Used on `playground.html` and future product pages, not on marketing or DS-meta surfaces. |

If you see `.ds-something` that ISN'T `.ds-doc-something`, it's drift and should be migrated. The historical mentions in `bug-history.md` and `design.md` are intentional (they document the migrations themselves).

### Panel vs card — name a top-level frame `-panel`, an inner surface `-card`

Orthogonal to the tier (above) and the compound shape (below): a named primitive's **role in the layout hierarchy** decides whether it's a `-panel` or a `-card`. Both may compose the same `.panel.ui-card` base (the swap widget's top-level frames all do) — the suffix names the role, NOT the surface recipe.

- **Panel** — a **top-level frame** in a column/region, sibling to other panels, holding a table / chart / list / form. Name `.ui-<role>-panel`. Examples: `.ui-orders-panel` (the Limit-mode Orders frame), `.ui-receive-panel` (the receive-column primary frame).
- **Card** — a **self-contained inner surface** that sits INSIDE a panel/form, one of several. Name `.ui-<role>-card`. Examples: `.ui-amount-card`, `.ui-quote-card`, `.ui-limit-price-card`, `.ui-tx-received`.

**The test:** does this primitive SIT INSIDE another frame (→ `-card`), or is it itself a top-level frame holding content (→ `-panel`)? An unnamed `.panel.ui-card` frame that grows a name takes `-panel`; a content surface composed into a frame takes `-card`. (The surface tokens are a separate concern — see `Panel Surfaces` / `Card & tile surfaces` below; a panel and a card can share `--surface-*` values.)

**Bug history.** June 2026 — `.ui-orders-card` was the lone top-level receive-column frame wearing a `-card` name while every other `.ui-*-card` was a genuine inner card; renamed atomically to `.ui-orders-panel`, and its previously-unnamed sibling (the general receive frame, Market Price only in Limit mode) was named `.ui-receive-panel` in the same pass.

### Compound class naming — pick by axis: structure, role, tone, or chrome

The three-tier rule above settles the OUTER namespace (`.ds-*` / no-prefix / `.ui-*`). This rule settles how to compose the INNER name when a class is built from two words. Four patterns coexist in the codebase — each encodes a different semantic axis. The dash count (single `-` vs. double `--`) is the lexical discriminator between same-prefix patterns, and picking the wrong shape is one of the most common naming drifts.

| Pattern | Shape | Use when | Examples |
| --- | --- | --- | --- |
| **Host-first** | `[host primitive]-[suffix]` (single dash) | The suffix names a STRUCTURAL variant — a contained element, a content type, a slot, a sub-element, or the structural-role concept of a wrapper-of-N primitive. The class joins an existing primitive family namespace. | `.chip-avatar` (chip containing an avatar), `.chip-delta` (chip showing a delta value), `.chip-shortcuts` (cluster of chips acting as preset-value shortcuts), `.chip-icon`, `.chip-pill`, `.chip-edit`, `.chip-window`, `.btn-with-icon`, `.btn-icon`, `.avatar-badge`, `.avatar-stack`, `.avatar-tandem`, `.avatar-label`, `.card-glow`, `.card-select` |
| **Qualifier-first** | `[qualifier]-[host primitive]` (single dash) | The prefix names a ROLE or DOMAIN specialization of the WHOLE component — the entire primitive is purpose-built for that role; no structural child by that name exists, and the class is not composed onto a base. | `.action-card` (card specialized for action affordances), `.accent-card`, `.feature-card`, `.enterprise-card`, `.brand-card`, `.brand-chip`, `.brand-tile`, `.brand-lockup`, `.identity-tile` |
| **Tone variant** | `[host primitive]-[tone]` (single dash) | Suffix names a SEMANTIC TONE / intent that any instance of the primitive can take on — success / danger / warn / neutral / accent1-3, or delta-pos / delta-neg. Lexically the host-first shape; semantically a per-instance variant axis distinct from BEM modifiers. Composes onto the base primitive (`<span class="chip chip-success">`). | `.chip-neutral`, `.chip-danger`, `.chip-success`, `.chip-warn`, `.chip-accent1/2/3`, `.chip-delta-pos`, `.chip-delta-neg`, `.chip-avatar-neutral`, `.chip-avatar-success`, `.chip-avatar-accent1/2/3` |
| **BEM modifier** | `[selector]--[modifier]` (double dash) | Adds a CHROME / SIZE / STATE modifier on the same primitive. The base selector stays whole; the modifier composes onto it. | `.chip--bare`, `.chip--outline`, `.chip--md/lg/xl`, `.chip-avatar--outline`, `.chip-avatar--sm/lg/xl`, `.avatar-tandem--lg`, `.avatar-tandem--placeholder`, `.accent-card--primary`, `.card-avatar--cluster`, `.alert--sm/lg` |

**The decision tree when authoring a new compound class:**

1. **Does the class belong to an existing primitive family** (chip, card, btn, avatar, list, input, etc.) **AND does the suffix name a structural variant or contained element?** → host-first: `[host]-[suffix]`. The class lives in the host's namespace.
2. **Does the class qualify a universal primitive with a WHOLE-COMPONENT role or domain specialization** (no structural child by that name; the entire component is purpose-built for the role; not composed onto a base)? → qualifier-first: `[qualifier]-[host]`.
3. **Does the class name a semantic tone or intent that any instance of the primitive can take on** (success / danger / warn / neutral / accent / delta) AND is it composed onto the base primitive (`.chip` + `.chip-success`)? → tone variant: `[host]-[tone]`. Lexically the same shape as host-first; semantically a distinct axis from BEM modifiers.
4. **Is the class a chrome / size / state modifier on the same primitive?** → BEM: `[selector]--[modifier]` (double dash).

**The test that disambiguates host-first vs. qualifier-first.** Ask: *would a child element actually exist with the suffix's name?* For `.chip-avatar`, an `<img class="avatar">` is genuinely a child of the chip — host-first is correct. For `.action-card`, no `<X class="action">` child exists — "action" is the role/intent, qualifier-first is correct. If the suffix names something you'd actually find inside the markup, it's structural; if it only describes the purpose / tone / domain, it's a qualifier.

**The wrapper-of-N variant of the test.** When the class names a WRAPPER whose children are instances of an existing primitive (`.avatar-stack` wraps `.avatar`s, `.logo-cloud-row` wraps `.logo-cloud-item`s, `.chip-shortcuts` wraps `.chip`s), the disambiguating question shifts. Ask: *are the children of this wrapper instances of `.X`?* If yes, X is the host (prefix). The suffix then names the wrapper's structural-role concept (`-stack` / `-tandem` / `-cloud` / `-shortcuts`) — it's still STRUCTURAL because it names the wrapper's role in the markup hierarchy, not a tone / domain qualifier on a base primitive. The original test ("would a child element exist with the suffix's name?") returns no for wrapper-of-N primitives (no `<X class="stack">` / `<X class="shortcuts">` child exists), but that's the test mis-applied — for wrappers, the suffix is the wrapper's identity, not a contained-child's name. `.amount-shortcuts` failing this test was the lesson that surfaced the variant (May 2026 v22).

**The test that disambiguates tone variant vs. BEM modifier on the same primitive.** Both apply to existing primitives and compose onto a base; the dash count tells them apart at a glance. Ask: *does this name express semantic INTENT that any instance could carry (success, danger, neutral, accent), or does it modify how the primitive RENDERS (size, chrome stripped, state flag)?* If intent, single-dash tone. If render modifier, double-dash BEM. Tones answer "what kind of chip is this"; modifiers answer "how does this chip look." Tones and modifiers compose: `<span class="chip chip-success chip--bare">` reads as "a success-toned chip without chrome." The chip family is the canonical precedent (`.chip-success` tone + `.chip--bare` chrome); the `.chip-avatar` sub-family mirrors the same split (`.chip-avatar-neutral` tone + `.chip-avatar--outline` chrome) — independent corroboration that the dash-count split is intentional, not drift. The split also lets CSS lexical selectors (`:not(.chip--outline):not(.chip--bare)`) reliably target the chrome axis without matching tones, and keeps the load-bearing specificity arrangement readable (`.chip.chip--bare` at (0,2,0) beats late-declared (0,1,0) tone rules — see the `chip-modifier-source-order` memory).

**English flow vs. system consistency.** The host-first shape sometimes reads less naturally in English — `.chip-avatar` parses as "chip + avatar" rather than the more native "avatar in a chip." That's a real tradeoff, but consistency across the family beats per-class English-flow optimization. Once `.chip-avatar` exists, `.card-avatar` (a card containing an avatar) inherits the same shape; reaching for `.avatar-card` would split the convention and force future contributors to remember which side of the line each class falls on.

**When in doubt — look at the sibling family.** If you're adding a class for "card containing X," check what `.chip-X` would be called or already is. The card family should mirror the chip family's shape. Same in reverse: if you're adding `.btn-X`, look at what `.chip-X` looks like for the same X. The five canonical primitive families (`.chip-*`, `.btn-*`, `.card-*`, `.avatar-*`, `.list-*`) should all follow host-first; a new compound that joins any of them inherits the shape.

**Adjacent-family patterns — legitimate variants of the rule** (not drift; documented for completeness):

- **The `.alert-*` family uses double-dash for EVERYTHING** — tone (`.alert--info` / `.alert--success` / `.alert--warn` / `.alert--danger`) AND size (`.alert--sm` / `.alert--lg`) AND state (`.alert--no-dismiss`). Legitimate because the alert primitive has no clean tone-vs-chrome distinction: every alert variant bundles tone + bg + border via `--a-tone` slot tokens; there's no "bare alert" / "outline alert" axis sitting orthogonal to tone the way it does on chips. When a primitive's variants ARE all tone+chrome bundled, BEM-throughout is coherent — different shape from the chip family but internally consistent.

**Known internal drift this rule does NOT yet fix** (flagged for future cleanup, not load-bearing for new authoring):

- **`.chart-avatar-row`** — three-word structural compound (family `chart` + contained element `avatar` + shape `row`). Audited May 2026 against the convention and judged honest: both `avatar` and `row` are structural (the row IS made of avatars; it IS a row), neither is a chrome/size/state modifier, and there is no `.chart-row` base primitive in the family (the closest sibling, `.chart-x-identity`, is a per-bar SVG `<g>` with `__content` / `__label` slots — different API, can't double as a `.chart-row` base). Inventing `.chart-row` to support a single `--avatar` variant would be premature abstraction. Keep as-is. No live consumer markup; documented as an optional fallback pattern under `.bar-chart`.
- **`.btn-primary` / `.btn-secondary` etc.** use single-dash for tone, AND `.btn-sm` / `.btn-lg` / `.btn-xl` use single-dash for size, AND `.btn-outline` / `.btn-lift` / `.btn-with-icon` use single-dash for chrome. The button family lumps tone + chrome + size + state all under single-dash — the actual drift from the four-axis split. The chip family is the cleaner precedent; the button family pre-dates the four-axis articulation. Don't migrate without surfacing — high blast radius across product, marketing, and catalog; lower load-bearing risk than the chip case would have been (no source-order trap because all button rules share the same specificity tier). Worth doing when the button family is next touched for other reasons.

These drifts are intentional callouts so future contributors know they're recognized — not aspirational rewrites. When authoring NEW compound classes, follow the rule above; existing drift gets migrated when its primitive family is next touched for other reasons.

**Bug history.** May 2026 — the `.chain-shortcut` → `.card-avatar` rename (v21) surfaced this convention as a system-wide question. Before that rename, the codebase had ~10 host-first compounds and ~7 qualifier-first compounds without a settled rule; new contributors picked by analogy or English-flow, drifting toward whichever shape they'd seen last. The rule above was extracted from auditing both camps and identifying the structural-vs-qualifier semantic split that justifies each. The `.card-avatar` rename itself documents the precedent in `bug-history.md → Class-naming convention — migration history`.

**May 2026 v22 follow-on** — `.amount-shortcuts` → `.chip-shortcuts`. The convention's initial drift list named `.amount-shortcuts` as "no clean rename target since there's no `.shortcuts` base primitive." That analysis under-explored the wrapper-of-N precedent (`.avatar-stack`, `.avatar-tandem`, `.logo-cloud-row`) — wrappers' suffixes name the WRAPPER'S structural role, not a contained-child. Once that variant of the host-first test surfaced (above), `.chip-shortcuts` became the obvious target: children are `.chip` (host), wrapper acts as preset-value shortcuts (suffix). The recipe had also generalized beyond "amount" (catalog documents slippage / gas / percent uses). Atomic rename — 63 refs across 7 files, see `bug-history.md → Class-naming convention — migration history` for the v22 entry.

**May 2026 v23 follow-on — four-axis articulation.** The chip family's tone-vs-chrome split (`.chip-success` single-dash + `.chip--bare` double-dash) was flagged in the initial drift list as "internally inconsistent within the chip family" — load-bearing because of the source-order trap (the `chip-modifier-source-order` memory), with cleanup planned as a separate refactor. Investigation in May 2026 found the opposite: semantic tone is a FOURTH axis distinct from host-first, qualifier-first, AND BEM modifier — it's a per-instance intent variant on a primitive, not a structural sub-element (host-first), not a whole-component role specialization (qualifier-first), and not a chrome/size/state render modifier (BEM). The chip family's dash-count split is the canonical precedent; the `.chip-avatar` sub-family's identical pattern (`.chip-avatar-neutral` + `.chip-avatar--outline`) independently corroborates the rule's intentionality. The rule was upgraded from three patterns to four; the chip family entry moved from "Known internal drift" to the new tone-variant table row + disambiguator paragraph as worked example. The button family — which lumps tone + size + chrome under single-dash — is now the actual identified drift, demoted from co-equal precedent. Zero code migration: the rename of choice for chips would have been atomic across product / marketing / catalog with serious specificity restructuring risk; the right move was always to recognize the chip family as correct.

### Grow a variant before authoring a new primitive

Before authoring a NEW component, check whether an existing family (`.setting-row`, `.list-item`, `.chip`, `.option-card`, …) can carry it as a **variant**. The test: *is this "same functionality, different chrome" — a label, a value, and an affordance arranged a new way?* If yes, it's a variant on the existing family (new modifier + the missing sub-element slots), not a new primitive. Two near-identical primitives fragment the system the same way aliases do: contributors pick by recency, the chrome drifts apart, and the eventual consolidation costs a full atomic retirement.

**Precedent (June 2026 — the setting-row consolidation).** `.accordion` shipped as a new universal primitive and was folded within days: an accordion header (title + live value + chevron) was ~a `.setting-row` drill row wearing flat chrome. It returned as `.setting-row--expand` — the family gained `__head` / `__chevron` / `__drawer` and now carries THREE contracts (settings / drill / expand) on one card chrome. The designer's framing: *"minimize the number of different types of components that are actually pretty similar."* When the variant genuinely doesn't fit (different role in the layout hierarchy, different surface tier, no shared anatomy), a new primitive is right — but make that case explicitly rather than defaulting to it.

**The mirror rule — a proposed VARIANT can also be a duplicate of a DIFFERENT family's existing composition (June 2026 — the flush-chip rejection).** "Grow a variant" doesn't mean every new need lands as a variant on the family you started from; check whether another family already covers the role under a different name. A "flush" icon-only chip variant (zero side padding, glyph hugged) was proposed for the asset-cell download affordance and REJECTED: the moment a chip drops its label it stops being a chip (a *labeled* tone pill) and becomes an icon button — and the lone-glyph space is already covered twice: `.spot-icon` (static tone glyph) + `.btn-icon.btn-circle` at the `xs` tier (interactive, any `.btn-*` tone, 24px). The role test: *does the element ACT (→ icon button) or LABEL (→ chip/spot-icon)? Does it carry text (→ chip) or stand alone as a glyph (→ not a chip)?* Decision recorded at `design-system/docs.html.html#asset-cell` (Rules pane) + the `.ds-doc-cell-dl` comment in `styles.css`.

**Audit the target before applying a sibling's split/rename — a pattern that earned its keep on A doesn't auto-transfer to B (June 2026 — seg vs. buttons).** When asked to give component B "the same treatment" as component A (a split, a rename, a section extraction), AUDIT B's structure FIRST; don't mechanically replay A's fix. The button / icon-button split was driven by REAL problems — two distinct components conflated under one prefix (`.btn-icon` label-button vs. `.btn-icon-only` icon-button) plus 8 dangling `#icon-buttons` anchors. Asked for "a similar split + rename" on the segmented control, the audit found NONE of that: the seg is one well-factored component (no confusable prefix, no dangling anchors, `.seg--icon-only` already clear at 9 uses, the icon/avatar shapes intentionally `:has()`-content-detected per the auto-detect rule). So it got a **docs spotlight** (a findable "Content shapes" chapter + a ⌘K-findable card), NOT a split — forcing the button pattern would have been churn that fought an existing design rule. The diagnostic: *does B actually have A's problem (real conflation / confusable names / dangling refs / wrong content-shape model), or does it just superficially resemble A?* Run the usage audit, surface the finding + your recommendation (per "Surface conflicts" / challenge-the-direction), and get the user's call BEFORE sweeping any call sites. (Validated June 2026: the seg "split" was reframed to a docs spotlight on the audit's evidence; the user agreed.)

### Primitive retirement is atomic — no aliases, no transitional period

When unifying two primitives into one canonical name (or retiring a name in favour of a renamed family), **migrate every consumer in a single commit**. No aliases. No deprecation window. The migration is deliberate; the rename is universal; the old name is gone from CSS, JS, and markup the moment the commit lands.

The precedent — eight atomic migrations, **v10 → v24**. The per-rename rationale, sweep scope, and file refs for **v10–v22** (`.ds-doc-class`→`.code-chip` · `.form-hint`→`.hint` · `.btn-alpha`→`.btn-neutral` · `.panel-head`→`.screen-header--compact` · `.hint`→`.muted` · `.amount-shortcuts`→`.chip-shortcuts`) live in `.claude/skills/lifi-ds-docs/references/bug-history.md → Class-naming convention — migration history`. The most recent, **v24 (June 2026)**, is kept here as the worked example because it also carries live contracts:
- **`.ui-order-cell` / `.ui-order-text*` → `.data-cell` / `.data-cell-text*`** (v24, June 2026) — **promotion to a universal rich-table layer**, the precondition for Portal and other surfaces to build the polished avatar+text+chip+kebab rows the Orders table pioneered. The orders-scoped cell wrapper + text chassis (with weight + ink modifiers) was lifted to tier-2 in `styles.css` alongside three sibling promotions: `.data-table--rich` (the geometric-alignment row chassis — separate borders, 4-grid row/header heights, flex-cell centring) and `.table-panel` (the framed-table composition — `.screen-header` + `.table-scroll` + the edge-bleed glue). Orders reapplied as `data-table--rich data-table--orders`. **Follow-on (June 2026 — the Portal-tables pass): the comfortable look was promoted from `.data-table--orders` INTO the `.data-table--rich` base.** The 16px body type (`--data-cell-text-size: --text-body`) + the normal-case standard-font header voice (regular weight, no uppercase, no tracking) are now the rich-table DEFAULT — because every real consumer wanted them and none wanted the lean dense base (uppercase 11px caption headers + 14px values). `.data-table--orders` now keeps ONLY genuinely orders-specific bits (overflow sizing, chain-column pin, status-chip inset, the limit-mode sticky-header bg); it renders identically by inheritance. **The lesson — promote the SKIN with the SKELETON:** a shared base must carry the VISUAL design (type + header voice), not just the structural/alignment machinery — or new consumers adopt the chassis and inherit the un-styled lean defaults. That's exactly what happened to Portal's 16 tables: they adopted `.data-table--rich` in June but stayed dense (uppercase headers, 14px mono cells, 16px avatars) until this follow-on moved the design into the base + fixed the Portal renderer (24→32px chain avatars; **zero mono in rows** — sans tabular-nums everywhere, even hashes/keys/addresses, a deliberate brand preference over the mono-for-identifiers convention). The orders-domain pair classes (`.ui-order-pair-*`) intentionally stayed orders-scoped — the SELL → BUY identity is orders-domain, didn't promote. **The size-pairing contract** that surfaced during the promotion (see `bug-history.md → "Promoting in-product code — the --sm pairing trap"`): `.data-table--rich`'s default `--data-table-row-h` (56) pairs with `--sm` cell padding (8 px vertical) so a 32 px avatar fits the row; real consumers are `--sm --rich` (Orders, the catalog Routes panel, and Portal's 16 tables). A non-`--sm` consumer either steps the slot up (40 px avatar → `--space-72`) or composes with `--sm`. (Portal chain-logo avatars sit at 32 px in single-mark rows + route pairs — matching the Orders chain mark.) **The mono variant is `.data-cell-text--mono` (font-family ONLY), never a composition with the legacy `.mono` utility** — `.data-table .mono { font-size: 0.92em }` wins on composition and shrinks the cell off the row's type tier (11 px where 14 expected); see `bug-history.md → "Promoted cell-text primitive — the mono variant must be a --mono modifier"`. Sweep: `styles.css` (the +93-line additive primitive block) + `swap.css` (slimmed `.data-table--orders` to its scope) + `orders-list.js` (renderer emits the new names) + `playground.html` (live widget table class list) + `design-system/playground.html` (the static `#ui-orders-panel` snapshot + Markup-pane codeblock + token refs) + `design-system/index.html` (3 new catalog cards: `#tables-rich`, `#tables-rich-cells`, `#table-panel`) + `design/components/orders-table.md` (spec reframed as "first scoped consumer of the promoted layer") + this file. Verified pixel-identical via computed-style reads of the live Orders table at every step. 4 commits + spec sync, atomic at each retirement boundary, no aliases.
  - **Follow-on (June 2026 — base-table redesign, commit `1a8537e`): the comfortable skin was promoted one more level — from `.data-table--rich` onto the BASE `.data-table` and every tier.** The base table is **no longer the "lean dense base"** the v24 entry above describes — that framing is now historical. What moved onto the base + all variants (the SKIN only; the geometric `--rich` chassis stays opt-in): (a) **header voice** → muted normal-case regular (was uppercase/tracked/semibold caption); (b) **cell type** → 16px `--text-body` sans; (c) **no monotype by default, SITE-WIDE** — `.data-table .mono` no longer swaps the family or shrinks (the `0.92em` shrink documented above is GONE; it now asserts `var(--font-sans)` + `calt 0`), so every string is standard sans on its tier. The earlier "zero mono in rows" brand preference (rich-only above) is now the rule for ALL tables; **mono is an explicit per-string opt-in via `.data-cell-text--mono`** (rich) — never the base `.mono`. (d) **3-level emphasis** muted / default / bold (`<strong>` → semibold + primary, was secondary). **Row heights are now PINNED on the 4-grid** via `height` + `box-sizing` + `vertical-align: middle` + horizontal-only padding (vertical rhythm from the height, not the line-box — so rows land on exact tokens instead of drifting; CLAUDE.md → Sizing Ladders "pin to a tier with height"). **This works under the base table's `border-collapse: collapse`** (verified: exact 32/40/56/64, no sub-pixel drift) — the base does NOT need `border-collapse: separate`. `.data-table--rich` uses separate borders only for its geometric avatar/chip CELL alignment (the half-pixel-border trap), not to grid-align row heights; a plain text base table hits exact heights under collapse. **Four-tier ladder** (added `--md` cozy): `--sm` 32 / `--md` 40 / default 56 / `--xl` 64. **Header ladder 10 / 12 / 14 / 16** (one token per tier) — and `--sm`'s 10px (`--text-micro`) header is the **ONE ALL-CAPS exemption** to the normal-case voice (normal-case reads too faint at micro size; uppercase + `--tracking-loose` is legible AND the classic dense-table caption header). The caps exemption ALSO makes `--md` distinct from default (th 12 vs 14), not just a density twin. **`--sm --rich` (Orders / Portal / Routes) is protected:** `.data-table--rich thead th` re-asserts 14px normal-case so the `--sm` caps exemption can't leak into a rich header — rich rows stay 56px / 14px-header / no-uppercase, unchanged. **Blast radius:** the default tier moving to a fixed 56px touches every plain `.data-table` site-wide (dashboards, personas, Specs tables) — intended grid alignment, flagged for a visual confirm. Sweep: `styles.css` (base th/td + the 4-tier ladder + the `--sm --rich` re-assert) + `design-system/index.html` (`#tables-base` + `#tables-size-ladder` cards) + `design-system/foundations.html` (`--text-micro` usage now names compact table headers). The `--text-micro` 0.92em-shrink note above stays accurate as HISTORY (it's why `.data-cell-text--mono` is family-only); the shrink itself is retired.

**June 2026 — `.btn-icon-only` → `.btn-icon` · `.btn-icon` → `.btn-with-icon` (button-family split; not a v-series class-naming entry).** Separated the two components that shared the `.btn-icon*` prefix: the icon-hugging button (was `.btn-icon-only` — square/circle, tight symmetric padding, requires `aria-label`) became `.btn-icon`, and the label-button-with-icon (was `.btn-icon` — label-optimized padding, optional leading/trailing icons + auto trailing arrow) became `.btn-with-icon`. **The reuse-a-freed-name technique:** to rename A→B AND C→A safely, do it in an ordered two-pass sweep — rename `.btn-icon`→`.btn-with-icon` FIRST, grep-verify zero bare `.btn-icon` remain + zero `.btn-with-icon-only` corruption, THEN `.btn-icon-only`→`.btn-icon`. ~570 exact-token replacements across 53 files (product + marketing HTML, JS, all CSS, catalog, `design/components/*.md`, this file, `bug-history.md`, the memory dir). The icon button also gained its own catalog section `#icon-buttons` (placed alphabetically between `#fab` and `#inputs`, monotonicity verified), which fixed the dangling `#icon-buttons` anchors; `.btn-with-icon` got its own `#buttons` card. Old names live only in the `styles.css` breadcrumb (search "Button icon classes — June 2026 rename") + this entry. Commits `58667c0` (rename) · `c617ab3` (`#icon-buttons` section) · `5ff81a8` (`.btn-with-icon` card).

Each migration touched every consumer atomically. No deprecation period; no "old class still works for backwards-compat" carve-out. The old name appears only in **migration-note comments** in the relevant CSS section (a historical breadcrumb so future readers see what changed and why) and in `bug-history.md`.

**The discipline this enforces.** Aliases compound — every contributor who reaches for the old name during a transition period perpetuates it. By the time the alias-removal commit lands, the old name is wired into half a dozen consumer surfaces that thought they were "future-proof." Atomic retirement makes the rename a single revert target — if a migration breaks something subtle, the rollback is one `git revert`, not a multi-commit unwind.

**When the migration would exceed the soft 8-file / 500-line autonomous-commit threshold,** pause and confirm scope (per the git-workflow framework above) — but the answer is almost always "still one atomic commit" because semantic coherence beats file-count budgeting. The `.panel-head` retirement was 9 files / 232 insertions; it landed as one commit because every file participated in the same primitive retirement and splitting would have created broken intermediate states.

**Multi-chat collision is a SECOND legitimate split reason (May 2026 v20).** When a parallel chat has uncommitted WIP touching the same file regions as the retirement, the file-by-file authorship is interleaved and `git add <file>` would sweep foreign WIP into the retirement commit. Splitting becomes defensible: commit the solo-edited files (A), hold back interleaved files until the foreign WIP commits, then close the loop with a follow-up commit (B). The pair-of-commits stays atomic in the sense that matters — both halves land on `main`, neither half independently breaks the system, the retirement is universal once both have landed. The v20 `.hint → .muted` retirement used this pattern: `7943889` (catalog + spec + docs + JS consumers — 7 files, solo) + `ac1c52a` (CSS source + catalog rendering — 5 files, post the parallel `.empty-state` commit). The user explicitly approves the split at the seam; don't infer the split silently. The hunk-filter pattern in `feedback_bulk-regex-scope-structure.md` v3 is the technique for staging only the rename hunks when a file's authorship is interleaved at hunk granularity.

**What the future-me audit looks like.** When grepping for a primitive name and finding zero hits except migration-note comments, the retirement was clean. If you find live consumer code on the old name, that's drift — flag it, don't author new code that consumes the legacy class. The grep is the authoritative test.

**The same grep runs BEFORE any primitive treatment — zero consumers means retire, not polish (June 2026).** When a task asks to polish, rename, or converge a primitive, grep its live consumers FIRST — the task's own consumer claims can be stale. If the only hits are the catalog card documenting it (catalog-only = zero product consumers), the right proposal is retirement, even when the task offered only polish/rename options. Validated June 11 2026: the legacy FAB-panel `.accordion` family arrived as "de-mono or rename; live consumers are the FAB sub-panels" — the grep showed the Globe / Logo Stack / Reactive Coins panels all run flat `.ds-doc-control-row` sections and the sole consumer was the catalog card; retired instead (`0c4963d`), accepted without pushback. 2nd validation June 12 2026: the widget execution-flow rebuild's mid-task grep surfaced THREE zero-consumer first-pass stubs of the very flow being rebuilt (`.ui-result-state` / `.ui-activity-card` / `.ui-recipient-chip` — catalog-only or wholly unreferenced); all retired in the rebuild commit (`4b2bee8`) instead of being aligned to the new design.

### Content vocabulary by tier-count — `__label`/`__caption` vs. `__title`/`__subtitle`/`__description`

Two-tier and three-tier components use DIFFERENT sub-element vocabularies. Pick by the content shape, not by component name. Establishing this as a convention (May 2026) after the `.ds-doc-variant-card` rebuild added a third tier (paragraph description) and `__label`/`__caption` stopped pairing cleanly with the new prose slot.

| Content shape | Vocabulary | Reach for it when |
| --- | --- | --- |
| **2-tier** — identifier + brief annotation | `__label` + `__caption` | The primary line IS the thing's name/identifier, and the secondary line is a short annotation. Caption reads as figcaption-like supporting text. |
| **3-tier** — heading + elaboration + prose | `__title` + `__subtitle` + `__description` | The primary line is a heading (the component reads as a mini-article / mini-proposal), the secondary line elaborates the heading, and an optional paragraph below carries deeper detail. Title/subtitle/description pairs as one coherent hierarchy. |

**Canonical examples:**

| Component | Tier | Vocabulary |
| --- | --- | --- |
| `.ds-doc-card` | 2-tier (component identifier + 1-line summary) | `__label` + `__caption` |
| `.swatch` | 2-tier (color name + token/hex) | `.swatch-name` + supporting code-chips |
| `.alert` / `.notification` | 2-tier (status heading + body line) | `.alert-title` + `.alert-message` (already title-style; equivalent role) |
| `.ds-doc-variant-card` | 3-tier (option heading + 1-line elaboration + optional paragraph) | `__title` + `__subtitle` + `__description` |
| Future: any "proposal-shaped" card with expand-to-read-more | 3-tier | `__title` + `__subtitle` + `__description` |

**Why not unify on one pair?** Label + caption + description stitches three different vocabularies (tag + annotation + paragraph) — a label is what you READ to identify something, a caption is what sits beside or below a figure, a description is prose. Title + subtitle + description reads as one hierarchy of three depth tiers — the same way an article structures its heading/dek/body.

**Authoring rules.**

- **Pick by tier-count, not by name.** A card called "Foo card" that has only an identifier + brief annotation gets `__label` + `__caption`. The same card, if it grows a paragraph description, migrates the heading slots to `__title` + `__subtitle` in the same edit. Don't keep mixed naming.
- **Don't introduce `__label` + `__caption` + `__description` as a triplet.** That's the failure mode this rule exists to prevent. If a 2-tier component needs a paragraph, it's becoming 3-tier — rename the heading slots.
- **The catalog card (`.ds-doc-card`) stays 2-tier on purpose.** Its `__label` is the component identifier (the class name); its `__caption` is the 1-line summary. There's no expand-to-read-more pattern on catalog cards — the body tabs (Markup / Specs / Anatomy / Rules) carry that depth instead.

**Avatar — `.avatar` is the canonical Component class (May 2026 v9).** Rendering ANY chain / token / bridge / DEX / wallet / social / LI.FI sub-product mark goes through `.avatar` + size + shape + optional ring/stack/label modifiers. Live spec at `design-system/index.html#avatar`; CSS in `styles.css → 8d. AVATAR — .avatar`; written spec at `design/components/imagery.md → ### Avatar`; asset library + file conventions at `design.md §05 → Avatars Asset Library` (icon-only chain / token / etc) and `design.md §05 → Portraits Asset Library` (persona / team photography). The catalog-meta pair `.ds-doc-avatar-grid` / `.ds-doc-avatar-cell` (parallel to `.ds-doc-icon-grid` / `.ds-doc-icon-cell`) stays tier-1 — only used inside the Libraries catalog sections — `#avatars`, `#partner-logos`, `#portraits` (the May 2026 split of the former `#brand-library`). Don't reach for it on product or marketing surfaces. Don't render full brand wordmarks through `.avatar` — `object-fit: cover` will crop the typography; use `.ds-doc-partner-logo` or a bare `<img>` inside `.logo-cloud-item` instead.

### Monogram identity avatars use `.avatar--initial` — never a page-local letter-block

When a person / org / integration has no photo or brand mark, its identity avatar is a **letter monogram via `.avatar--initial`** (the Gmail / Slack / Linear convention) — NOT a hand-rolled page-local letter-block. This variant (June 2026) retired the drifting `.portal-initial` family + the inline `--avatar-bg` radials; reach for it everywhere an identity needs initials.

- **Authoring** — `data-initial="<label>"` on a `.avatar` span; `shared.js → initInitialAvatars()` fills the glyph (up to 2 letters: first + last whitespace-token, single-token → 1) + a **deterministic `--spectral-*` tone from a hash of the label**, so the same entity is the same colour on every surface. `window.lifiInitialAvatar(label, opts)` is the markup-string helper for JS-rendered rows. Override the tone with `data-initial-tone="<1-12|neutral>"` (markup) / `opts.tone` (JS).
- **Tone classes** — `.avatar--tone-1…12` map `--avatar-tone` to the 12 `--spectral-*` hues (coral → rose). `.avatar--tone-neutral` is the muted grey plate for non-human entities (System, pending invites). `.avatar--flat` drops the radial for a solid fill (dense table cells).
- **White-text-safe by construction** — the plate normalises the tone's lightness to a deep band via `oklch(from var(--avatar-tone) min(l, 0.46) c h)`, identical in both themes (it caps the bright dark-mode spectral values down), so white glyphs clear WCAG AA (≥ 5.1:1) on every tone while the hue stays full-chroma. **Don't hand-pick a fill colour** — the tone system owns it; an inline `--avatar-bg` defeats the cross-surface consistency.
- **Rides the full six-tier `--avatar-size` ladder** (xs 24 / sm 32 / default 40 / lg 48 / xl 64 / 2xl 96) like every avatar sub-variant — same modifiers, same heights; the glyph scales via `font-size: calc(--avatar-size × 0.4)`. `aria-hidden="true"` when a visible name sits beside it (redundant decoration); a real `aria-label` only when it stands alone.
- **Re-rendering after a `data-initial` / `-tone` change needs an explicit repaint — `initInitialAvatars` is one-shot (June 2026).** The init guards each element with `__initialAvatarDone` (runs once) and only *adds* a tone class — it never recomputes glyph or strips the old tone. So swapping `data-initial` / `data-initial-tone` on an EXISTING mark (e.g. an org/persona switch that re-points the same trigger avatar) leaves it showing the stale glyph + colour — most visibly the FOUC placeholder's letter/hue, while freshly-built sibling rows render correctly. The fix is `shared.js → window.lifiRepaintInitialAvatar(el)` — a shared in-place repaint that strips the `avatar--tone-*` class, recomputes glyph + tone from the live attributes, and re-asserts `__initialAvatarDone`. Call it on the mutated mark after setting its `data-initial` / `-tone`. Consumers: `portal-render.js → applyIdentity` (org switch — trigger + account marks) and `portal.js → openDetail` (integration Detail open — entity-head mark). **Freshly-CREATED markup is fine** — a brand-new element has no guard set, so `lifiInitInitialAvatars(root)` fills it normally (that's why the panel rows, rebuilt each open, were always correct). The repaint is only needed when you mutate an already-painted mark in place. (Promoted June 2026 from a local `portal-render.js → repaintInitial()` once `openDetail` became the second consumer — atomic retirement, no alias.)
- **Shape encodes identity TYPE — entity vs. person (June 2026).** The shape modifier is not free chrome; it carries meaning, the GitHub / Linear / Notion convention. **Non-person entities → `.avatar--rounded`** (squircle): organizations / workspaces, integrations / apps, products. **People → `.avatar--circle`**: members, audit/activity actors, the logged-in account ("you"). The split is most useful in a mixed rail — a rounded org mark reads "your workspace," a circle below reads "you, the person" — and the rounded-square is also the correct *frame for a real org/app logo* (a circle crops a brand mark via `object-fit: cover`; the monogram fallback should match the shape a real logo wants). The Portal is the canonical consumer: org switcher (trigger + dropdown) + integration cells = rounded; members + audit actors + account chip = circle. `System` / pending-invite entities keep `--tone-neutral` but follow the same person-vs-entity shape rule. Don't reach for a shape that contradicts the identity type to "match the neighbours."

**Where enforced.** CSS: `styles.css → 8d. AVATAR` (search `.avatar--initial`). JS: `shared.js → initInitialAvatars()` + `window.lifiInitialAvatar`. Spec: `design/components/imagery.md → Initials monogram`. Catalog: `design-system/index.html#avatar-component`. This section is the durable cross-session contract.

### Composable slots vs. variant modifiers — pick by what varies

When designing a component's variant API, pick the shape by what's actually varying across consumers. Two shapes, no overlap:

| Shape | Reach for when | API surface |
|---|---|---|
| **Composable slots** — `__identity`, `__price`, `__deltas`, `__lead`, `__body`, `__trailing`, etc. Consumers omit slot markup they don't need. | Optional regions are **orthogonal**. A consumer might want any combination — identity-only, identity + price, identity + deltas, or all three. Variants are combinatorial; there's no fixed "compact mode." | One class on the wrapper, named child elements per slot. Markup determines variant. |
| **Variant modifiers** — `.btn-primary`, `.chip--bare`, `.alert--warn`, `.ui-amount-card--zero`, `.list-item--lg`. Consumers add a class to the same DOM. | The **same content** takes different chrome, state, tone, or size. The variant is "what does this look like," not "which rows are present." | Modifier classes on the same root. Class determines variant. |

**The variant test.** Ask: *what's actually varying across consumers — which DOM regions are present, or what does the same DOM look like?* If the former, slots. If the latter, modifiers. Don't conflate the axes — picking wrong calcifies the wrong API shape.

**Canonical examples.**

- **Composable slots** — `.ui-token-info` (`__identity` / `__spot` / `__price` / `__deltas`); `.list-item` (`__lead` / `__body` / `__trailing`); `.ds-doc-card` (`__head` / panes); `.ui-limit-price-card` (`__lead` / `__main` / `__chips`); `.screen-header` (`__back` / `__title` / `__subtitle` / `__trailing`).
- **Variant modifiers** — `.btn-{primary,secondary,neutral,tertiary,link,destructive,success}` (chrome family); `.btn-{sm,lg,xl}` (size axis); `.chip-{neutral,delta-pos,delta-neg}` (tone) + `.chip--{bare,outline}` (chrome) + `.chip--{sm,lg,xl}` (size); `.alert--{warn,danger,success,info}` (tone); `.ui-amount-card--zero` (state); `.list-item--lg` (size).
- **One primitive can mix both axes.** `.chip` has tone modifiers (`.chip-neutral` / `.chip-delta-pos`) AND chrome modifiers (`.chip--bare` / `.chip--outline`) AND a child slot (`.chip-window`). The axes are independent; each picks the shape that fits what it expresses.

**Named-recipe registry — when one renderer composes BOTH axes into named, swappable presets.** When a single-source renderer needs **two or more** whole-component compositions a consumer picks between (not just per-instance slot/modifier choices), encode them as a **declarative registry** threaded through the renderer, rather than forking renderers or hand-maintaining parallel variants. The shape: a column/slot map (id → `{head, align, slot}`) + a recipe registry (id → `{label, columns[], rowClass?, ring?}`), where `columns` is the **structural** axis (which cells + which pair representation) and `rowClass` + `ring` are the **presentational** axis (chrome on the same cells). `renderRow(order, recipeId)` iterates the recipe's columns; `renderHead` builds the matching head; the skeleton path shares the renderer so it tracks the recipe automatically. Adding a recipe = one registry entry; a registry-driven picker + the catalog both iterate the registry, so neither can drift. Reach for this when "a few versions to choose from" is the requirement; reach for plain slots/modifiers when it's per-instance composition.

**The corollary — collapse the registry when it drops to ONE entry.** A named-recipe registry earns its keep at 2+ compositions; at one it's dead indirection. When a registry falls to a single entry, **collapse it back to a plain single-source renderer**: keep the slot/column map + the single-source `renderRow`/`renderHead` (those carry the skeleton-can't-drift guarantee independent of any registry), but drop the registry object, the `recipeId` parameter, the per-recipe modifier class, and the picker UI; fold the surviving recipe's presentational chrome into the component's BASE look. The Orders table row (`orders-list.js`) is the **worked precedent for the full arc** (June 2026): it shipped as a `ROW_RECIPES` registry (`default` `--text-body`/`--lg` + `compact` `--text-sm`/`base`, with a live A/B rail picker); `default` was retired, and once it dropped to one entry the registry was collapsed to a fixed `COLUMN_ORDER` array + `renderRow(order)`, the `compact` dense type folded into the `.data-table--orders` base (`--data-cell-text-size: --text-body`), and the rail picker + `data-row-recipe` attr removed. The `COLUMNS` map + single-source renderer stayed; only the registry indirection went. Spec: `design/components/orders-table.md → The order row`.

**The altitude-shift corollary — when a collapsed registry's pattern returns at the NEXT altitude.** Collapsing a registry doesn't mean the multi-composition pattern was wrong; it means the *altitude* was wrong for the consumer count at that moment. The natural sequel: when a second, third, fourth *different consumer surface* needs the same row architecture (different columns, different domain, different identity — but the same rich-table chassis), the registry comes BACK — one altitude up. Not `ROW_RECIPES` re-introduced inside one component, but **a column-config per consumer table** fed to a shared renderer / cell-builder library. The Orders / Portal-tables arc (June 2026) is the worked example of the full two-step: (a) Orders collapsed its in-component registry once it dropped to one composition, and the `COLUMN_ORDER` array stayed as Orders' fixed column list; (b) when Portal needed Fee Management / Members / Transactions tables with avatar + chip + meter rows on the same chassis, the answer was NOT to resurrect `ROW_RECIPES` inside Orders, but to promote the row layer itself (`.data-table--rich` + `.data-cell*` + `.table-panel`) and let each Portal table own its own column-config — same pattern as Orders' `COLUMN_ORDER`, just one altitude above. **The diagnostic for the altitude.** Ask: *do the multiple compositions belong to ONE component (rare; usually drift) or to DIFFERENT consumer surfaces (the natural case)?* If one component, the registry stays inside it OR collapses to one. If different surfaces, the chassis promotes and each surface owns its own config. Collapse-then-re-emerge-higher is the healthy lifecycle, NOT a reversal of the earlier collapse.

**Host-context modifiers — one variant per host primitive, named after the host.** When a chrome-bearing primitive composes inside different host primitives that each carry their OWN chrome (and would clash with the default), the right answer is a small family of variant modifiers — one per host context — each suppressing the default chrome so the host's recipe cascades through. Name each modifier after the host primitive (the class name, not an abstract intent name) so the API reads as: *"this trigger, configured for the `.foo` host."*

Three precedents in the dropdown trigger family (May 2026):

| Modifier | Host context | What it suppresses |
|---|---|---|
| `.dropdown__trigger--bare` | `.btn-borderless` toolbar row | bg + border + sets its own `--text-muted` color (host has no color recipe; the modifier owns it) |
| `.dropdown__trigger--flush` | `.setting-row` | padding 0 (chevron aligns to row's right column); transparent at every state |
| `.dropdown__trigger--seg` | `.seg-overflow` | every chrome property (bg, border-color, box-shadow) at every state (rest / hover / focus-visible / `[data-open]`); defers color to `.seg-item`'s own state recipe (rest muted, hover one notch brighter, active full ink via `.is-active`) |

These three variants are for triggers that **stay a `.dropdown__trigger`** — a label-and-chevron control that reads as part of the dropdown family but blends its chrome into a host. When a fourth such host context emerges, author a host-named variant (`--tabbar`, etc.) following the same shape. Don't reach for `--inherit` / `--ghost` / `--minimal` — the host-named convention is what makes the variant discoverable from the host's catalog page.

**`.dropdown__trigger--card` is a different axis — it ADDS chrome, not suppresses it (June 2026).** The three variants above all *strip* the default trigger chrome so a host's recipe shows through. `--card` is the inverse: it dresses the trigger in the canonical `.option-card` recipe (full-width, `--surface-card` + `--elev-1`, a 1 px transparent rim → 64 %-accent rim on `[data-open]`, accent hover wash routed through `--dropdown-card-tone`), upgrades the wrapper to block flex via `:has(.dropdown__trigger--card)`, and pairs with `.dropdown__panel--match-trigger` (JS sets the panel width to the trigger's, lifting the 288 px ceiling). Reach for it when the trigger is a prominent full-width **column** selector — an org / workspace / account switcher that should read as a card, not a compact toolbar chip. So it is NOT part of the host-named suppress-chrome family above; don't file a new chrome-suppression context under `--card`, and don't reach for `--card` for a compact toolbar trigger (that's the default trigger or `--bare`). Avatar at the 32 px (sm) tier, label one tier up (`--text-body`), 16 px chevron. Canonical consumer: the Portal left-rail org switcher. Catalog: `#dropdown-default` (Trigger-shape zone) · spec: `design/components/navigation.md → Dropdown`.

**Icon-only / standard-component triggers — use `data-dropdown-trigger`, NOT a chrome-suppression variant (May 2026).** When the trigger should wear a *different button component's* chrome entirely (the canonical case: an icon-only menu button that reads as a sibling of the `.btn-icon` buttons beside it — playground rail theme-actions kebab), don't author a `.dropdown__trigger` variant to mimic that component. Instead, put the menu BEHAVIOR hook on the standard button:

```html
<div class="dropdown">
  <button class="btn-icon btn-borderless btn-circle" data-dropdown-trigger
          aria-haspopup="menu" aria-expanded="false" aria-label="…">…</button>
  <div class="dropdown__panel dropdown__panel--align-end" role="menu" popover="manual">…</div>
</div>
```

`initDropdown()` (shared.js) matches `.dropdown__trigger, [data-dropdown-trigger]`, so the standard button becomes a trigger with **zero** dropdown chrome — no variant, no specificity dance, no open-state leak. The button keeps `aria-haspopup` + `aria-expanded` for a11y (the JS toggles `aria-expanded`); `data-dropdown-trigger` is purely the JS dispatch hook. This is the right answer whenever the trigger is "a standard button that happens to open a menu" rather than "a dropdown's own styled trigger." The chrome-suppression variants above remain for the label-and-chevron case where the trigger genuinely IS a `.dropdown__trigger`.

**Why this beats an implicit `.foo--marker.baz` composition override block** (full bug history in `bug-history.md → Implicit composition overrides — incomplete by default`): implicit overrides are hidden API. They live as quietly-named multi-class selectors at single-class+single-class specificity; they almost always cover a *subset* of the chrome properties / states that need suppressing; new state added to the base primitive silently leaks through. A named modifier is discoverable in the catalog, explicit about which states it suppresses, exhaustive by design. See memory `promote-override-to-named-modifier` for the durable preference signal.

**Anti-patterns this rule prevents.**

- **Don't author `.ui-foo--compact` / `--sticky` / `--row` chrome classes** when each overlay/header/list-row context just needs a different *combination* of slots. That's a slots-axis problem masquerading as a chrome problem; modifier classes can't combinatorially express N optional regions without a class per combination. Reach for slot composition + let consumers omit what they don't need.
- **Don't expect consumers to omit `<span>` markup** to "make the button primary." Same content, different chrome → modifier, not slot. Use `.btn-primary`.
- **Don't introduce slot + modifier siblings that mean the same thing.** If `.ui-foo` has a `__price` slot AND a `.ui-foo--with-price` modifier, one is redundant. Pick one mechanism per axis.

**Atomic retirement applies to both.** When renaming slots (`.ui-price-chart__header` → `.ui-token-info__price`, May 2026 v2) or modifiers (`.btn-alpha` → `.btn-neutral`, May 2026 v16), migrate every consumer in one commit. No aliases. See `### Primitive retirement is atomic — no aliases, no transitional period`.

**Atomic retirement applies to value-override blocks too — not just renames (June 2026).** When a migration re-points a theme-aware alias to new primitives (e.g. `[data-theme="light"] { --spectral-N: var(--spectral-N-light); }`), DELETE any stale literal override of that same alias in the same commit. CSS same-selector source-order means a leftover `--spectral-N: oklch(…)` block declared *after* the alias silently WINS — so the migration looks complete (primitives defined, alias present, spec updated) while the old values keep rendering. The v3 spectral light palette shipped this way: a v2 "vibrant" literal block at `styles.css:1535` shadowed the v3 alias for weeks, so the live light palette was v2 while design.md §Spectrum + personas.html were v3. The diagnostic tell: a "dead-looking" alias block may be the live-INTENDED one, shadowed by the thing that should have been retired — and a token is only dead once you've grepped ALL consumers, not just the alias chain (`personas.html` read the `-light` primitives directly). Read the COMPUTED value, not the source, to know which block wins. Full diagnostic: `bug-history.md → Stale literal override shadows a migrated alias`.

**Auto-detect content-driven state via `:has()` — don't require an `.is-foo` opt-in modifier on the parent when the trigger IS the content shape.** When a parent's state depends on the SHAPE of its content (a specific child class present, a specific descendant existing, content type signal), the parent can self-style via `:has()` rather than require an author opt-in flag. Markup stays clean, the state is impossible to forget, and binding new content automatically lifts the state.

The rule: **content-driven state → `:has()` on the parent. Author-decided state → opt-in modifier (`.is-selected`, `.is-active`, `.is-error`, `.is-loading`).** Don't conflate the axes.

Canonical example (May 2026 — chip-avatar placeholder). The `.chip-avatar` auto-quiets its label color whenever a `.avatar--placeholder` or `.avatar-tandem--placeholder` sits inside:

```css
.chip-avatar:has(.avatar--placeholder),
.chip-avatar:has(.avatar-tandem--placeholder) {
  color: var(--text-muted);
}
```

No `.chip-avatar.is-placeholder` opt-in needed — the placeholder content IS the trigger. The moment the picker rebuilds the tandem with real `<img>` children (via `swap.js → liftTandemPlaceholder()`), the selector stops matching and the chip restores primary ink in the same render. Authors can't forget to flip a flag; the flag doesn't exist.

Second canonical example (May 2026 — borderless segmented control with avatar tabs). The `.seg-item` already auto-detects an SVG child via `.seg-item:has(> svg)` (icon-bearing items flip to inline-flex). The avatar variant follows the same shape — when a borderless seg-item carries an `.avatar-tandem` (or lone `.avatar`), the item becomes a square cell sized to the borderless tier height:

```css
.seg--borderless .seg-item:has(> .avatar-tandem),
.seg--borderless .seg-item:has(> .avatar) {
  display: inline-flex; align-items: center; justify-content: center;
  flex: 0 0 auto;
  aspect-ratio: 1 / 1;
  padding-inline: 0;
}
```

No `.seg--avatar` opt-in modifier — the avatar IS the trigger. The retired `.chart-token-tabs__item` per-instance padding rule in `swap.css` is the canonical case that prompted this: a bespoke 32 px / 2 px padding override that should always have been the primitive's job. Same precedent shape as the chip-avatar rule above; same shape as `.seg-item:has(> svg)`. Live demo: `design-system/index.html#seg-borderless` — Avatars zone.

**When NOT to auto-detect** — the state is external / author-decided / orthogonal to content:
- `.is-selected` / `.is-active` on list rows and cards (the row's CONTENT is the same whether selected or not; selection is a state choice)
- `.is-error` on form fields (validation result, not content shape)
- `.is-loading` on containers (orthogonal to the content underneath)
- `.is-expanded` on disclosure controls (toggle state, not content state)

These are correctly opt-in modifiers. The signal: *would removing the parent's class change the underlying meaning of the content, or just its presentation?* If just presentation, opt-in. If the content itself differs by class presence, `:has()` is honest.

**`:has()` cost.** Browser support is universal as of late 2024 (Chrome 105+, Safari 15.4+, Firefox 121+). DOM-traversal cost is real but negligible for single-level checks (`.parent:has(.child--mod)`). Avoid deep / wildcard selectors (`:has(... :nth-child(...))`) when an opt-in modifier would do — the cost balance flips at some point. For single-level state mirrors (the canonical case), `:has()` is the cleaner answer. [[auto-detect-over-opt-in-class]]

**Bug history.** May 2026 — `.ui-token-info` v2 rebuild almost shipped with `.ui-token-info--compact` / `--sticky` / `--row` modifier classes for the overlay variants on the roadmap (popover hover-card, sticky scroll-header, portfolio row). But each context wants a different combination of slots (popover = identity + 24h delta only; sticky = identity + price only; portfolio = identity + price + 24h delta) — chrome-class variants would have needed N classes for the N combinations, and the slots are clearly the axis that varies. Refactored to composable slots before the v2 commit; the rule was extracted from that decision. (Memory: `composable-slots-vs-variant-modifiers`, `primitive-useful-standalone`, `auto-detect-over-opt-in-class`.)

### Reusable primitives must be useful standalone — the catalog Preview is the test

When authoring a reusable `.ui-*` or universal primitive intended for diverse contexts (overlay, popover, sticky header, notification, list-row heading, portfolio row), the **catalog Preview pane is the standalone-completeness test**. If the preview reads as "just a header" or "missing context" without the typical pairing component from its production page, the boundary is drawn wrong — fold the missing surface IN, don't expect consumers to assemble it.

**The check, stated as a question.** Before authoring a primitive's catalog card: *would this primitive read complete if dropped into a popover, sticky header, notification, or hover-card WITHOUT the component it's currently paired with in the playground?* If no, two correct moves:

1. **Fold the missing surface in** via composable slots (see `### Composable slots vs. variant modifiers`) so consumers can omit what they don't need. This is usually the right move when the missing content is structurally part of the primitive's role.
2. **Rename the primitive to a narrower role** so its scope is honest — e.g., `.foo-header` rather than `.foo-info` if the primitive truly is just an identity row. Then the catalog preview matches the name and the boundary isn't misleading.

Don't ship a primitive that only makes sense as half of a pair. Designers scanning the catalog by variant name expect each card to stand on its own.

**Canonical case (May 2026).** `.ui-token-info` v1 was authored as an identity-only row (avatar tandem + ticker · chain name), paired with `.ui-price-chart__header` below it in the playground for spot price + deltas. Catalog Preview surfaced the gap: *"Token info is missing price chart info, so by itself it is not that useful of a component. It just looks like a header."* The boundary between `.ui-token-info` and `.ui-price-chart` was drawn wrong — spot state belonged in the identity primitive (it's WHAT this token is worth, not WHERE it's been). Atomic v2 rebuild folded the spot row INTO `.ui-token-info` (`__identity` + `__price` + `__deltas` slots); `.ui-price-chart__header` retired atomically; the chart became the historical-visualization layer only. Both primitives now read complete standalone.

**Why this matters beyond the one case.** Reusable primitives are reused by definition — across product, marketing, dashboards, overlay sets, and future feature work that doesn't exist yet. A primitive designed for one production composition leaks its dependency into every future consumer. The catalog is the contract: what reads there is what reaches the system. If the catalog preview is incomplete, the primitive is incomplete; consumers will either reach for the wrong primitive (because this one looks too sparse) or duplicate its missing slots elsewhere (because this one didn't provide them).

**Audience-priority connection.** This rule is the engineering consequence of `### Audience priority — Designers > Engineering > Marketing`. Designers scan the catalog by variant name plus a glance at the live preview. If the preview lies about completeness, scanning fails. (Memory: `primitive-useful-standalone`.)

### Inline `<code>` renders as a chip site-wide

Every inline `<code>` element on every page of this codebase auto-renders as a filled mono pill (the **code chip** — `.code-chip`). The trigger is the bare `code` element selector in `styles.css` — no container class needed, no opt-in. Wrap any code identifier — class name, modifier, custom-property, file path, command, token name — in `<code>` and the chip lands automatically.

| Context | Behaviour |
| --- | --- |
| Bare `<code>` anywhere on the site | Default chip — 12px mono · 600 · uppercase-cancelled · tracking-cancelled · filled bg (`currentColor 7%`) · no border · 5px radius |
| `<code>` inside `.ds-doc-card__label` | Quieter override — 0.72em, weight 500, muted colour |
| `<code>` inside `.ds-doc-eyebrow` | Louder override — 1em (bumped from default 0.85em so it reads at the eyebrow's 12px size) |
| `<code>` inside `.ds-doc-codeblock` | **Suppressed** — code blocks have their own syntax-highlight token spans (`.comment`, `.string`, `.tag`, etc.) and a chip would compete |
| Non-`<code>` host needing chip styling | Add the explicit `.code-chip` class (rare — only when semantics require a non-`<code>` host) |

**Authoring rules.**

- **Reach for `<code>` for code identifiers, not for arbitrary mono runs.** The chip is semantically "this is a code identifier" — class name, modifier, file path. Don't wrap a sentence of mono text in `<code>` just to make it mono; use `.mono` (or a stylesheet rule) for that.
- **Don't suppress the chip with inline `style`.** If a specific context needs a different treatment (smaller, louder, different colour), add a context override (`.my-parent code { font-size: 1em; }`) or a modifier (`.code-chip--strong`) in `styles.css`. Track the modifier on the catalog card.
- **Don't reach for the old name.** `.ds-doc-class` was retired in the May 2026 v10 promotion. No aliases — the migration was deliberate and the new name is universal across product, marketing, dashboards, and catalog.
- **The shared rule cancels parent typography.** `text-transform: none` and `letter-spacing: 0` are baked into the chip rule, so the class name always reads as actual code even when the parent is uppercase (an eyebrow) or has wide tracking. Don't author per-context cancellations.

**Where this is enforced.**

- `styles.css` — search `.code-chip — universal inline-code chip` for the anatomy comment block + the bare-element-selector rule + the label / eyebrow context overrides + the codeblock suppression rule.
- `design.md §13 → Code chip — .code-chip` — full written spec with the recipe, contexts, and rationale.
- `design-system/index.html#code-chip` — live catalog card with side-by-side previews (default, label override, eyebrow override, codeblock suppression).
- `.claude/skills/lifi-ds-docs/references/writing-style.md → The label rule` — guidance for when to use `<code>` in card labels and captions.

### Muted helper copy uses `.muted` site-wide

Every "muted, smaller, non-data informational line" in this codebase uses the `.muted` primitive (May 2026 v20 — renamed from `.hint`, which had itself been promoted from `.form-hint` in v11). One typography, four placement modifiers. **Never inline `style="color:var(--text-muted|faint)"` on a plain element to make a muted line** — that's the ad-hoc pattern this rule replaces (~144 occurrences across `styles.css` before the v11 promotion).

| Context | Class | Margin | Alignment |
| --- | --- | --- | --- |
| Below a form input / select / textarea | `.muted.muted--field` | `margin-top: --space-8` | inherits (left) |
| Inside a component slot (card footer, etc.) when data is absent | `.muted.muted--slot` | none — slot owns spacing | inherits |
| Standalone block under a heading / hero / empty state | `.muted.muted--block` | none | center |
| Anywhere — typography only, host owns spacing | `.muted` (bare) | none | inherits |

Base typography is shared across all four: 12 px (`--text-caption`) · weight 400 (`--fw-regular`) · ink 40 % (`--field-placeholder` = `--text-faint`) · `--lh-caption`. Inline `<a>` auto-styled in `--accent-primary-mid` with underline.

**Authoring rules.**

- **Pick the modifier by placement, not by tone.** "Informational" / "instructive" / "warning" tone is orthogonal. For validation errors use `.form-error` (danger-toned sibling), not a `.muted` modifier.
- **Don't suppress the typography with inline `style`.** If you need different chrome, use a modifier — or add a new one in `styles.css`. Never re-author the typography per instance.
- **Don't reach for `.muted` for body prose.** Muted lines are short (typically ≤ 80 chars). For paragraph-length copy use `.ds-doc-text` (catalog) or a styled `<p>` (product / marketing).
- **`.muted a` is auto-styled** — don't add `.ds-doc-link` or per-link styling on inline `<a>`. Hover state included.
- **Don't reach for the old names.** `.form-hint` was retired in the v11 promotion (→ `.hint`); `.hint` was then retired in the v20 rename (→ `.muted`). Both migrations were atomic; no aliases.

**Where this is enforced.**

- `styles.css` — search `.muted — universal muted helper / placeholder copy` for the recipe + the four modifier rules + the link affordance block.
- `design/components/forms.md → Muted — .muted + --field / --slot / --block` — full written spec with bad/good copy pairs.
- `design-system/index.html#muted` — live catalog card with the four context variants side-by-side.

### Sizing Ladders — sm / default / lg / xl, every multi-size component aligns

Every multi-size component graduates through the same 4-tier vocabulary: **sm / default / lg / xl**. Heights step +8 (32 / 40 / 48 / 56), labels step through the typography scale (12 / 14 / 16 / 18 — `--text-caption` / `--text-sm` / `--text-body` / `--text-body-lg`), radii step +4 (8 / 12 / 16 / 20), every value a `--space-*` or `--text-*` token. Same-tier siblings always pair (`.btn-lg` ↔ `.seg-lg` ↔ `.input--lg`). Don't author new tier names; don't fork the math. When a component needs different padding to fit its label on the height ladder, widen the line-box via calc-based `line-height` rather than inventing a non-token padding value.

**Source of truth.** Spec lives in `design/components/foundations.md → Component Sizing Ladders`; live reference + application matrix at `#sizing-ladders` in `design-system/index.html`. Buttons (`#btn-sizes`) and the segmented control are the canonical implementations. The matrix tracks which other components are aligned, drifting, or pending an `xl` tier — when you bring `.input--*` or `.switch-*` into the ladder, update the matrix in the same edit.

**Don't reach for one-off sizes.** If a layout needs an in-between height, the answer is to pick a tier (or extend a missing tier through the system, not as a one-off CSS rule). Forking the ladder is the failure mode this rule exists to prevent.

**The rendered value is what counts — not the declared `min-height`.** A `min-height: var(--space-56)` on a component whose content actually renders 73 px is a failure of the ladder, not a success. Browsers stretch past `min-height` when content overflows, and most consumers never notice until a designer flags it (this happened to `.list-item--lg` — its declared min-height of 56 vs. its own content recipe ≈ 73 px). When you size a component:

1. **Pin to a tier with `height: var(--space-N)`** (fixed), not `min-height`. The row hits the ladder value or you pick a taller tier.
2. **Constrain content to fit** the tier: tight unitless line-heights (`1.2` for body type, `1.1` for display type), token-only padding, and a slot gap chosen so `padding-y * 2 + body-height ≤ tier-height`. For the canonical 64 tier with `.list-item--lg` padding `12/20` + `.list-item__body` gap of `--space-2`: title 18 px (1.2 lh = 21.6) + 2 gap + desc 12 px (1.2 lh = 14.4) = 38 px body inside the 40 px content budget.
3. **If content can't fit any tier in the ladder, the body is wrong, not the tier.** Either shrink the desc to `--text-caption`, drop the desc line entirely, or step the avatar up a tier so it visually encompasses the body block. Never invent a new height value to accommodate overgrown content.

**Tokens, not bare pixels — for every spacing property.** `padding`, `margin`, `gap`, `width`, `height`, `min-height`, `max-height`, `border-radius`, `top` / `right` / `bottom` / `left` (positional) must reference `var(--space-*)`. Type sizes reference `var(--text-*)`. Colors reference semantic tokens (`--text-primary` / `--text-secondary` / `--text-muted` / `--accent-*` / `--surface-*`). The 4 px grid + named tiers at `--space-2` through `--space-160` covers every legitimate need. The only legitimate bare-pixel values are `1px` / `2px` for hairline borders and outlines (where the px IS the visual quantity) — and even those should prefer `var(--space-2)` when the value is `2px`.

**SVG attribute coordinates follow the same 4-grid (May 2026).** `viewBox`, `x`, `y`, `width`, `height` on shapes, `transform="translate(…)"` values, and `<foreignObject>` dimensions aren't strictly CSS pixels — they're viewBox units. But the spacing-ladder rule still applies semantically. Chart chrome (gridlines, baselines, tick text positions, pivot transforms, axis-title positions, foreignObject dimensions) should land on multiples of 4. Authoring `y="270"` for a baseline, `pivot=285` for a transform, or `x="66"` for a tick position is the same kind of drift as authoring a CSS `padding: 7px` — even though the browser doesn't enforce 4-grid alignment in SVG attributes, the eye registers the rhythm break and future contributors reading the chart's geometry will perceive it as off-system. Bar widths and gaps that derive from data count (e.g. 12 bars in a fixed plot width) are exempt — but the chrome anchoring them isn't. Reference: the `.bar-chart` migration (May 2026) realigned every anchor to multiples of 4 — gridlines y=32/92/152/212/272, ticks x=68 with y=36/96/156/216/276, baseline y=272, pivot y=288, foreignObject y=-12 height=24, viewBox `800 / 380` aspect.

**Pre-ship checklist — verify, don't assume.** Before declaring any new or modified component done, run these checks against the rendered DOM (not the source CSS):

- **Height / width** — `preview_inspect` the component; the computed `height` (or `min-height` if intentional) must equal a `--space-*` token value. Same for any explicit `width`. If it lands between tiers, fix it.
- **Reserved border slots — kill the paint, keep the slot.** Base rules reserve `border: 1px solid transparent` so every variant lands on the height ladder (the canonical button shared rule is the reference). When overriding, override `border-color`, not the `border` shorthand. `border: none` drops the slot AND removes 2 px from the total box — the symptom is "default tier renders 2 px below its token value" (height 38 where the ladder says 40). Same trap documented for navigation-only cards in the canonical UI card recipe; hit `.btn-neutral` in May 2026 (was `border: none`, fixed to `border-color: transparent`).
- **Padding / gap / radius** — computed values must equal token values. If you see a bare `7px`, `9px`, `13px`, somebody forked the ladder. Either snap to the nearest token or escalate the discrepancy as a design-decision question.
- **Type sizes** — `font-size` must equal a `--text-*` value. `line-height` may be unitless (1.1–1.6) when the type-pair's `--lh-*` token doesn't fit (display + clamped types are the canonical exception, documented in the Display line-height rule).
- **Same-tier siblings pair** — if you applied `.list-item--lg` to a row, the buttons inside should be `.btn-lg`, the inputs `.input--lg`, etc. Cross-tier mixing (`.btn-xs` inside a `--lg` row is fine for tertiary affordances; `.btn-sm` inside a `--lg` row probably means the row should be smaller).
- **The validator catches what it can.** `scripts/validate-doc.py` doesn't yet scan CSS for bare pixels — when it does, the rule above is what it enforces. Until then, this manual checklist is the contract.

The reflex when something "looks slightly off" should be: query the computed value, find the bare px or out-of-ladder number, snap it to a token. Drift is almost always a measurable token violation, not a vague aesthetic call.

**Migration history — `.btn-alpha` was 2 px short at the default tier (38 → 40 px, May 2026; class subsequently renamed to `.btn-neutral` in v16).** Discovered while pairing `.dropdown__trigger` against `.btn-alpha` for the alpha-family migration. The shared button base reserves `border: 1px solid transparent` — a 2 px slot the cascade paints invisibly. `.btn-alpha` overrode with `border: none`, dropping the slot and pulling default-tier height to 38 (commit `aae4586`). Fix: swapped to `border-color: transparent` — slot stays, paint stays suppressed. Other tiers (sm 32, lg 48) were already correct because their padding tokens happened to recover the 2 px; the fix moves the correction back into the slot where it belongs. Same principle as the "Card root element — `border: 0` is explicit, not omitted" rule (kill the paint, keep the slot). The recurring trap was generalized into the Sizing Ladders pre-ship checklist's "reserved border slots" check (commit `1d970cc`) so future variants don't re-introduce the same drift by overriding `border: none` without re-asserting the slot. Class was renamed `.btn-alpha → .btn-neutral` in the v16 promotion sweep ([[btn-alpha-to-btn-neutral-v16]]); the bug-history paragraph above keeps the old name for archaeological accuracy.

### Chart viewBox must match wrapper aspect — never paint past the canvas

Catalog Preview demos for any chart primitive with a wrapper-level `aspect-ratio` rule (`.bar-chart`, `.line-chart`, `.area-chart`, future radial chart consumers — anything where the consumer container enforces a fixed aspect) MUST use a viewBox whose aspect matches the wrapper's. Mismatch causes a two-failure compound: the SVG fits-inside its container with empty space on the sides (because `preserveAspectRatio="xMidYMid meet"` is the SVG default), AND rotated content positioned in viewBox coordinates that fall outside the viewBox PAINTS PAST the SVG's CSS bottom edge into the surrounding layout (because `svg { overflow: visible }` is the site convention, per the LI.FI mark rule).

The visible failure: rotated identity labels (the `<g class="chart-x-identity">` units with `<foreignObject>` HTML content) collide with the caption text or next zone below the chart. Designers scanning the catalog see "broken layout, chart bleeds into prose."

**The contract — the canonical plot frame.** The shared geometry lives in ONE module — `chart-frame.js → window.LifiCharts.chartFrame` — consumed by both `dashboard-live.js` (the live binder) and `design-system/ds-bar-charts.js` (the catalog renderer, which hydrates the `#bar-chart-default` Preview demos from sibling JSON specs). Edit the frame there once and both surfaces follow; they cannot drift (this single-source refactor, June 2026, retired the hand-coded static demos that previously had to be re-aligned by a scripted coordinate sweep). The frame: y-axis gutter `marginL = 48` (only needs to clear 3-char tick labels — CSS mirror `--bar-chart-plot-inset-start: 6%`), right breathing `marginR = 12` (`--bar-chart-plot-inset-end: 1.5%`), top `gridTop = 32`. **The baseline is `viewBox_height − bottomMargin`** — and `bottomMargin` is sized to *what sits below the baseline*, NOT a fixed 8 px:

| Aspect / viewBox | What's below the baseline | bottomMargin | Baseline y | Identity y |
|---|---|---:|---:|---:|
| `800 / 380` (default `.bar-chart`) | rotated avatar-identity labels | 108 | 272 | 288 |
| `800 / 320` (`--no-avatars`, flat category labels) | flat text labels | 48 | 272 | — (labels at 296) |
| `800 / 320` (`--no-avatars`, bare) | nothing | 32 | 288 | — |
| `800 / 320` (`--avatars-only`) | centered avatar marks | 48 | 272 | 288 |
| `800 / 280` (compact tier) | rotated labels | 80 | 200 | 216 |
| `800 / 480` (hero tier) | rotated labels | 160 | 320 | 340 |

Label offsets, all on the 4-grid: identity translate at `baseline + 16`; flat category labels at `baseline + 24`; value labels at `bar-top − 8`.

**Keep `bottomMargin` a multiple of 16 so the gridlines also land on the 4-grid.** The 4 evenly-spaced gridlines sit at `baseline − (i/4) × plotH` (`plotH = baseline − 32`); for those to be multiples of 4, `plotH` must be divisible by 16. On the `800/320` frame, `bottomMargin: 32` → `plotH 256` → gridlines `288/224/160/96/32` (clean); an off-grid `24` → `plotH 264` → gridlines drift to `230/98`. When authoring a new tier, pick `bottomMargin` so `(viewBox_height − bottomMargin − 32)` is divisible by 16; for rotated avatar labels reserve ≥ ~96 below the baseline (rotated content extends `label_width × sin(45°)` ≈ 35–50 px diagonally; the budget absorbs it). Bar widths and x-positions derive from the data count and are exempt from the 4-grid — but every chrome anchor obeys it.

**Don't pick "compact-feeling" viewBox dimensions disconnected from the wrapper.** A viewBox of `0 0 500 280` LOOKS like a sensible mini-demo size but breaks the moment the demo lands inside a wrapper that enforces `800/380` aspect. The wrapper wins on rendered dimensions; the SVG's viewBox is what determines where content paints inside that rendered box.

**Authoring rules.**

- **Before writing the SVG markup, read the consumer wrapper's `--*-aspect` slot.** Match the viewBox aspect to the slot's value. Pick the viewBox by the WRAPPER's aspect-ratio first, then size bar coordinates / chart chrome inside that viewBox.
- **Verify post-write with a visible-leaf metric.** Don't measure the rotated parent `<g>`'s `getBoundingClientRect()` — the bbox includes the `<foreignObject>`'s empty space and produces misleading negative-inside numbers. Measure the leaf `.chart-x-identity__label` (or `.avatar` for avatars-only mode) against the SVG's bottom edge. Positive = inside.
- **Screenshot the result.** The metric is necessary but not sufficient — `overflow: visible` lets content paint past the SVG in ways the metric can miss (text descenders, glow filters, fractional rounding). Final verification is visual.
- **Pair with the 4-grid rule.** Chart chrome coordinates (baselines, ticks, gridlines, pivots, foreignObject anchors) still land on multiples of 4 per the Sizing Ladders rule. The viewBox-aspect rule and the 4-grid rule compose; obey both.

**Bug history.** May 2026 — `.bar-chart` mini-demos shipped at `viewBox="0 0 500 280"` inside an `800/380` wrapper, so rotated identity labels painted past the SVG bottom. Full diagnostic + the visible-leaf-vs-rotated-parent measurement trap: `.claude/skills/lifi-ds-docs/references/bug-history.md → Chart viewBox must match wrapper aspect`.

### Padding follows content shape — asymmetric for text buttons, symmetric for label-plus-icon pills

The button family (`.btn-primary` / `.btn-secondary` / `.btn-neutral` / etc.) uses **asymmetric padding** at every tier — 8/20 (sm), 12/24 (default), 12/32 (lg), 16/40 (xl). The horizontal value is always wider than the vertical. **This isn't an accident** — text buttons render label content that's wider than the line-height, so they need horizontal breathing room to feel balanced. Vertical padding tracks the line-box; horizontal padding adds the breathing.

But **label-plus-icon pill triggers** (dropdown triggers, anchored menu triggers, future autocomplete triggers, tag-shaped chips) have different proportions. The trigger content is **label + gap + small icon** — the icon contributes meaningfully to the width, and the inter-element gap takes care of internal breathing. Inheriting text-button padding (12/24) gives ~48 px of total horizontal padding for content that's often only 40-80 px wide — the trigger reads as oversized whitespace bracketing a short label.

**The rule.**

- **Text buttons** — asymmetric `12 / 24` (default), `8 / 20` (sm). The button family base + size modifiers carry this. Don't override.
- **Label-plus-icon pill triggers** — **symmetric** `12 / 12` (default), `8 / 8` (sm). Hugs the content evenly; preserves the canonical 32 / 40 height ladder (vertical padding × 2 + line-box + 2 px transparent border = 32 / 40); trims ~24 px of unmotivated width per trigger.
- **When composing `.btn-sm` onto a pill trigger**, override `.btn-sm`'s asymmetric 8/20 with the symmetric 8/8 explicitly. Specificity `(0, 2, 0)` (e.g., `.foo-trigger.btn-sm`) beats `.btn-sm`'s `(0, 1, 0)`.

**The trigger to apply this.** Ask: does this primitive render TEXT content (which has natural left/right asymmetry, label is wider than line-height) or LABEL + ICON content (a tight pair whose width is content-driven)? If TEXT → asymmetric. If LABEL + ICON → symmetric.

**Canonical implementation.** `.dropdown__trigger` in `styles.css` — the `:where()` block sets `padding: var(--space-12)` (symmetric) and the `.dropdown__trigger.btn-sm` rule re-asserts `padding: var(--space-8)` so the sm composition doesn't inherit `.btn-sm`'s 8/20. Comment block explains the choice. May 2026 migration retired the inherited 12/24 default that read as oversized for `Sort by ↓`-style triggers.

**Don't extend this to actual buttons.** Symmetric 12/12 on `.btn-primary` would shrink "Continue" or "Place order" to a chip-sized button that doesn't read as a primary CTA. The rule is specific to label+icon pills, not buttons.

### Typography tokens — every type property reaches for a variable, never a literal

Every typography property on every selector resolves to a `var(--*)` token. No literal pixel sizes, no literal weight numbers, no literal letter-spacing em values. Same discipline as the spacing rule above — tokens are the contract; literals are drift waiting to compound.

The contract by property:

| Property | Token family | Notes |
|---|---|---|
| `font-size` | `var(--text-*)` — see `styles.css :root` (search `--text-hero`) | Hero / h1 / h2 / h3 / body-xl / body-lg / body / sm / caption / micro. Never a bare `px` / `rem`. |
| `font-weight` | `var(--fw-*)` — `--fw-light` (300), `--fw-regular` (400), `--fw-medium` (500), `--fw-semibold` (600), `--fw-bold` (700), `--fw-extrabold` (800), `--fw-black` (900) | Never a bare `400` / `600` / `700`. The numeric literal IS the value the token resolves to; using the token makes the *intent* legible and lets the whole system flex weight if the brand ever swaps fonts. Ladder is currently 100% tokenized across `styles.css` + `swap.css` + `page.css` + `doc-shared.css` (May 2026 sweep). |
| `letter-spacing` | `var(--tracking-*)` — `--tracking-crunch` (-0.05), `--tracking-tighter` (-0.04), `--tracking-tight` (-0.03), `--tracking-snug` (-0.015), `--tracking-normal` (0), `--tracking-loose` (0.06), `--tracking-wide` (0.10), `--tracking-wider` (0.14) | Pick the closest tier. If none fits within a few thousandths, the ladder probably needs a new token — flag it, don't author the literal. |
| `line-height` | `var(--lh-*)` matching the type-size pair (`--lh-h3` with `--text-h3`, etc.) | **Exception:** unitless ratios in the `1.1–1.6` range are acceptable when the type-pair's `--lh-*` token doesn't fit (display sizes that need tighter rhythm, custom tiers like the 14 px / 20 px line-box that powers `.seg-item` + `.ui-amount-card__label` at `calc(20 / 14)`). Document the ratio in a sibling comment so the next reader sees the intent. |
| `font-family` | `var(--font-sans)` / `var(--font-mono)` (defined in `styles.css :root`) | Use the token, never the literal. `var(--font-sans)` carries a richer fallback chain (`'Figtree', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif`) than the historical `'Figtree', sans-serif` shorthand. Fully tokenized across all authored CSS as of the May 2026 sweep — comments still reference `Figtree` by name as prose, not runtime style. |
| Color of type | semantic ink tokens — `var(--text-primary)` / `--text-secondary` / `--text-muted` / `--text-faint` / `--text-tertiary` / `--accent-*` / `--danger` / `--success` | Never a bare `oklch(...)` / `rgb(...)` / hex. Reach for the semantic role, not the colour. |

**Why this matters for *typography specifically* — beyond the general "tokens > literals" reason.** Typography is the densest carrier of brand voice in a UI. Literal weights and tracking quietly fragment the system: 5 selectors using `font-weight: 600`, 3 using `600` via `--fw-semibold`, 2 using `bold` (= 700 — a different value), 1 using `700` directly. Visually they almost-match; functionally they're four different specs. When the brand decides "every semibold tier across the system drops to 550 (variable-font axis)" — only the selectors using `var(--fw-semibold)` flex. The literals stay frozen. Tokenizing is what makes a brand evolution a *single-edit operation* instead of a multi-week grep-and-replace.

**Authoring checklist when writing or auditing a typography rule:**

1. **`font-size`** — must be `var(--text-*)`. If the value isn't on the ladder, the design probably wants a different tier, not a new size.
2. **`font-weight`** — must be `var(--fw-*)`. Even when the literal value happens to be the same as the token, use the token. The semantic name (`--fw-bold`) reads as *intent*; the number (`700`) reads as *implementation detail*.
3. **`letter-spacing`** — must be `var(--tracking-*)`. If you find yourself reaching for an exact em value the ladder doesn't carry, flag it before authoring — the right answer is usually "round to the nearest tier" or "the ladder needs an explicit new tier, propose one."
4. **`line-height`** — prefer `var(--lh-*)` paired with the type size; fall back to a unitless ratio in `1.1–1.6` when the pair would be visually wrong. Never a literal `px` value here — the pair-token IS pixel-anchored, so reaching for `line-height: 24px` directly bypasses the system.
5. **Color** — semantic role token, not a colour value. `var(--text-muted)` over `var(--gray-500)` over `oklch(60% 0 268)`.

**Same-position-on-the-ladder = same-token.** When two selectors render visually equivalent text — e.g., `.ui-amount-card__label` (the "Send" / "Receive" caption) and `.seg-item` (default-tier tab label) — they should literally consume the same tokens, not just resolve to equivalent values. The "are they actually the same?" check should pass at the source level (matching `var(--*)` references), not at the computed-value level. Mismatched literals that *currently* compute the same are still drift — a future tweak to one literal silently breaks the visual parity with the other. Reference: the `.ui-amount-card__label` typography rule was unified to consume `var(--fw-semibold)` + `var(--tracking-normal)` + the same 20 px line-box as `.seg-item` in May 2026 (commit "DS: Token typography rule") — same-text-different-token drift was the trigger.

**Per-component slot indirection is good.** Existing primitives (e.g., `.ui-amount-card { --amount-card-label-weight: var(--fw-semibold); }`) layer a slot custom-property over the token, so per-instance consumers can override via inline `style="--amount-card-label-weight: var(--fw-bold)"` without touching the rule. **Bind the slot DEFAULT to a token, not to a literal.** The slot is the override hook; the token-as-default is what makes the override system itself token-aware.

**Don't sneak literals through `calc()` or `color-mix()`.** A `calc(14px * 1.2)` is still a literal pixel value at the leaf — same drift, harder to find with grep. If you're doing math on type, the inputs must be tokens. The `calc(20 / 14)` exception above is line-height-specific (the line-box is pixel-anchored; the ratio is unitless mathematics, not a pixel value being snuck in).

**Where the rule is enforced.**

- The general principle lives in the "Tokens, not bare pixels" checklist (in the Sizing Ladders section above) — that section covers spacing; this section covers typography. The two together say: *no literal values on any of the system-managed properties.*
- The validator (`scripts/validate-doc.py`) doesn't yet scan CSS for typography literals. When it does, this section is what it enforces. Until then, manual review at PR/commit time.
- **Migration status (May 2026 sweep):**
  - `font-weight` — **100 % tokenized** across `styles.css` + `swap.css` + `page.css` + `doc-shared.css`. 264 literals migrated. The ladder gained three new tiers (`--fw-light` 300, `--fw-extrabold` 800, `--fw-black` 900) so every weight in use today has a named token.
  - `font-family` — **100 % tokenized**. 8 `'Figtree', sans-serif` literals migrated to `var(--font-sans)` (richer fallback chain bundled). Remaining mentions of "Figtree" in CSS files are inside `/* ... */` comments — documentation prose, not runtime style.
  - `letter-spacing` — **100 % tokenized** across `styles.css` + `swap.css` + `page.css` + `doc-shared.css` (June 2026 sweep, designer-in-the-loop). The exact-match pass (5 instances: `-0.04em`, `-0.03em`, `0.06em`) landed first; the off-ladder pass then snapped every remaining literal to its nearest tier per an explicit per-value decision — near-zero nudges (`±0.005em` / `±0.01em`) → `--tracking-normal` / `--tracking-snug`, eyebrow tracking (`0.04` / `0.05em`) → `--tracking-loose`, wide eyebrows (`0.08em`) → `--tracking-wide`. No tracking literals remain.
  - `font-size` — **100 % tokenized** across the same four files (June 2026 sweep). The exact-match pass snapped value-identical literals; the off-ladder pass then snapped the rest by per-value decision — dense mono chips (`11px`) → `--text-caption`, label sizes (`15px` / `0.95rem`) → `--text-body`, and the `.uc-title` marketing-hero family (`52` / `38` / `34` / `26px`) → the nearest display/heading tier (`--text-display` / `--text-h2-lg` / `--text-h3`). The marketing heroes were snapped onto the product ladder rather than kept as a separate scale — verified in-browser (no crowding/overflow). No font-size literals remain.
  - `line-height` — **audited clean (June 2026); no migration needed.** A sweep of all four files found **zero bare `px` / `rem` line-height literals** (the only form that bypasses the system). The remaining usage is `var(--lh-*)` tokens (168×) plus unitless ratios (94×) — and the unitless ratios are the *legitimate exception* the table above blesses, not drift: `1` (40×, single-line / icon contexts where no `--lh-*` pair applies), `0.95`–`0.96` (mega / display canvas-fill), and `1.06`–`1.75` (the 1.1–1.6 band + relaxed-body edges). Forcing these onto `--lh-*` tokens would be wrong. The one open extension is teaching `check-css-tokens.py` to flag future `px` / `rem` line-height literals (it would report 0 today) — a follow-up, not pending migration.
- **The auditor that enforces this.** `scripts/check-css-tokens.py` (nightly **B4** · `/checkup`) derives the ladders from `styles.css :root` and flags any bare `font-size` / `letter-spacing`; `--fix` snaps EXACT (value-identical) matches, and OFF-LADDER values are reported with the nearest tier for a designer call. As of the June 2026 sweep it reports `__COUNT__=0` for both properties.
- **Going forward:** the rule is contract, not aspirational. When you author or touch a typography rule for any reason, leave the rule fully tokenized as part of your edit. Don't bundle drive-by tokenization sweeps into unrelated changes — do it as a dedicated commit when the scope justifies it. As of June 2026 all four system-managed type properties (`font-size`, `font-weight`, `font-family`, `letter-spacing`) are 100 % tokenized and `line-height` is audited clean — the rule is now purely maintenance: keep new rules tokenized as you author them.

### Panel primitive — the core reusable frame, three-slot model, density via tokens

`.panel` (`styles.css`) is **the canonical floating-frame primitive every panel-shaped surface composes** — the playground rail, the swap widget shell (`.panel.ui-card`), the receive aside, the modal (`.modal.panel`), and the Portal's 44 panels (`.panel.portal-panel`). It owns **surface chrome only**: opaque `--surface-raised` + a tokenized cast shadow (`--panel-shadow`, default `--elev-3`) + a reserved border slot (`--panel-border`, default transparent) + `--panel-radius` (16) + token-driven padding/gap + flex-column. Width / position / z-index / max-height belong to the consumer. Every new panel-shaped component (drawer, sheet, command bar, popover-tier surface) composes `.panel` rather than hand-rolling surface chrome — a hand-rolled `background` / `box-shadow` / `border-radius` / `padding` on a panel-shaped surface is drift.

**Density via the `--panel-*` tokens, not bare overrides.** `--panel-padding` (24) and `--panel-gap` (12) are the density knobs. Tune them on the consumer so `.panel`'s `padding` / `gap` read them on the same element: `.ui-card { --panel-gap: var(--space-16); }`, `.portal-panel { --panel-padding: var(--space-20); }`. A bare `gap:` / `padding:` override bypasses the token system — reach for the token. (June 2026: `--panel-gap` was promoted from a hardcoded literal in `.panel`; default reproduces the old value exactly.)

**Three-slot BEM model (June 2026) — additive, optional, composes WITH the content primitives:**

| Slot | Pairs with | Owns |
|---|---|---|
| `.panel__header` | `.screen-header` (two classes, one `<header>`) | Panel-edge behavior: the header→body spacing handoff, `--ruled` (bottom hairline), `--sticky` (sticky-over-scroll, bleeds to panel edges). `.screen-header` keeps owning the title/back/trailing grid. |
| `.panel__body` | — | The clip-and-host scroll region (`flex: 1 1 auto; min-height: 0; overflow-y: auto`) — scroll the body, not the frame. Engages only at bounded height. **Clips horizontally** — don't host edge-bleed hover rows directly (give them their own scroll host, e.g. `.ui-token-picker__body`). |
| `.panel__footer` | `.action-bar` (inside it) | The pin (`margin-top: auto`) + top hairline + top padding. Absorbs the *positioning* role of `.action-bar--footer`. |

`.screen-header` is NOT retired into `.panel__header` — it's used outside panels (modals, app-rail screens). The header element wears both classes. Companion additions: **`.screen-header__subtitle`** + `.screen-header__titles` wrapper (the long-referenced-but-unimplemented subtitle slot; where Portal's `.portal-panel__sub` lands), and **`.link-arrow`** (universal tier-2 accent **inline** link for prose / list rows / meta notes — "Resume →", "Contact support →"; absorbs Portal's `.portal-panel__link`).

**`.screen-header__avatar` — large identity-avatar lead (June 2026).** A leading slot for a large brand / identity avatar to the LEFT of the title — the profile / organization / settings / identity header, an alternative lead to `__back`. The slot is any `.avatar` (a logo `<img>` or an `.avatar--initial` monogram); sizes large via `--avatar-size` (default `var(--space-64)` / 64). `:has(.screen-header__avatar):not(:has(.screen-header__back))` sets the grid to `auto 1fr auto` and **left-aligns** the title (profile style, not the modal-centred default) — the `:not(:has(__back))` guard keeps it at (0,3,0), above the trailing-column rules, so an avatar + trailing header still reserves the lead column. Canonical consumer: the Portal **top-level page heads** — `.portal-page-head` (a distinct big-`h1` primitive, deliberately NOT converted to `.screen-header`) applies the same identity-lead idea via its own `.portal-page-head__avatar` slot, fed the active org's mark by `portal-render.js → orgMarkHTML` + synced on org switch. Catalog: `#screen-header-avatar`; spec: `design/components/surfaces.md → .screen-header`.

**Panel-header trailing action = neutral button, NOT a link (June 2026 DS standard).** The action in a panel header's `.screen-header__trailing` slot ("View all", "Open explorer") is a **`.btn-neutral .btn-sm`** button — so the affordance reads as a defined, tappable control rather than an inline link, and pairs with the header height. Use **`<button type="button">`** for in-page actions (SPA view switches — the `data-view-link` handler already calls `e.preventDefault()`, harmless on a button); **`<a class="btn-neutral btn-sm">`** only when it genuinely navigates to a URL. **Don't reach for `.link-arrow` in a panel header** — that primitive is now for *inline / standalone* accent links only (prose, list rows, meta notes). The Portal is the canonical consumer: Dashboard ("View all" → Analytics, "Open explorer" → Transactions) + Analytics ("View volume", "View revenue"). Catalog: `#panel` Anatomy + Rules panes; the `.link-arrow` CSS comment in `styles.css` carries the breadcrumb.

**Surface variants — elevated (default) vs flat (June 2026).** The elevation axis answers ONE question: *"is this surface floating above the page?"* Chrome + overlays float — the app rail, dropdowns, modals, the FAB — and keep the elevated look. CONTENT panels that sit in a scrollable canvas are peers on the page; at scale (the Portal's ~44 panels) a cast shadow on each reads as noise that double-encodes the panel↔canvas tint delta. The axis flips **three tokens as one coherent identity** — `--panel-surface` (fill) + `--panel-shadow` + `--panel-border`:

- **`.panel--elevated`** (default): raised-FRAME fill (`--surface-raised`) + `--elev-3` + transparent border. A floating frame.
- **`.panel--flat`**: CONTENT-tier fill (`--surface-card`) + no shadow + `--border-subtle` hairline. A content surface sitting on the canvas.

The fill flips too (not just shadow/border) because a flat panel *is* a content surface, not a raised frame — so it wears the content tier. That also makes it **white-capable in light mode**: `--surface-card` pivots to the page seed, so the composer's surface seed can drive panels to white, where the frame tier (`--surface-raised`, derived *below* page) never could. (Model: Material 3's filled+outlined card; the Linear / Stripe / Vercel dense-console look.)

**Portal canvas unifies panels + KPI cards.** `portal.css → .portal-kpi-grid .stat-card` reads the same three `--panel-*` tokens instead of its own Paper material (whose `--mat-bg` is a hardcoded neutral white that never tracked the org-tinted `--surface-card`). Result: on the Portal canvas, panels and KPI tiles are ONE pixel-identical content surface in every state, flipping together with the composer. Scoped to the KPI grid so stat cards nested *inside* panels keep their Paper lift.

Three things make the axis work:

- **The defaults live on `:root`, NOT on `.panel`.** `--panel-surface` / `--panel-shadow` / `--panel-border` are defined in `:root` and only CONSUMED by `.panel` (`background: var(--panel-surface)`; `box-shadow: var(--panel-shadow)`; `border: 1px solid var(--panel-border)`). If a default lived on `.panel` itself, each panel would set its own value and BLOCK an ancestor scope from cascading in — the same lesson as the `--card-gap` / `--grid-gap-*` ladders. The reserved `1px solid transparent` border slot costs ZERO layout (box-sizing: border-box is global) so flat paints a hairline with no consumer shift.
- **Scope-wide flip via a canvas wrapper — EVERY palette canvas must re-declare `--panel-surface`, even the elevated-default one.** Set the three `--panel-*` tokens on a content-canvas wrapper and every descendant `.panel` follows while the rail + overlays (OUTSIDE the canvas) stay elevated. Two canonical consumers: `portal.css → .portal-canvas` (static *flat* default for the Portal's ~44 panels + KPI cards) and `swap.css → .playground-canvas` (`--panel-surface: var(--surface-raised)`, the *elevated* default). The rail (`.app-rail` / `.playground-rail`, outside the canvas) keeps `:root` elevated, so the chrome-floats / content-sits split is automatic from DOM nesting. **The non-obvious part — you CANNOT rely on overriding only `--surface-raised` on the canvas.** `:root` declares `--panel-surface: var(--surface-raised)`; that `var()` substitution resolves ONCE, at the `:root` declaration, against `:root`'s `--surface-raised`. A scoped Theme Composer pick paints `--surface-raised` onto the canvas, but the canvas-inherited `--panel-surface` was already resolved at `:root` — so `.panel` backgrounds keep the sitewide theme unless the canvas ALSO carries its own `--panel-surface` declaration (which re-resolves `var(--surface-raised)` against the *canvas's* themed value). This is why `clearPanelsFromScope`'s `removeProperty('--panel-surface')` is safe ONLY when the canvas has a stylesheet rule to fall back to — both canvases now do. Any future palette canvas needs the same re-declaration. (June 2026 — the playground canvas was missing this and its panels didn't re-theme; see `bug-history.md → "Scoped theme doesn't re-colour .panel surfaces"`.)
- **Per-theme via the Theme Composer.** `theme-editor.js → features.panels` paints the three tokens to the editor's `scope` (the same channel as Corners / Spacing / Surface) — an Elevated / Flat `.option-card--tile` pair inside its own "Panels" section (June 2026 redesign; split out of the merged "Panels & spacing" section in the same month's setting-row consolidation — the closed section head now summarizes "Elevated" / "Flat", and the active choice's description renders as one `.muted` caption under the pair). Enabled on the **Portal Brand scope only** (seeds `'flat'` to match the canvas; the **Chrome scope omits it** so the rail stays elevated) and the **Playground rail** (per-theme persisted; `'elevated'` default matches that canvas). Helpers: `theme-composer-core.js → ThemeComposer.readPanels` / `ThemeComposer.applyPanelsToScope` / `ThemeComposer.clearPanelsFromScope` (both states painted explicitly so a flat-default canvas can flip back to elevated; inline wins over the stylesheet rule, clear reverts to it). The editor NEVER auto-paints on mount (only card-clicks paint) so opening it can't force a default onto the canvas — same "don't force defaults" discipline as gaps. Export (`buildCssExport`) emits `--panel-surface`/`--panel-shadow`/`--panel-border` only when flat (elevated is the `:root` default). The FAB composer ALSO surfaces Panels, but **gated on a canvas being present** (`features.panels: !!canvas` in `shared.js → enterEditView`) — so it appears on the playground / Portal (where it scopes safely to `[data-palette-canvas]`) and stays OFF on chrome-only pages (marketing / catalog / launchpad), where painting `--panel-*` to `:root` would flatten the navbar / dropdowns / all chrome. The FAB seeds the active card via a honesty pass (`shared.js → readScopePanels` reads the canvas's computed `--panel-shadow`: `none` → flat) so the Portal's flat-default canvas shows the correct card; like the rail it never auto-paints on mount (only card-clicks paint). See [[Theme Composer scope]].

**Deliberate non-changes — don't "fix" these:**

- **`.panel`'s shadow is tokenized (`--panel-shadow`) but still NOT the `--mat-*` registry.** It was intentionally removed from the material registry (Apr 2026) — glass blur reads wrong in dense form/settings content (same call as `.dropdown__panel` → paper). The elevated/flat axis flips a plain shadow + border, NOT a material; the default still resolves to `--surface-raised` + `--elev-3`. Glass siblings (`.dropdown__panel`, `.ds-doc-menu-panel`, `.cmd-palette__panel`) stay glass; choose by intent — *does the surface need to show the page behind it?* Yes → glass-by-class. No → `.panel`.
- **`.action-bar--footer` coexists with `.panel__footer`** — it's used by non-panel modals; retiring it into `.panel__footer` is a separate later sweep, not bundled here.

**Source of truth.** This section is canonical. CSS: `styles.css → .panel` + the surface-variant block (`.panel--flat` / `.panel--elevated`) + the slot block beneath it (search `Panel slots`); tokens in `:root` (search `--panel-gap` for density, `--panel-shadow` for the elevation axis); the Portal flat default in `portal.css → .portal-canvas`. Composer: `theme-editor.js → features.panels` + `theme-composer-core.js → ThemeComposer.applyPanelsToScope`; mounts in `portal.js → openEditor` (brand scope) + `swap.js → mountRailEditor`. Spec: `design/components/surfaces.md → Panel`. Catalog: `design-system/index.html#panel`. When the slot model or token set evolves, update this section in the same commit.

### `.screen-header` — the universal surface-chrome header (one component, composed everywhere)

`.screen-header` is **the single chrome line at the top of any bounded surface** — swap-widget screens + pickers, modal title bars, the Theme Composer, FAB sub-panels, app-rail drill screens, and all ~44 Portal panel heads wear this *same* primitive. It's **tier-2 universal** (no `.ui-` prefix; promoted off `.ui-screen-header` in May 2026 because modals span marketing + product). The `:has()`-adaptive grid morphs to which slots are present (`__back`/`__avatar` · `__title`(+`__titles`/`__subtitle`) · `__trailing`); every slot is optional. **Don't reinvent a per-surface header — compose this one** (the June 2026 standardization existed precisely to stop that drift).

**The composition contract — keep these uniform across every consumer:**

- **Name stays `.screen-header`.** Don't rename to `panel-header` — it's a misnomer (the component lives on non-panel surfaces: modals, rail screens) AND collides with the existing `.panel__header` BEM slot (a *different* concern). The two are orthogonal and composed together: `class="panel__header screen-header"` — `.panel__header` owns panel-EDGE behavior (sizing, `--ruled` hairline, `--sticky` bleed); `.screen-header` owns the title/back/trailing grid.
- **Title is uniformly `<h2>`** when it's text — at EVERY tier (incl. `--compact`). The header sits inside a surface within a page that already owns its `<h1>`, so the title is a section heading, never the page's sole h1. Size is class-driven by `.screen-header__title` (not the tag), so the heading level stays consistent. *(The big page-level title at the top of a Portal view is the separate `.portal-page-head` primitive — that one IS an `<h1>`. Don't compose `.screen-header` for a page title.)*
- **Back slot is tier-locked:** default tier → `screen-header__back btn-icon btn-borderless btn-circle` (40 px); `--compact` tier → the same + `btn-sm` (32 px). Never mix (a 40 px back in a 32 px compact row, or vice versa).
- **Trailing text action = `.btn-neutral .btn-sm`** (a defined, tappable control, not an inline link — `.link-arrow` is for inline/standalone links only). Icon-only trailing actions use `.btn-icon .btn-borderless`.
- **`--compact` is for narrow glass popovers ONLY** (the FAB glass sub-panels — Globe / Logo Stack / Reactive Coins). **Substantial panels stay default tier** — the Theme Composer (480 px) + the rail Edit-theme screen use the default 40 px chrome (June 2026: the composer was the lone `--compact` outlier; moved to default tier across all its mounts — FAB / Portal modal / rail). The back-button size tracks the tier in the shared FAB chrome (`shared.js → createFabSubPanel`: `isComposer ? default : btn-sm`). `--quiet` (uppercase tag-style title) currently has no live consumer — a documented-but-unused variant.
- **`--sticky-glass`** is a scroll behavior (orthogonal to size) — pins + frosts the head over a scrolling panel body; pair with `--compact` on the glass FAB sub-panels.

**The Theme Composer mounts in 5 places** — FAB floating (`shared.js`), `enterEditView` host (`shared.js`), the catalog live demo (`design-system/ds-theme-composer.js`), the Portal Settings modal (`portal.js`), and the playground rail (`playground.html`). A change to "the composer" must sweep all five.

**Source of truth.** Spec: `design/components/surfaces.md → Screen header` (the full layout matrix, size-tier table, variants, "Composition contract") + the migration history. CSS: `styles.css → .screen-header` (search `.screen-header — universal`) + the Panel slots block. Catalog: `design-system/index.html#screen-header` (8 cards: anatomy · main · sub · plain · variants · composition · avatar · panel composition). When the contract evolves, update this section + surfaces.md + the catalog in the same commit.

### `.modal` — step-transition fluid resize (the default for content-swapping modals)

Every `.modal` that **swaps its content** — a wizard whose body changes per step (the integration wizard, the wallet-connect wizard), or any modal mutated via `flow.innerHTML = …` — **animates its height between the natural sizes of consecutive states instead of snapping**. This is **baked into the `.modal` primitive**, not per-flow: author a normal modal whose content changes and the fluid resize is automatic. Static modals (confirm dialogs whose content never changes) are a silent no-op. This is the canonical transition between related modal steps.

**Mechanism — a shared FLIP.** `shared.js → initModalAutoResize()` (`window.LifiModalResize`) puts a `ResizeObserver` on each `.modal`, caches the last settled natural height, and on a real change: pins the OLD height (`transition: none`) + forces a reflow, clears the inline transition so the `.modal[data-modal-resizing] { transition: height … }` rule animates to the NEW height, then reverts to `auto` on `transitionend`. `overflow: hidden` is applied for the duration of the tween (+ the body's scroll frozen) so taller incoming content reveals as the panel grows rather than spilling past the rounded edge. Auto-wired to every `.modal` on load; `window.LifiModalResize.observe(el)` opts in a dynamically-mounted modal.

**Contract:**
- **Timing lives on the component** — `--modal-resize-duration` (`0.28s`) + `--modal-resize-ease` (`cubic-bezier(0.4, 0, 0.2, 1)`) are tunable slots on `.modal`; override per-modal via inline `style`, not in JS (the helper reads the duration to time its release). `0.28s` is one notch slower than the `0.25s` entry slide-up so the resize reads as deliberate.
- **Height axis ONLY** — width is owned by the `--w-*` size modifier and stays fixed across a flow; changing `.modal--sm` ↔ `.modal--lg` mid-stream is not a supported transition.
- **Reduced motion snaps** — under `prefers-reduced-motion: reduce` the tween is skipped (honored in the JS AND a defensive CSS guard).
- **The first measurement after open is baseline-only** so the entry slide-up (transform/opacity, plays once) and the height tween (plays per step) never fight; a closed (`display:none`) modal reports ~0 height and resets the baseline so re-opening starts clean.
- **Don't pair `.modal-close-float` with a content-swapping flow** — the resize clip would clip the floating close (it sits outside the panel box). Stepper flows dismiss via a header/footer button; the float is for static headerless modals (which never resize).
- **The verification trap** — measuring height during the `0.25s` entry slide-up gives skewed numbers (the keyframe's `scale(0.97→1)` transform is baked into `getBoundingClientRect`). Let the open animation settle (~450 ms) before measuring step heights, or you'll chase a phantom few-px "drift" that's just the scale.

**Source of truth.** This section is canonical. CSS: `styles.css → § 9. Modal — .modal` (the `--modal-resize-*` slots + the `.modal[data-modal-resizing]` rule). JS: `shared.js → initModalAutoResize`. Spec: `design/components/feedback.md → Modal → Step transitions — fluid resize`. Catalog: `design-system/index.html#modal-stepped` (live click-through demo). When the contract evolves, update this section + feedback.md + the catalog in the same commit.

### Panel Surfaces — one recipe, three tokens, every glass panel uses it

Every floating glass panel and menu uses the same three-token recipe — `background: var(--surface-panel-glass)` + `box-shadow: var(--shadow-panel-glass)` + `backdrop-filter: var(--backdrop-panel-glass)` — so they all read as the same surface in both themes. The shape (radius, padding, width, z-index) belongs to the component; the surface character is fixed by the tokens. Light/dark shadow auto-flips via `[data-theme="light"]` on `--shadow-panel-glass`; never author per-component theme overrides, never fork the recipe by hand-writing `background: color-mix(--glass-base …)` + per-component shadow + per-component blur. The shadow recipe itself carries the accent halo (10% in dark, 7% in light) and a 1px accent ring (light only) — those are part of the canonical glass look, NOT a per-component opt-in.

**Glass is for navigation chrome, not anchored menus.** Glass works when the underlying content is visually load-bearing — the navbar reveals the page behind it, the command palette overlays a working canvas, the FAB menu floats over a busy page. It does NOT work for panels anchored to a specific trigger inside a form row, settings list, or dense settings UI — there, the blur reads as "weird background blur" against an already-tinted surface (user feedback, May 2026, on the limit-expiry dropdown). Anchored menus use the Paper material instead (solid `var(--surface-card)` + `var(--elev-1)`, no backdrop blur). The split: glass surfaces float over arbitrary content; paper surfaces anchor to a specific element.

**Source of truth.** Spec lives in `design.md §02 → Panel Surfaces`; live reference at `#material-glass` in `design-system/index.html` (the recipe is the Glass material's underlying definition since May 2026). Canonical glass implementations: `.navbar::before`, `.cmd-palette__panel`, `.ds-doc-menu-panel`. The Materials application matrix at `#materials` tracks which other panels are aligned, pending review (`.search`), or deliberately excluded (`.modal` — opaque surface, different recipe; `.dropdown__panel` — Paper material since May 2026, see migration note below).

**Migration note (May 2026).** `.dropdown__panel` moved from glass → paper. The glass blur read as visual noise inside form rows where the dropdown sits next to other tinted controls. Every `.dropdown` consumer (7 catalog cards, 1 playground instance at the time) auto-picked up the new look via the global `:where(...) { --mat-*: ... }` registry in `styles.css`. The two registries sit at the same selector level — find the glass one with `Glass-by-class` and the paper one with `Paper-by-class`. **Don't put `.dropdown__panel` back in the glass registry** — re-architecture decision, not a temporary tweak.

### Card & tile surfaces — always reference a `--surface-*` token, never bake a value

Every card, tile, stat-card, panel — anything with a non-glass surface fill — sets `background:` from a `--surface-*` token (`--surface-sunk`, `--surface-raised`, `--surface-card`, `--surface-panel-glass`, plus future gradient / accent-tinted surface tokens). **Never inline an `oklch(…)`, an `rgba(…)`, or a one-off `color-mix(…)` for a card background.** The `--surface-*` family is the single source of truth for "what does a surface look like" — auto-flips light/dark, evolves once across the system when the visual language changes, and prevents the per-component drift that makes 20 cards look like 20 different products.

**Why this matters.** Without it, every contributor invents their own card surface (`background: color-mix(white 6%, transparent)` here, `background: oklch(15% 0.02 268)` there) and the system fragments. The underlying lesson: the elevation hierarchy belongs in the token layer, not in component-level overrides. Current dark surface ladder: 8 → 16 → 28 (`--surface-sunk` / `--surface-raised` / `--surface-card`); light values pivot `--surface-card` to `--surface-page` so cards lift via shadow alone. Bug history (April 2026 `.stat-card` zero-contrast bug + May 2026 surface-ladder rebalance + June 2026 v3 card-floor bump 24 → 28 for lifted-page themes): `.claude/skills/lifi-ds-docs/references/bug-history.md → Card & tile surfaces`.

**Authoring rules.**

- **Pick the surface token by elevation intent.** Cards / tiles / paper-material consumers → `--surface-card` (theme-aware: 28% dark, `var(--surface-page)` light). Tile-tier surfaces (footer, flow nodes, theme tiles, raised page-level regions, `.panel` shells) → `--surface-raised` (16% dark, 97% light). Recessed wells / demo containers → `--surface-sunk` (5% dark, 91% light). Floating glass panels → the Glass material (or the Panel Surfaces three-token recipe at the token layer).
- **Per-theme overrides happen on the COMPONENT, not the token.** Tokens hold values; components reference tokens. `--surface-card` already pivots to `var(--surface-page)` in light mode (cards lift via shadow alone — Material Design approach). If a different component needs theme-specific behaviour, write the per-theme rule on the component (`[data-theme="light"] .my-tile { background: var(--surface-raised); }`) — but the alternate value must STILL be a token, never a literal.
- **Future gradient / accent-tinted surface types extend the token family.** When you need a brand-tinted card (e.g., the gradient hero KPI), the right move is to add a new `--surface-grad-brand` token (or similar) and have components consume it — not to inline the gradient on every card variant. Ask before extending the token set; the goal is consistency, not proliferation.
- **The validator should flag literal background values on card primitives** (planned addition — see `validate-doc.py`). Today this rule is documentation-enforced; the next validator pass will catch drift mechanically.

**Source of truth.** Surface token definitions live in `styles.css → :root` (dark defaults around line 358) and `[data-theme="light"]` (light overrides around line 878). Authoring guidance + the full taxonomy live in `.claude/skills/lifi-ds-docs/SKILL.md → Card surface tokens`.

### Canonical UI card recipe — one surface, one elevation, one active pattern

Every UI card in the system defaults to the same recipe so cards read as one family across product surfaces. Established May 2026 in the ui-card unification pass; the reference implementation is `.ui-amount-card` (live at `#ui-amount-card` in the catalog).

| Property | Value |
|---|---|
| **Background (rest)** | `var(--surface-card)` — auto-flips: 24 % dark / page-pivot (100 % white) in light |
| **Border (rest)** | `1px solid transparent` — reserves space for the active accent border; renders invisibly at rest |
| **Shadow (rest)** | `var(--elev-1)` — resting card tier |
| **Border-radius** | per-card geometry (`.ui-amount-card` 16 px · `.theme-card` 16 px · `.uc-card` 20 px · `.card` 12 px) — the surface is unified, the shape stays card-specific |
| **Hover** | `background: color-mix(in oklch, var(--accent-primary) 3%, var(--surface-card))` + `box-shadow: var(--elev-2)` |
| **Active / `.is-active`** | **Rim** `border-color: color-mix(in oklch, var(--accent-primary) 64%, transparent)` (softened May 2026 — was 100 %) · **Fill** `color-mix(... 4%, var(--surface-card))` uniform across themes (was 5 %) · `box-shadow: var(--elev-2)` — **1 px accent rim inside the box, NO halo glow, no layout shift** |

**Softening rationale.** Full-saturation rim + 5 % fill made the selected card dominate its siblings in lists of 5+ cards (e.g. `.ui-quote-list`). Rim at 64 %-transparent-mix keeps the hue family (still unmistakably brand) while dropping the punch — below ~50 % it starts reading as a neutral hairline and kills the "selected" signal; 64 % was the sweet spot for "calmer but still clearly accented." Fill uniform 4 % across themes — an earlier mode-split (2.5 % light / 4 % dark) was tried to equalise perceived lift against light's near-white `--surface-card`, but reverted because the light value went too quiet against the panel. Variant-aware consumers (`.list--cards` via `--list-card-tone`, future `.foo-card` via its own `--foo-tone`) route the same mix recipes through their tone token — the percentages stay constant; only the colour reference flips.

**Why a real border (inside the box) and not box-shadow / outline.** The first May 2026 pass used `box-shadow: 0 0 0 1px var(--accent-primary)` for the active ring — clean and no layout shift, BUT the outset ring lives outside the border-box and gets clipped by any ancestor with `overflow: hidden` (notably the playground rail, which uses `overflow-x: hidden` for the slide animation). A real `border: 1px solid transparent` at rest reserves the slot inside the border-box, costs 1 px of internal padding per side (acceptable trade), and survives any overflow context. Outline with negative offset works in modern browsers but renders inconsistently around `border-radius` in older Safari builds. Real border is the most reliable.

**Why no halo glow.** A blurred accent halo (`0 0 18px 0 color-mix(--accent-primary 18%)`) reads as ambient lighting and competes with the actual shadow + surface step. A crisp 1 px ring reads as "this one is selected" without that competition. Pre-May 2026, `.theme-card` had a halo; removed in the unification.

**Authoring rules.**

- **Reach for the recipe first.** Most cards consume Paper material automatically (`.card` / `.stat-card` / `.action-card` / `.notification` / `.content-tile` / `.search`) — for those, the recipe is already wired via `--mat-bg` / `--mat-shadow`. New card primitives should either compose Paper material or set the surface tokens directly to match.
- **Per-card geometry is fine.** Different cards have different sizes (sub-cards 16 px radius, marketing cards 20 px). Unify the SURFACE; keep the SHAPE.
- **Don't fork the active state.** If your card needs selection, use the 1 px ring recipe. Don't invent a halo, a 2 px outer ring, a translucent overlay, or a per-card accent border at rest — those are the variants the May 2026 unification retired.
- **Container variants (form-card, etc.) keep the same base.** Accent-tinted containers (`.form-card`, `.card--accent`) layer their tint on `--surface-card`, never on `--surface-raised` (which is the panel tier).
- **Card vs. row distinction — accent-tinted hover is for CARDS only.** This bullet originally claimed dense row lists (`.setting-row`, drill menus, preferences lists, inbox rows, search-result rows) should use the same accent-tinted hover as cards. **That direction was retired** in the May 2026 row-recipe pass — row-shaped primitives consuming `.list-item--interactive` now use a NEUTRAL grey hover (see the "List-row state recipe" section immediately below). The card recipe (3% accent on hover, 4% on `.is-active`, rim + shadow) stays the canonical contract for **card primitives** (`.ui-amount-card`, `.theme-card`, `.ui-quote-card`, `.accent-card`, `.tile`, etc.). The DISTINCTION is what the primitive IS, not what it's stacked next to: a CARD is a self-contained surface with elevation; a ROW is one entry in a stacked list. Cards get accent hover; rows get neutral. The original bullet's setting-row reversion was a SYMPTOM of this conflation — the right resolution was a row-specific recipe, not forcing rows into the card recipe.
- **Navigation-only primitives drop the border slot — but `border: 0` is explicit, not omitted.** Cards with a real selection state (`.ui-amount-card`, `.theme-card`, `.ui-quote-card`) reserve the `1 px solid transparent` rest border to host the active accent ring on `.is-active` without layout shift. Navigation-only primitives — drill rows that open a sub-screen rather than "selecting" a value — have no `.is-active` consumer; the reserved slot is dead weight and reads as a perimeter line on hover against near-white panel surfaces. Drop it explicitly: `border: 0`. **Do NOT just omit the declaration** — `<button>` carries a 2 px outset user-agent border that will leak through and look much worse than the transparent slot did. `.setting-row--drill` is the canonical case (rail panels, modal preferences). When you author a new navigation-only card primitive, decide up front whether it has selection semantics; if not, `border: 0` is the contract.

- **`.accent-card` is a separate family — marketing-CTA emphasis, not canonical selection.** Each variant (`.accent-card--primary` / `--secondary` / `--tertiary`) ships with its own 3-layer halo glow at REST as its visual identity — the halo signals "this is an emphasized tier" rather than "this is selected." The card has no `.is-active` consumer (the click navigates), no reserved transparent border slot (rest border is `--border-subtle` or 30 %-transparent accent), and a `:hover` rim at full tone saturation — louder than the canonical-recipe family by design. The May 2026 softening pass aligned `.accent-card:active` press-state values to the canonical numbers (4 % fill, 64 %-transparent rim) so the system feel stays cohesive across families, but kept the variant resting halos untouched. Don't reach for `.accent-card` for list selection; don't reach for `.theme-card`/`.ui-quote-card` recipes for marketing CTAs.

**Source of truth.** Spec lives in `design/components/surfaces.md → Cards` (and the per-card sections in the catalog). The reference implementation is `.ui-amount-card` (`#ui-amount-card`). The Rules pane there carries the canonical bullet; this file is the durable contract.

### Widget card-title labels — one canonical style via `.ui-card-label` / `--ui-card-label-*`

Every card-title label in the swap widget — the small label naming what a card IS ("Send", "Receive", "Receiving wallet", "When ETH is worth" / "Limit price", the review-summary flow label) — reads at ONE treatment, single-sourced from the `--ui-card-label-*` token set:

| Property | Token | Value |
| --- | --- | --- |
| size | `--ui-card-label-size` | `--text-sm` (14 px) |
| weight | `--ui-card-label-weight` | `--fw-semibold` (600) |
| color | `--ui-card-label-color` | `--text-primary` |
| tracking | `--ui-card-label-tracking` | `--tracking-normal` |
| line-height | `--ui-card-label-lh` | `calc(20 / 14)` — 20 px line-box |

**Two ways to apply it; both consume the same tokens (so there's genuinely one source):**

- **New cards** — add `.ui-card-label` to the label span (zero-CSS default). `.ui-destination-wallet-card__label` is the exemplar (`<span class="…__label ui-card-label">`).
- **Slot-pattern cards** — point the card's own label slots at the shared tokens. `.ui-amount-card` (`--amount-card-label-*`) and `.ui-limit-price-card` (`--limit-price-card-lead-*`) do this — their slots default to `var(--ui-card-label-*)`, so per-instance override still works. (`.ui-review-summary__label` was retired June 2026 with the review-screen mock alignment; the execution flow's Received card applies bare `.ui-card-label`.)

**Why a single source.** The treatment used to be redeclared per card and kept in lockstep BY HAND (the limit card's comment literally said "when a property changes on `.ui-amount-card`, update this in the same edit, and vice versa"). It drifted — the destination card shipped at `--text-secondary` (a lighter grey) + `--tracking-snug`, which a designer caught. Now changing `--ui-card-label-color` once updates every card; card-to-card drift is structurally impossible.

**NOT this style — the smaller field-label role.** `.ui-amount-field__label` and `.ui-token-select__label` are 12 px / `--fw-medium` / `--text-secondary` — a deliberately quieter role (a field caption, not a card title). Don't fold them into `--ui-card-label-*`.

**Where enforced.** Tokens + class: `swap.css` → search `WIDGET CARD-TITLE LABEL`. Consumers: the four card-title labels above. Spec: `design/components/swap.md → Card-title label`. When you add a widget card with a title label, apply `.ui-card-label` (or point a slot at the tokens) — never redeclare size / weight / color / tracking / line-height inline.

### List-row state recipe — variant of the card recipe, fill-only

Row-shaped interactive primitives consume a row-specific variant of the canonical card recipe. Three states span two hue families, all translucent over `transparent` for universal compositing:

| State | Background | Hue family | Intent |
| --- | --- | --- | --- |
| `:hover` | `color-mix(in oklch, var(--text-primary) 4%, transparent)` | Neutral grey | Passive affordance |
| `.is-selected` / `.list-item--selected` | `color-mix(in oklch, var(--accent-primary) 6%, transparent)` | Accent | Committed pick |
| `:active` (mouse-down) | `color-mix(in oklch, var(--accent-primary) 10%, transparent)` | Accent | Press feedback |

**Why neutral on hover.** Cards use accent on hover because hovering a card IS the commit signal — they're freestanding primitives the user has already chosen to interact with. Rows are scanned in lists: the cursor often passes over rows the user isn't intending to commit to. Accent on hover would falsely telegraph "selected" during scanning. Neutral hover reads as "this is hoverable" without claiming commitment. Selected + active step up into accent because by that point the user HAS committed (or is committing).

**Why fill only, no rim or shadow.** Canonical card `.is-active` includes a 1 px accent rim + `var(--elev-2)` shadow. Rows deliberately drop the rim — perimeter lines inside a stacked list read as visual noise across many siblings. Same with elevation: lift doesn't make sense for a row inside a scrolling list.

**Specificity gap on selected:active — explicit override required.** Because `.is-selected` (0,2,0) wins over `:active` (0,1,1) on specificity, pressing a row that's already selected would normally stay at the 6% selected tint with no press feedback. To restore the press signal, every consumer adds an explicit higher-specificity rule:

```css
.list-item--interactive.is-selected:active,
.list-item--interactive.list-item--selected:active {
  background: color-mix(in oklch, var(--accent-primary) 10%, transparent);
}
```

Aligned consumers carry parallel overrides: `.ui-token-list .list-item--interactive.is-selected:active::before` (token picker pseudo) and `.data-table tbody tr.is-selected:active td` (table rows). When adding a new row primitive to this family, author the explicit `.is-selected:active` rule alongside the base hover/selected/active values — otherwise re-pressing an already-selected row reads as dead.

**Translucent over transparent — universal compositing.** Mixing with `transparent` (NOT `--surface-card` or `--surface-raised`) lets the recipe composite over any underlying surface. A `.list-item--interactive` on a panel (`--surface-raised`), a card (`--surface-card`), or directly on the page (`--surface-page`) all paint the same way — the underlying surface shows through at `1 - alpha`. This is the distinction from the card recipe, which uses solid mixes specifically because cards always sit on a known surface.

**Side-bleed is a separate pattern.** The token picker extends the hover/selected/active surface past the row's L/R edges via a `::before` pseudo (`swap.css → .ui-token-list .list-item--interactive::before`) with `inset: 0 calc(-1 * var(--list-hover-bleed))`. The COLORS match the recipe above exactly — only the painted surface area extends. Most consumers (chain list, settings rows, menu items) get the colors without the bleed.

**Where this is enforced.**
- `styles.css → .list-item--interactive` block (search "CANONICAL LIST-ROW STATE RECIPE") — canonical source.
- `design-system/index.html → #list-item` Rules pane — rendering surface.
- This section — durable cross-session contract.

**Aligned consumers** (May 2026):
- `.list-item--interactive` — base rule (styles.css). Token picker, chain list, settings rows, drill rows, menu items consume.
- `.ui-token-list .list-item--interactive::before` — pseudo extension with side-bleed (swap.css).
- `.data-table tbody tr` — table rows. Routes through slot tokens (`--data-table-row-hover-bg`, `--data-table-row-active-bg`, `--data-table-row-selected-bg`) so per-domain consumers can retint via the `--data-table-selected-tone` slot — same shape as the card-recipe `--*-tone` pattern. The slot architecture also means table-specific concepts (striped, expanded) stay isolated from the row-recipe values.

**Future candidates.** Inbox rows, search-result rows, future drill menus, preferences lists. Apply the same color values; if the primitive needs the bleed too, use the `::before` pattern from `.ui-token-list` and ensure the panel hosting it has `padding-inline` reduced by `--list-hover-bleed`. If you author a fourth+ primitive that consumes the same color recipe AND can't use color-mix inline, promote the values into shared tokens (`--list-row-hover-bg`, etc.) at that point.

**Migration history (May 2026).** Replaced the prior `--overlay-hover` (5% `--text-primary` overlay) / `--overlay-press` / `--accent-primary 12%` selected mix. Old values were calibrated for buttons + cards; the row-specific recipe was iterated on the token picker over several rounds (4% → 6% → 8% → 6% selected; 3% → 6% → 4% hover with hue swap from accent to neutral; active locked at 10% throughout), then promoted to the base `.list-item--interactive` rule. The earlier CLAUDE.md bullet "Dense interactive row lists are accent-tinted like cards" was retired atomically (see bullet above) — that rule was the symptom of conflating cards (self-contained surface) with rows (one entry in a stack), and the right resolution was a separate recipe per primitive shape.

### Card root element — `<button>` vs. `<div role="button">` vs. `<article>`

When authoring a new card primitive that's clickable (single-selection, navigate-on-click, etc.), the choice of root element matters. Two recurring traps:

**1. Button-in-button is a parser footgun. Reach for `<div role="button" tabindex="0">` when the card needs to host a real `<button>` child** (chevron toggle, dismiss button, inline secondary action). The HTML5 parser auto-closes the outer `<button>` the moment it sees the inner `<button>` opening tag — your nicely-structured markup gets re-arranged at parse time, with the inner button ending up as a SIBLING of the outer card rather than a child. CSS targeting `.foo-card > .foo-card__toggle` silently fails because there's no parent-child relationship anymore. Caught by inspecting the DOM and finding the toggle parented to the rail container instead of the card.

Pattern:

```html
<!-- ✗ Parser auto-closes the outer button when it sees the inner one -->
<button class="foo-card">
  <span class="foo-card__body">…</span>
  <button class="foo-card__toggle">↓</button>  <!-- ends up as a sibling -->
</button>

<!-- ✓ Card root is a div with role=button; child can be a real button -->
<div class="foo-card" role="button" tabindex="0">
  <span class="foo-card__body">…</span>
  <button class="foo-card__toggle">↓</button>
</div>
```

Reference implementations of this pattern: `.ui-quote-card` (uses `<article>` + `<button>` child for the expand toggle), `.ds-doc-variant-card` (uses `<div role="button">` + `<button>` child for the chevron toggle, May 2026). For keyboard accessibility on the `<div role="button">` root, add Enter/Space handling in JS via event delegation — the variant-card pattern is `initVariantCardToggles()` in `shared.js`. Where the card has no interactive children, prefer a real `<button>` so keyboard support is free; reach for `<div role="button">` only when you genuinely need the nested-button affordance.

**2. `<button>` resets `font-family` to the user-agent default. Always declare `font-family: var(--font-sans)` on a button-rooted card.** Buttons don't inherit `font-family` from the page — they reset to Arial (or whatever the browser's UA default is) and don't transmit through to descendant text. The only button-rooted card that genuinely carries this declaration is `.ds-doc-variant-card`. (`.persona-connect-card` was a carrier until June 2026, when it was retired into the `.list-item` connect / identity row composition — its picker rows are now `<div role="button">`, not button-rooted.) **Do NOT trust an "already carries this" list at face value — it decays.** A June 2026 audit found three cards once listed here as button-rooted-carriers are neither: `.theme-card` (root `<div>`, shared.js/portal.js renderers), `.ui-quote-card` (root `<article>`, ui-quote-list.js:677), and `.ui-amount-card` (root `<div>`, playground.html) — all inherit Figtree from `body` normally, none is `<button>`-rooted, none declares `font-family`, and all three verified clean in-browser (computed `Figtree`). The same audit caught `.persona-connect-card` (then `.wallet-connect-card`) FALSELY listed as a carrier when its `<button>` picker root was actually rendering Arial — fixed by adding the declaration. `.ds-doc-variant-card` is the original case: it missed the declaration, and the bug only surfaced when the marker primitive rendered in Figtree directly next to an Arial label, making the drift visible side-by-side. The rule applies ONLY to cards whose **root is a `<button>`** — `<div>`/`<article>` roots inherit Figtree and need nothing. Add the declaration the first time you author a button-**rooted** card; and when relying on this list, re-verify the root element + the computed `font-family` in-browser rather than trusting the source grep or the list itself.

**3. Dual-host LIST / MENU primitives need the FULL UA-chrome reset, not just font-family.** Points 1–2 cover button-*rooted* cards. A related trap hits primitives that render on EITHER `<a>` or `<button>` (menus, list rows, action sheets) — `.menu-item` is the canonical case. Authored and tested only on `<a>` (which carries no UA chrome), it silently leaked the `<button>` UA chrome — a 2 px outset border, buttonface background, `appearance: auto`, the Arial font, AND content-width sizing — the moment a `<button>`-hosted consumer (the orders kebab) was added; the theme-actions kebab had been broken the same way for as long as it existed. There is no global `button { … }` reset, so the fix lives on the base rule: `appearance: none; -webkit-appearance: none; border: 0; background: transparent; font: inherit; width: 100%; text-align: left; cursor: pointer;` — a no-op for `<a>`, repairs every `<button>` consumer at once. `border: 0` must be explicit (the outset border leaks if merely omitted — same trap as `.setting-row--drill`). **Diagnose by comparing computed styles of BOTH hosts side by side, not by eye** — the "heavy outline" symptom hides a four-property root cause. Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → \`.menu-item\` on a \`<button>\` host leaks UA chrome`.

**2nd instance (June 2026) — `.side-nav__item`.** Portal's left rail uses `<button class="side-nav__item">` rows for single-page view-switching (not `<a>`), and the base `.side-nav__item` rule had been authored for `<a>` hosts — so the `<button>` UA chrome leaked as **white pills in dark mode** (invisible in light, where buttonface ≈ the near-white rail). Same root cause as `.menu-item`; same fix — the dual-host reset now lives on the base `.side-nav__item` rule (`font-family: inherit`, NOT the `font` shorthand, so the rule's own `font-size`/`font-weight` stand). The recurrence is the signal: **a primitive that can render on either host should carry the reset from day one**, not wait for a `<button>` consumer to expose the leak.

**`width: 100%` is part of the dual-host reset — not just chrome (June 2026).** Beyond the UA *chrome* leak above, a block-level flex `<button>` and `<a>` SIZE differently: a block-level flex `<a>` stretches to fill its block parent, but a block-level flex `<button>` keeps its **intrinsic content width**. So a row primitive authored/tested on `<a>` fills its container, but the same primitive on a `<button>` host renders **ragged content-width** — and (the visible tell) each row's hover / active fill is a *different width* hugging its label instead of spanning the column. `.side-nav__item` hit exactly this in the Portal rail (button-hosted view-switchers): rows were content-width until `width: 100%` was added to the base rule. The canonical `.menu-item` reset already bundles `width: 100%` for this reason; treat width as a mandatory line of the dual-host reset, alongside `appearance` / `border` / `background` / `font-family`. Same diagnostic: compare the two hosts' computed widths, don't eyeball.

**Where this is enforced.** `styles.css` — search any of the button-rooted card rules for `font-family: var(--font-sans)` and the comment block explaining why; the `.menu-item` AND `.side-nav__item` base rules carry the full dual-host reset block + comment. The Card root element rule lives here in CLAUDE.md as the durable contract; the per-component rules carry the inline comment for context.

### Card gap ladder — one swappable family for every card-to-card gap

Lists and grids of card-shaped siblings set their `gap:` from the **card-gap ladder** family (`--gap-card-xs / sm / md / lg / xl`) — never raw `--space-*`, even when the value happens to match. Token names describe POSITION on the ladder, NOT the use case — and that decoupling is what makes the entire mapping swappable as a unit, exactly like the Theme Composer swaps brand colours.

**Default mapping** (defined in `styles.css → :root`):

| Token | Default | Tier | Typical use (guidance, not contract) |
|---|---|---|---|
| `--gap-card-xs` | 4 px | Extra-small | Ultra-tight; reserved for joint-surface compositions (no current consumer) |
| `--gap-card-sm` | 8 px | Small | Compact list — `.ui-amount-pair` (swap pair with FAB at seam), `.playground-rail__theme-cards`, `.ui-form-stack` (nested card group inside the swap form) |
| `--gap-card-md` | 12 px | Medium **(default)** | Canonical card list — `.ui-quote-list` |
| `--gap-card-lg` | 16 px | Large | Comfortable grid — `.theme-card-grid` |
| `--gap-card-xl` | 24 px | Extra-large | Spacious grid — `.tile-grid`, `.enterprise-grid`, `.uc-grid` |
| `--gap-card` | → `--gap-card-md` | Alias | Brevity for inline overrides only |

**Consumption pattern — local CSS var with default.** Every card-list / card-grid component publishes a local `--card-gap` that defaults to a tier token, then reads `gap: var(--card-gap)`:

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

This shape gives a component-level default AND a per-instance escape hatch — consumers override `--card-gap` inline or via context selector without touching component CSS:

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

**Swappable mapping — the why behind pure size names.** A future "card gap composer" installs Compact or Spacious presets by overriding the 5 tokens at `:root`. Every consumer flips automatically via cascade. No component re-authoring needed:

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

This is exactly the model the Theme Composer uses — write the overrides on `:root[data-…-preset]`, let the cascade do the work.

**Authoring rules.**

- **Reach for the named tier, never the raw `--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. Bypassing the tier to the raw value defeats the swappability — a future preset swap won't reach values authored as `--space-12`.
- **The `md` tier is the default.** When in doubt about which tier a new card list should use, start at `--gap-card-md`. The other tiers exist for specific contexts (a tight FAB-seam pair, a compact rail list, a marketing hero grid). Don't reach for `lg` or `xl` just because it "looks roomier" without a contextual reason.
- **Components publish a local `--card-gap`.** Pattern: `.foo-list { --card-gap: var(--gap-card-sm); gap: var(--card-gap); }`. The local var is the per-instance override hook — consumers set `--card-gap` inline or via a context selector without re-authoring the component.
- **Custom values via `--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 remap the ladder via a preset.
- **Adjacent families don't substitute.** `--stack-*` (vertical content rhythm — sections, form fields), `--cluster-*` (inline-flex gaps — icons + labels, chip groups), `--gap-card-*` (card-to-card spacing). One family per concern; don't cross the streams.
- **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 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 — don't promote it to a generic primitive; the reusable thing is the **wrap-and-publish-`--card-gap`** technique, not the wrapper name.

**Source of truth.** Token definitions live in `styles.css → :root` (search `--gap-card-xs`); the system is documented in the catalog at `#card-gap` with live tier previews, and in `design.md §04 → Semantic gap families` for the prose spec. The reference consumers are `.ui-amount-pair` (sm), `.playground-rail__theme-cards` (sm), `.ui-form-stack` (sm, nested-rhythm wrapper inside the swap form), `.ui-quote-list` (md), `.theme-card-grid` (lg), and `.tile-grid` (xl). Bug history this ladder addresses (pre-May 2026 drift across 6 selectors with 4 ad-hoc patterns): `.claude/skills/lifi-ds-docs/references/bug-history.md → Card gap ladder`.

### Theme Composer scope — themes the content canvas, not the chrome (June 2026)

The shared theme editor (`theme-editor.js → ThemeComposer`) mounts on three surfaces — the **FAB Theme Composer**, the **Playground rail**, and the **Portal Settings → Theme** panel. Each paints to a `scope`, and the cardinal rule is: **the composer themes the page's CONTENT CANVAS, never the chrome (rail / app-shell).**

**The marker.** A page that has a chrome/canvas split marks its canvas with **`data-palette-canvas`** (on `.portal-canvas` + `.playground-canvas`). `shared.js → paletteCanvas()` resolves it. The FAB composer scopes its WHOLE editor (palette + corners + surface, plus the canvas-gated gaps + panels axes) AND its select path (`applyPresetById`) to that element. Pages with no split (marketing, catalog, launchpad) have no marker → the FAB falls back to the sitewide `:root` paint (there `:root` *is* the content surface, so it's correct). This unifies all three mounts: **FAB, Portal "Brand" scope, and the Playground rail all theme the canvas BY DEFAULT; the chrome is themed separately** — Portal Settings → "Chrome" scope, and (on the rail + FAB lists) the **apply-scope seg** (June 2026 — the unified apply, which absorbed the rail's short-lived "Chrome" dropdown row): a 3-state `.seg` above the theme cards — **Widget/Canvas · Chrome · Everywhere** — that routes what a card pick paints AND, since **July 2026 (reach-on-switch)**, re-applies the currently-previewed theme at the new scope's reach the moment you switch — so the control does what its labels say instead of silently arming the next pick. **The three labels are DISJOINT targets, not a cumulative ladder** (a ladder would make "Chrome" identical to "Everywhere"): each scope paints its target region with the previewed theme and the **untargeted region with the site default**. The untargeted region is PAINTED with the default rather than cleared, because the canvas inherits `:root` — merely clearing it under Chrome scope would let the chrome's theme cascade straight back in and render Chrome identically to Everywhere. A switch BEFORE the first pick is a no-op (nothing has been previewed yet, so the never-auto-apply discipline holds). **CARD-PICK semantics are deliberately unchanged** — `applyPresetChrome` still preserves the widget's scoped preview ("independent scopes are the point"); only an explicit scope SWITCH normalizes reach, which is the one capability this trades away (you can no longer hold two non-default themes at once via the seg; default-chrome + themed-widget still works). The seg is EPHEMERAL (in-memory, resets to the canvas default on every load — so the louder scopes are always a deliberate same-session act, the never-auto-apply discipline) and the FAB only SHOWS it on pages with a canvas marker (elsewhere every pick is already sitewide). In Chrome scope the rail card's edit pencil opens the ephemeral chrome-scoped editor modal (`swap.js → openChromeEditor`, the Portal `openEditor` pattern; edits live-paint `:root`, nothing persists). With no pick, the chrome renders the **site default brand** (`DEFAULT_BRAND_ID` — LI.FI 1.0, restored June 12 2026 after the one-day v1.2 flip to 2.0).

**The chrome pick path — `ThemeComposer.applyPresetChrome(id)` (`shared.js`).** A sitewide apply that does NOT supersede the canvas: it mutates the closure (so the mode-toggle observer re-asserts the chrome pick on every flip — the leak channel used as the correct persistence path) + paints `:root` with the full axis set, but skips `clearScopedPalette()` and dispatches `lifi:preset-applied` with `{ chrome: true }` so the rail keeps the widget's scoped preview. `setActivePresetId` still runs (`active-theme` IS the sitewide/chrome active; the observer's surface/charts re-asserts key off it). Ephemeral across reloads like every pick.

**The everywhere pick path — `ThemeComposer.applyPresetEverywhere(id)` (`shared.js`).** ONE pick paints BOTH halves: the chrome via the same closure-mutating `paintChromeFromPreset` the chrome path uses (so the mode-toggle observer keeps re-asserting it), AND the canvas via `paintCanvasFromPreset` — the full per-canvas axis set (palette + radii + gaps + surface + panels + charts + font), which a `:root` paint can't reach (gaps/panels are canvas axes). On a page with no canvas it degenerates to the sitewide pick (incl. `clearScopedPalette`). Dispatches `lifi:preset-applied` with `{ chrome: true, everywhere: true, scoped: !!canvas }`. **Known latent bug (low-impact, June 2026): on the no-canvas branch this path paints-THEN-clears, so a brand FONT gets swept** — `paintChromeFromPreset` registers `--font-sans`/`font-family` in the scoped-token registry, then `clearScopedPalette` sweeps them off `:root`. `applyPresetById` is correct (clear-then-paint). Only reachable via API on chrome-only pages (the "Everywhere" seg isn't shown without a canvas); fix-when-touched = reorder to clear-first. See `bug-history.md → "applyPresetEverywhere sweeps the brand font on a CHROME-ONLY page"`. The two paint halves are the SHARED helpers `paintChromeFromPreset` / `paintCanvasFromPreset` (extracted June 2026) — `applyPresetById`, `applyPresetChrome`, and `applyPresetEverywhere` all compose them, so the three pick paths cannot drift.

**The load-bearing rules (why the chrome stays put / the canvas survives):**

1. **A canvas-scoped pick/edit must NOT mutate the sitewide closure `palette` / `window.__themeComposerPalette__`.** That closure is the CHROME (`:root`) palette. `initBrandPalette`'s **mode-toggle `MutationObserver`** re-applies it to `:root` on every light/dark flip — so if a canvas pick mutated it, the canvas theme would leak onto the chrome the next time the mode toggled (this was the actual root cause of the "FAB overrides the whole page" report — *not* the pick itself, which only painted the canvas, but the deferred observer re-paint). `applyPresetById`'s canvas branch builds a LOCAL palette; the `enterEditView` `onChange` guards the closure write with `if (!canvas)`. The chrome closure stays the default through picks, edits, AND mode toggles.
2. **The Playground rail's `lifi:preset-applied` supersede handler is `detail.scoped`- AND `detail.chrome`- AND `detail.everywhere`-guarded.** A canvas-scoped pick fires `{ scoped: true }` → re-sync the highlight but don't clear the canvas's radii/gaps/panels (just painted by the pick). A chrome pick fires `{ chrome: true }` → keep BOTH the canvas overrides AND the in-memory `railActiveId` (the widget preview must survive a chrome re-theme), and sync the rail's `railChromeActiveId` chrome tracker. An everywhere pick fires `{ everywhere: true }` (checked FIRST) → sync BOTH trackers (`railActiveId` = the id so the rail's mode-flip observer keeps re-asserting the canvas's per-mode scoped literals; `railChromeActiveId` for the Chrome-scope highlight), clear nothing. The clear path stays only for a genuine sitewide pick (no canvas). The rail's list highlight is SCOPE-AWARE: Widget shows `railActiveId` (falling back to `active-theme`), Chrome shows `railChromeActiveId` (seeded `DEFAULT_BRAND_ID` — the honesty reset guarantees `:root` IS the default until a pick), Everywhere shows an id only when both halves agree — null (no highlight) is the honest "no single theme is everywhere".

**Coexistence on the Portal.** The Portal canvas is org-themed by `portal-render.js` (`applyOrg`) + has its own mode observer. A FAB pick overrides the canvas until the next org switch / mode toggle re-asserts the org theme — an accepted transient override (the canvas is org-managed; the FAB is a quick preview).

**Apply a theme = the ENTIRE theme (June 2026).** `ThemeComposer.applyPresetScoped(id, scope)` is the canonical *complete-theme* applier — it paints palette + semantics + text + overrides + charts + font + mono **AND radii + gaps + surface-contrast + panels** (apply-or-clear), so NO caller re-implements axes. (Before: radii/gaps/surface/panels were each caller's responsibility; the Design Workshop scenario cards call `applyPresetScoped` alone and got Jumper colours on LI.FI 1.0 geometry — pill corners + 32px panels missing. Fixed by moving all four axes INTO `applyPresetScoped`, mirroring how it already owned charts/font/mono — additive + idempotent, so existing complete callers (rail/Portal/org, which re-apply afterward) don't regress. `applyPresetById` / `applyPresetEverywhere` were already complete via `paintCanvasFromPreset`.) The general principle the user stated: **"apply a theme" means colours + corners + typography + spacing + surface + panels — never just the palette.**

**The active-card highlight is SCOPE-AWARE via a `data-active-theme` DOM tag (June 2026).** Every canvas paint (`applyPresetScoped` + `paintCanvasFromPreset`) tags the scope element `data-active-theme="<id>"` — the DOM source of truth for "which theme is painted here". The FAB composer's `renderCardsList` reads it for the **Canvas** scope (vs. the sitewide `active-theme` for **Chrome**; both-must-agree for **Everywhere**), so the highlight reflects the canvas's REAL theme on open — not the sitewide default. `clearScopedPalette` drops the tag on a sitewide supersede; the FAB re-renders on the scope-seg switch + on `lifi:preset-applied[-scoped]`. (Fixed: a scenario card applying Jumper to the canvas left the composer still highlighting LI.FI 1.0 — the highlight lied about what was rendered.)

**Demonstrating theming? Use DARK mode — light mode collapses the brands together (July 2026).** Any screenshot, exhibit, demo, or QA pass meant to SHOW a theme change must run in dark mode. In light mode every brand's page surface converges toward white — LI.FI `oklch(99% 0.007 277)` vs Jumper `oklch(98% 0.003 262)`, ~1pp apart — so a correct theme swap can be genuinely imperceptible; dark spreads the same three brands `L18.5% → L14% → L9%` (hue 276 → 173). Compounding it, **the chrome carries almost no accent-bearing pixels at rest** (the rail's chromatic elements are overwhelmingly the theme cards' own FIXED `.swatch--strip` previews, which don't track the active theme), so a chrome re-theme moves tokens while painting little. Don't diagnose a theming control as broken from a light-mode screenshot. Full diagnostic: [`bug-history.md → "A control that paints nothing isn't necessarily broken"`](.claude/skills/lifi-ds-docs/references/bug-history.md).

**Where enforced.** `shared.js → paletteCanvas` / `applyPresetById` (canvas vs sitewide branch) / `ThemeComposer.applyPresetScoped` (owns ALL axes + sets `data-active-theme`) / `ThemeComposer.applyPresetChrome` (the chrome pick path) / `enterEditView` (`scope: canvas ? [canvas] : null` + the `!canvas` onChange guard; `renderCardsList` scope-aware `activeId`) / the mode-toggle observer in `initBrandPalette`. Rail: `swap.js` `lifi:preset-applied` handler (scoped + chrome + everywhere guards + the `railReapplying` mid-switch bail) + the apply-scope seg wiring in `initRailThemeCards` (`railScope` / `railChromeActiveId` / `railLastPickedId`) + `applyScopeReach` (the reach-on-switch normalizer) + `paintWidgetScope` (the ONE canvas painter shared by the pick + switch paths, so they cannot drift) + `openChromeEditor` (the ephemeral chrome editor modal — the surviving piece of the retired `initRailChromeTheme`). FAB seg: `shared.js` (`fabScope` + `#pcScopeSeg`, shown only when a canvas is marked). Markers: `portal.html` + `playground.html` (`data-palette-canvas`). This section is the canonical contract; the Radius / Surface / Spacing / Panels sections below inherit this scope model (their "FAB → canvas when present, else :root" behavior all flows through the same editor mount).

### Theme tokens track the scope — `deriveTokens` is the COMPLETE source (June 2026)

The scope model above only works if **every brand-dependent token is written to the scope**. A `:root` token defined via `var()` / `oklch(from …)` / `color-mix(… var(--brand…) …)` indirection resolves **once, at the `:root` declaration** — so overriding the seed (`--lifi-sapphire` etc.) on a scoped `[data-palette-canvas]` does NOT re-resolve it for descendants. The token must be written to the scope as a **resolved literal**, which is exactly what `theme-composer-core.js → deriveTokens` does (it's the single place derived tokens become per-scope literals). **The rule: any new `:root` token that depends on a brand seed/derived token MUST be added to `deriveTokens`' output — or it silently fails to re-tint on a scoped canvas.** This is the generalization of the `--panel-surface` / `--material-paper-bg` / semantic-tone bugs (each was a brand-dependent token missing from `deriveTokens`).

**What `deriveTokens` emits** (the scope-tracked set): the seeds (`--lifi-*`), accents (`--accent-*`), surfaces (`--surface-*`, `--material-paper-bg`), text/overlay/border/gradient tokens, AND (June 2026) the **semantic tones** (`--success/danger/warn/info` + `-bg`/`-border`/`-fg`/`on-*`), `--on-accent-*`, `--field-ring`/`--field-border-focus`, `--accent-tint`, `--lifi-sapphire-accessible`. Semantic tones are emitted as **resolved literals** — both so they re-tint per canvas AND to sidestep the v2 relative-color browser caching bug (a literal has no cached relative-color source) — computed from the seed + the per-MODE coefficient tables `SEM_DEFAULTS` (which MIRROR the styles.css `--sem-<role>-{L-offset,C-mult,H}` defaults at `:root` + `[data-theme="light"]` — keep the pair in lockstep when retuning), merged with the theme's optional `semantics` override (June 2026 — the Semantic Composer merge; see "Semantic states control" below). The literals go to TRUE canvases only: when the paint target is `:root`/`documentElement` (no-scope OR the Portal Chrome scope), applyPalette skips the literals (SEMANTIC_SCOPED_ONLY) and writes/clears the `--sem-*` COEFFICIENT vars instead, so the stylesheet formula system stays live. Panel/surface-contrast tokens are written by the sibling `applyPanelsToScope` / `applySurfaceToScope`.

**Deliberate non-tracked (chrome + residuals):** glass/material-gradient (`--glass-base`, `--material-glass-bg`, `--material-gradient-bg`, `--shadow-panel-glass`, `--material-halo-bg`) float ABOVE canvases as chrome → sitewide only (the auxiliary marketing gradients — `--grad-brand-45` / `--grad-subtle-bg` and their four radial siblings `--grad-hero-radial` etc. — were all retired June 2026, zero consumers); `--ink-*` (skeleton greys, `#ccc` fallback); `--lifi-*-light` (no consumers); `--brand-anchor` (alias whose downstream tones are emitted directly). These are allowlisted in the guard — if one ever gains a canvas consumer, move it into `deriveTokens`.

**The global brand flip (LI.FI 1.0 → 2.0 — a config change, NOT a refactor).** The default brand is single-sourced: `shared.js → DEFAULT_PALETTE` **derives from the `DEFAULT_BRAND_ID` preset** (`presets.js`), so the JS default can't drift from the canonical seeds (it had — the stale retired-blue `L68` dark primary; see bug-history). To flip the global default when 2.0 ships:

1. Change `DEFAULT_BRAND_ID` in `shared.js` (`'lifi-1'` → `'lifi-classic'`).
2. Update the `styles.css` `:root` + `[data-theme="light"]` first-paint seed literals (`--lifi-sapphire/pink/ink/dark` + surfaces) to match that preset — CSS can't read JS, so these are a hand-maintained **mirror**.

That's it — every vertical (website, playground, portal) derives from `DEFAULT_PALETTE` + the same `:root`. The LI.FI-1.0-only-default rule still holds (nothing auto-applies from localStorage; this only changes WHICH brand the cold-load default IS).

**Where enforced + guarded.** `theme-composer-core.js → deriveTokens` (the emit set) · `shared.js → DEFAULT_BRAND_ID` + `DEFAULT_PALETTE` (single-source) · **`scripts/check-theme-tokens.py`** (nightly **B9** · `/checkup`) verifies TC1 (every brand-dependent `:root` token is emitted or allowlisted) + TC2 (the `:root` seed literals match the default preset — catches CSS↔JS first-paint drift). This section is the canonical contract.

### Radius system — three independent tokens, one customization channel

Every card / panel / text-button surface in the codebase consumes one of three radius tokens — `--card-radius`, `--panel-radius`, `--button-radius` — instead of hand-picking a `--space-*` value. The three are sibling systems, each independent, each customisable live from the **Corners** section of the shared theme editor (`theme-editor.js → ThemeComposer`, `features.corners`). Corner radius is part of a theme's identity alongside its palette, and is editable from BOTH surfaces that mount the editor: the **FAB Theme Composer** (paints the page's **content canvas** when one is marked — `[data-palette-canvas]` — else `:root` sitewide on pages with no chrome/canvas split; see [[Theme Composer scope]] above) and the **playground rail** theme-edit screen (paints `.playground-canvas` — the widget zone only). June 2026 — corners were folded into the shared editor (the "Theme Composer" merge); previously they were rail-only (`swap.js → initRailCorners`). (June 2026 follow-on: the FAB editor's `scope` flipped from `null` (sitewide) to the canvas when present, so FAB corner edits no longer touch the chrome — they scope with the palette.)

**The three tokens.**

| Token | Default | Consumed by |
|---|---|---|
| `--card-radius` | `var(--space-16)` (16 px) | Every card-shaped primitive — `.card / .card--sm / .card--lg`, `.ui-amount-card` (via `--amount-card-radius` slot), `.ui-quote-card`, `.ui-token-select`, `.ui-amount-field`, `.ui-limit-price-card` (via `--limit-price-card-radius` slot), `.theme-card`, `.tile / .feature-card / .enterprise-card / .tile--sm / .tile--lg`, `.accent-card`, `.brand-card` (inherits via `.tile`), `.stat-card`, `.action-card` (via `--ac-radius` slot), `.alert / .alert--sm / .alert--lg`, `.notification`, `.setting-row / --sm / --lg`, `.menu`, `.dropdown__panel`, `.ds-doc-menu-panel`, `.ds-doc-variant-card`. |
| `--panel-radius` | `var(--space-16)` (16 px) | `.panel` (the container tier — form panel, receive panel, playground rail). One consumer by design — panels are the "frames" that hold cards. |
| `--button-radius` | `var(--space-16)` (16 px) | The text-button shared rule (`.btn-primary / .btn-secondary / .btn-tertiary / .btn-link / .btn-destructive / .btn-success / .btn-neutral`), all size modifiers (`.btn-sm / -lg / -xl`), `.btn-white`, and every `.btn-icon` tier (`xs / sm / default / lg`) so square chrome buttons round in lockstep with text buttons. Also the **segmented control** — the `.seg` wrapper + `.seg-item` at every tier (sm / default / lg / xl) derive from `--button-radius` via the same `calc()` ladder, so a `.seg` pairs concentrically with a `.btn` at the same tier and rounds in true lockstep with the slider (segs and buttons are commonly stacked; the matched corner is what reads as paired). The `.seg-menu` split picker (the label-and-chevron mode picker that sits in the track in place of an item; sm / default / lg / xl) and `.search-trigger` (+ `--sm` / `--lg`) also derive from `--button-radius` via the same calc ladder (June 2026 — both were rebound from literal `--space-*` radii that *matched* the ladder by value but silently ignored the slider: `.seg-menu` was the seg-item migration's missed sibling, and `.search-trigger`'s own comment already claimed its radius was "locked to the .btn ladder"). |

**Intentional exemptions.** Don't migrate these to `var(--<*>-radius)`:

- **`.btn-circle`** (full radius pill) — explicit shape primitive (resolves to `--r-full`).
- **Avatars at 50%, pills at `--r-full`, hairlines at 1-2 px** — these aren't cards; they're shape-defined.
- **Asymmetric radii** like `.playground-rail` (`16 0 16 16` — the drawer-handle mating geometry) — only the three rounded corners consume `--panel-radius`; the flat right edge stays intentional.

**The customization channel — corners follow the surface's paint scope (June 2026).** Corner radius is a property of each theme, edited alongside its palette in the shared editor's **Corners** section (the Edit-view redesign, June 2026 — every section is a collapsible `.setting-row--expand` card whose closed head reads a live summary in its `__value` slot: the active trio name or "Custom"): a 2×2 `.option-card--tile` grid (Soft / Standard / Sharp / Custom) + a state-reactive `.muted` caption naming the active choice + a slide-down `.reveal` drawer (toggled by the Custom tile) holding three discrete-ladder `.slider-field--compact` sliders (Panel / Card / Button radius — each with an editable numeric readout that snaps typed px to the nearest rung) + a Pill toggle. A change updates the editor's `radii` state and repaints the matching CSS variable to the editor's **`scope`** — the SAME channel the palette paint uses:

- **FAB Theme Composer** (`scope: canvas ? [canvas] : null`) → paints `--card/panel/button-radius` to the page's **content canvas** when marked (`[data-palette-canvas]`), else to `:root` sitewide (marketing / catalog / launchpad — no chrome to protect). So on the Portal / Playground, FAB corner edits stay on the canvas and never touch the rail chrome; on a plain marketing page they're sitewide. (June 2026 — the scope flipped from always-`null`; see [[Theme Composer scope]].)
- **Playground rail** (`scope: [.playground-canvas]`) → paints to the canvas only (widget + backdrop). The rail, drawer-handle, and FAB sit OUTSIDE the canvas and inherit from `:root`, so a rail corner edit leaves the playground chrome on the sitewide theme. This is what preserves dual-scope: LI.FI 1.0 chrome + e.g. Jumper 1.0 (pill) corners on the canvas, independently.

Either way, the editor's `onChange` persists `radii: { card, panel, button, pill }` to the theme record in `theme-presets` (the same record the palette editor writes to). Selecting a theme also applies its radii to the surface's scope (sitewide via `applyPresetById`; canvas via the rail's select handler), defaulting to card 16 / panel 16 / button 12 / no-pill when absent. Nothing auto-applies on cold load — `:root` stays 16/16/12 until a user pick/edit, matching the LI.FI-1.0-default brand rule (no preview survives a reload; the editor only paints on mount, which is a user-initiated edit). Radius is mode-independent — a single `radii` object covers both modes, so the Corners section never re-renders on a mode-toggle. A sitewide pick (`lifi:preset-applied`) clears the rail's scoped radii so the widget follows the sitewide theme. JS: the Corners controls + paint live in `theme-editor.js` (`features.corners`, `renderCornersBody`, `paintRadii`); the shared `window.ThemeComposer.readRadii` / `ThemeComposer.applyRadiiToScope` / `ThemeComposer.clearRadiiFromScope` helpers live in `theme-composer-core.js`.

**The Button slider reports the RENDERED radius, not the `--button-radius` anchor (June 2026).** The Panel and Card sliders are honest by construction — `--panel-radius` / `--card-radius` are consumed *directly* (`border-radius: var(--card-radius)`), so the slider's number equals the rendered corner. The Button slider is not: `--button-radius` is the lg-tier ANCHOR, and every default-tier button renders at `calc(--button-radius − 4)` (the ladder above). So the Button slider's VALUE is defined as the **rendered default-tier radius** (what the user sees on the Connect CTA / seg control), and `--button-radius` is written as **`value + 4`** via `theme-composer-core.js → buttonRadiusToAnchor(px)` (`px > 0 ? px + 4 : 0` — 0 stays 0 so "Sharp" squares every tier, segs included). This keeps the displayed ladder identical to Card/Panel (0/8/12/16/20/24/32) and keeps the default at 12 — the product's real default-tier radius — so the playground faithfully previews production while `--button-radius` still resolves to 16. The stored `radii.button` is the *rendered* radius the user picks; the `+4` conversion lives at exactly three write sites — `applyButtonRadius` + `applyRadiiToScope` (swap.js) and `buildCssExport` (theme-composer-core.js, so exports stay theme-complete). **Don't "simplify" the `+4` offset away** — it's what keeps the readout matched to the rendered button; dropping it reintroduces the slider-says-16-but-button-is-12 detachment. Diagnostic archaeology: `bug-history.md → Customization slider reports the token ANCHOR`.

**Authoring rules.**

- **Reach for the token by name, not the underlying `--space-*` value.** If a new card-shaped primitive needs 16 px radius, write `border-radius: var(--card-radius)`. Writing `var(--space-16)` directly defeats the customization channel — the playground drill's scope override won't reach you.
- **Pick the right token for the primitive's role.** Card-shaped surface? `--card-radius`. Container frame (think "the thing that holds the cards")? `--panel-radius`. Text button? `--button-radius`. When in doubt, lean card (the broadest consumer).
- **Local slot pattern is encouraged for primitives that already have a token layer.** `.ui-amount-card { --amount-card-radius: var(--card-radius); }` — the local slot stays, defaults to the system token, allows per-instance inline overrides via `style="--amount-card-radius: 0"`. Don't bypass the local slot when one exists.
- **Don't introduce a fourth token without surfacing it first.** The three tokens cover ~90 % of radius needs. A fourth (e.g., `--input-radius`) is doable (add the axis to the editor's `CORNERS_PRESETS` / per-axis sliders in `theme-editor.js → renderCornersBody` + extend the `radii` shape + a CSS sweep), but propose before authoring — the system's value is the small, predictable count.
- **The paint target IS the editor's `scope` — don't hardcode it.** The rail paints `.playground-canvas` (widget-only); the FAB composer paints `:root` sitewide. Both flow through the editor's `scope` param + `window.ThemeComposer.applyRadiiToScope`. Don't write radius vars to a fixed element from new code — route through the editor (or the shared helper with the right scope) so corners honor the surface's blast radius.
- **Per-theme persistence, not global.** Corner edits belong to the theme being edited (`theme-presets[].radii`) — keyed off `editingPresetId` (FAB) / `railEditingThemeId` (rail). Don't reintroduce global `lifi-playground-*-radius` localStorage keys — those were retired when corners moved per-theme (atomic, no aliases).
- **Reset and Export are corner-aware (unified action set — June 2026).** **Reset to original** restores the editing theme's `radii` from its bundled seed (clears to the `:root` 16/16/12 default when the seed has none) alongside palette + meta — `#pcEditReset` (FAB) / `data-theme-action="reset"` (rail kebab), shown for bundled themes only. **Export CSS** (`#pcEditExport` / `data-theme-action="export"`) emits the theme's `--card-radius` / `--panel-radius` / `--button-radius` into the exported `:root` block so the export is theme-complete; radii are mode-independent, so they appear only in `:root`, never the `[data-theme="light"]` block (`--button-radius` resolves to `999px` when the theme's pill flag is on). Every Export path now passes `radii` through `window.ThemeComposer.openExport(palette, radii)` (shared.js `openExportModal` → `theme-composer-core.js → buildCssExport(palette, radii)`) — the standalone list-view Export was retired in the merge.

**Migration history (May 2026).** Sweep covered ~20 primitives across `styles.css` + `swap.css`. Pre-sweep state: every card hand-picked from `--space-12 / 16 / 20` according to its tier. Post-sweep: every card consumes `var(--card-radius)` (uniform 16 by default); per-card size differentiation now comes from padding/icon/typography, not from radius. **Button radius defaults shifted, then re-stepped** — the card sweep briefly made all button tiers uniform at 16 (`.btn-sm` had been 12, `.btn-lg` 20, `.btn-xl` 24). That uniform era was reversed later in May 2026: the text-button radius ladder is now **8 / 12 / 16 / 20** (sm / default / lg / xl), each tier derived via `calc()` off `--button-radius` (the lg-tier anchor, still 16) so the playground Button-radius slider scales the whole ladder while preserving the +4 steps — sm = token−8, md/default = token−4, lg = the bare token, xl = token+4. The segmented control (`.seg-*` wrapper + item radii) shifted in lockstep so same-tier siblings stay concentric, and the shared Radius row in the Sizing Ladders rule above is now 8 / 12 / 16 / 20. **(June 2026 follow-on:** the seg's radii were first authored as the literal `--space-*` values that *matched* the ladder — value-paired but hardwired, so they didn't track the playground Button-radius slider. They now consume `--button-radius` via the same `calc()` expressions the buttons use — wrapper = anchor −8 / −4 / 0 / +4; item 4px tighter = anchor −12 / −8 / −4 / 0 — so the slider rounds segs and buttons in true lockstep. Default values are unchanged: 8 / 12 / 16 / 20 wrapper, 4 / 8 / 12 / 16 item. This was the "reach for the token, not the underlying `--space-*`" drift the Authoring rules above warn about.) **(June 2026 v2 follow-on — borderless seg-items drop the inner-concentric step:** the `4 / 8 / 12 / 16 item` ladder above is the FILLED variant, where the painted track forms the visible outer corner the item nests inside. In `.seg--borderless` the wrapper paints no chrome (transparent bg, `padding: 0`, no track), so the ITEM is the only visible silhouette — stacking a borderless seg above a same-tier `.btn` made the item read tighter than the button. The borderless variant therefore bumps each tier's `.seg-item` AND the `.seg-menu` split-picker wrapper up one step so item radius = wrapper radius = same-tier button radius (sm 8 / default 12 / lg 16 / xl 20), still derived via `calc()` off `--button-radius` so the slider keeps rounding everything in lockstep. Commit `4c00e2e`. **The general rule: a concentric inner-step is only correct when the wrapper has a PAINTED outer corner to nest inside; a chromeless wrapper hands the visible-silhouette role to the child, so the child must match sibling primitives directly.**) `.btn-icon` tiers + the `.destination-card` seed still consume the bare `--button-radius` (16, unchanged); the destination-card CTA re-asserts the seed so its corners stay concentric with the preview frame. Some catalog-meta primitives (`.ds-doc-card`, `.ds-doc-viewport`, `.ds-doc-codeblock`) keep their hand-picked radii — they're documentation chrome, not user-facing cards, and deferred for a phase-2 sweep.

**Per-theme migration (May 2026).** Corners moved from a global rail drill (a `layout-corners` sub-screen + `data-corners-row-value` row, persisting to four global `lifi-playground-card/panel/button-radius` + `-button-pill` keys, hydrated on every page load) into the per-theme theme-edit screen. The four global localStorage keys were retired (atomic, no aliases); corners now live in each theme record's `radii` field and apply only when a theme is selected/edited. The select/preview path (`initRailThemeCards` select + edit handlers) gained an `applyRadiiToScope(readThemeRadii(preset), …)` call alongside `ThemeComposer.applyPresetScoped`; `mountRailEditor` sets `railEditingThemeId` + calls `railCornersHydrate`; the `lifi:preset-applied` (sitewide pick) listener clears scoped radii so the widget returns to `:root` defaults. Existing global radius prefs do not carry over — the default is 16/16/16, so nothing meaningful is lost.

**Theme Composer merge (June 2026).** Corners moved from rail-only (`swap.js → initRailCorners`, ~260 lines + bespoke markup in `playground.html`'s theme-edit screen) INTO the shared `theme-editor.js → ThemeComposer` as `features.corners` — so the same Corners section renders in BOTH the FAB Theme Composer and the rail. `initRailCorners` + the `railCornersHydrate` hook were deleted; the per-axis-slider markup (`#railCornersTriosGrid` + the `.playground-rail__editor-slider` wrapper) was lifted into the editor's corners renderer as `.theme-editor-slider` (itself retired by the June 2026 Edit-view redesign → the universal `.slider-field--compact`); the read/apply/clear helpers were promoted to `shared.js` window globals (`ThemeComposer.readRadii` / `ThemeComposer.applyRadiiToScope` / `ThemeComposer.clearRadiiFromScope`). The radii paint joined the editor's `scope` channel, which is what made FAB corners sitewide. Same merge swapped the FAB list to the shared `.theme-card--row` renderer and unified the action set (Reset · Duplicate · Export · Delete).

**Source of truth.** Token definitions live in `styles.css → :root` (search `--card-radius:`). The Corners controls + paint live in `theme-editor.js` (`features.corners` · `renderCornersBody` · the corners block in `mount()` · `paintRadii`); the editor-local CSS (`.theme-editor-drawer`, `.theme-editor-active-desc`, `.corners-pill-row`) lives in `styles.css → "THEME EDITOR — editor-local layout glue"` (the controls themselves are the universal `.option-card--tile` + `.slider-field--compact` primitives). Apply-on-select: `shared.js → applyPresetById` (sitewide) + `swap.js → initRailThemeCards` select/edit handlers (canvas). Reset / Export / Delete wiring: `shared.js` (`#pcEdit*` handlers) + `swap.js → initRailThemeActions` (kebab); the export builder is `theme-composer-core.js → buildCssExport(palette, radii)` (exposed via `shared.js` `openExportModal` = `window.ThemeComposer.openExport`). The `+4` button-anchor conversion is `theme-composer-core.js → buttonRadiusToAnchor` (consumed by `ThemeComposer.applyRadiiToScope` + `buildCssExport`).

### Canvas grid-spacing system — two axes, composer-driven (sibling of the Radius system)

The horizontal + vertical spacing rhythm of a content **canvas** (the Portal dashboard grid, the Playground widget stage) is two mode-independent tokens, editable per-theme from the Theme Composer. Architecturally this is the Radius system's twin — per-theme value, painted to scope, persisted, exported — so it mirrors that pattern at every layer EXCEPT the control surface: gaps render as **two discrete-ladder sliders, no preset cards** (the trio-grid pattern was dropped for gaps June 2026 — the two-axis gap is simple enough that bare sliders read clearer than a Compact/Standard/Spacious trio). Since the June 2026 setting-row consolidation they live in their own **"Spacing" section** (a `.setting-row--expand` card whose closed head summarizes "24 / 24"; briefly merged with the Panels tile pair as "Panels & spacing" earlier the same month, split so each canvas-geometry axis reads its own summary), each slider with an editable numeric readout that snaps typed px to the nearest rung.

**The two tokens** (`styles.css :root`, no `[data-theme]` variant — gaps don't flip by mode):

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

**Why two axes, not one.** The canvas genuinely uses different values for two different jobs — tight gutters *between* side-by-side cards (16) vs. roomier breathing *between* stacked sections (24). A single symmetric knob would flatten that distinction. (Confirmed with the user June 2026.)

**Relationship to the `--gap-card-*` ladder.** Different concern. `--gap-card-*` is the universal card-LIST gap ladder (a swappable family any card list opts into). `--grid-gap-x/y` is the composer-controlled CANVAS grid rhythm. The Portal/Playground canvas grids consume `--grid-gap-x/y` (not `--gap-card-lg`); everything else still uses the card-gap ladder. Don't cross them.

**Scope — Portal + Playground canvases (not sitewide).** Tokens are defined universally (so the FAB *could* drive them sitewide later), but only two surfaces consume them today:

- **Portal** (`.portal-canvas`) — `portal.css` wires the canvas grids (`.portal-kpi-grid`, `.portal-cols`, `.portal-metric-grid`, `.portal-detail-cols`, `.portal-roles-grid`, `.portal-steps`) to `column-gap: --grid-gap-x; row-gap: --grid-gap-y`, and the canvas section margins (`.portal-page-head`, `.portal-entity-head`, `.portal-detail-tabs`, `.portal-status-banner`, the grids' `margin-bottom`) to `--grid-gap-y`. Intra-component cluster gaps (`.portal-route__pair`, `.portal-entity-head`'s own `gap`, etc.) stay `--space-*`. The section rhythm is **re-pointed margins, NOT a flex-gap on `.portal-view__inner`** — a flex-column conversion was tried and reverted because direct children (entity-head, detail-tabs) carry their own margins and would double-gap; re-pointing is the lower-risk faithful tokenization.
- **Playground** (`.playground-canvas`) — `swap.css` wires `.widget-stage` (base flex + the `[data-layout="dual"]` grid + the expanded-layout column calcs) to `column-gap: --grid-gap-x; row-gap: --grid-gap-y`, with `max-width` tracking the gutter (`calc(416px + var(--grid-gap-x) + 416px …)`) so a wider gap can't clip the two panels. **`.playground-canvas` pins `--grid-gap-x: var(--space-24)`** because the widget's historical gutter is 24, not the :root 16 — cold load preserves it. The **`.ui-amount-pair` FAB-cutout seam is NOT a consumer** (mask geometry; left on its own spacing).

**The FAB composer surfaces Spacing only when a canvas is present.** `DEFAULT_FEATURES.gaps = false` (the bare default stays off); the section is explicitly enabled in the Portal + Playground rail mounts (`features.gaps: true`) AND in the FAB **gated on a canvas** (`features.gaps: !!canvas` in `shared.js → enterEditView`). 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 — the only consumers are the Portal / Playground canvases). On the playground / Portal the FAB seeds the sliders via a honesty pass (`shared.js → readScopeGaps` reads the canvas's effective computed `--grid-gap-x/y`, e.g. the `.playground-canvas` 24-gap pin) so opening the editor doesn't clobber the rendered default on mount (`paintGaps` runs at mount — same reason the rail uses `readCanvasGaps`). (Contrast with corners + surface, which ARE in the FAB unconditionally because their tokens are universal consumers.)

**Composer behavior** (`theme-editor.js features.gaps`):
- **Two standalone sliders** — Horizontal gap (`x`) + Vertical gap (`y`), each a discrete slider on the ladder `[8, 12, 16, 20, 24, 32, 40]` (7 ticks, starts at 8 — cards never touch — vs. corners' 0). NO preset cards: unlike Corners, the Spacing section is just the section label + the two sliders (`.theme-editor-gaps` wrapper). `GAPS_TRIOS` + the trio/Custom-card markup + `findActiveGapsTrio` were retired June 2026.
- **Per-surface lifecycle** — Portal is **ephemeral** (`onChange` no-op → resets on reload, matching Portal corners). Playground **persists** `p.gaps = {x, y}` to the `theme-presets` record and applies on theme-select. Apply-on-select is **conditional**: paint saved gaps, else `ThemeComposer.clearGapsFromScope` so a gap-less theme falls back to the `.playground-canvas` 24 default (painting `readThemeGaps` defaults would force 16). `lifi:preset-applied` (FAB sitewide pick) clears scoped gaps.
- **Honest readout** — the Playground editor seeds from the canvas's EFFECTIVE computed gaps for a gap-less theme (`readCanvasGaps` in `swap.js`), so the Horizontal readout shows the rendered 24, not the bare token 16.
- **Export** — `buildCssExport(palette, radii, gaps)` appends `--grid-gap-x/y` to the `:root` block (mode-independent, like radii). Every Export path passes gaps.

**Authoring rules.**
- **Reach for the token by name, not the underlying `--space-*`** when wiring a canvas grid/section, so the composer reaches it. A bare `--space-24` on a canvas section defeats the knob.
- **Pick the axis by role** — between-cards gutter → `--grid-gap-x` (`column-gap`); between-sections rhythm → `--grid-gap-y` (`margin-bottom` / `row-gap`).
- **Adding a 3rd canvas consumer** — wire its grids/sections to the tokens, enable `features.gaps: true` 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). Don't put gaps in the FAB without surfacing first.

**Source of truth.** Tokens: `styles.css → :root` (search `--grid-gap-x:`). Helpers: `theme-composer-core.js → ThemeComposer.readGaps / ThemeComposer.applyGapsToScope / ThemeComposer.clearGapsFromScope`. Composer: `theme-editor.js → features.gaps · renderCanvasBody · GAPS_PRESETS · paintGaps`. Mounts: `portal.js → openEditor` (ephemeral) + `swap.js → initRailThemeCards` mount + select/edit/clear handlers (persisted). Export: `theme-composer-core.js → buildCssExport`. The gap controls are universal `.slider-field--compact` discrete-ladder units inside the "Spacing" section's drawer (June 2026 redesign + the setting-row consolidation split) — no preset cards. When the token set or composer feature evolves, update this section in the same commit.

### Surface contrast control — per-theme canvas↔panel tonal step (sibling of Corners + Spacing)

The recessed-canvas surface (`--surface-sunk`) — how far the canvas sits below the page tonally — is editable per-theme from the Theme Composer's **Surface** section, the third composer control (`theme-editor.js features.surface`) alongside Corners + Spacing. It tunes the canvas↔panel contrast a theme reads with — flat (canvas ≈ panel) to strongly recessed.

**THE LEVER.** `--surface-sunk` is **derived in `deriveTokens` (theme-composer-core.js, search `--surface-sunk`)** from the surface seed `surf`: dark `max(surf − 1.5, 0)` / light `max(surf − 3, 0)` (light was `surf − 5` before June 2026 — brightened to `surf − 3`, the airier canvas default that also = the light clamp floor). The `styles.css :root` / `[data-theme="light"]` literals (search "recessed canvas") are first-paint fallback only (dark 6.5% / light 97%). The composer slider lets a theme widen the recess (it can only go *darker* than the default, since the default sits on the floor).

**THE VALUE — per-mode recess below the page.** The stored value is the canvas's lightness **recess below the page seed** in L percentage points; `--surface-sunk` L = `surf − contrast`. Schema: `preset.surface = { dark, light }` — top-level on the theme record, sibling to `preset.radii` / `preset.gaps` (a two-field object, structurally parallel to gaps' `{x,y}`; the two fields are the two modes). Baseline defaults reproduce `deriveTokens` exactly: **dark 1.5 / light 3** (light was 5 before June 2026; lowered to 3 = the airier canvas default, which is also the light clamp floor — so the light slider can't go below 3 without the canvas inverting the raised-tier panels. To go lighter, elevated panels must move to the content tier; see [[Panel primitive]] surface variants).

**PER-MODE, not mode-independent (the one deliberate divergence from Corners/Spacing).** Corners + Spacing are mode-invariant *geometry* and store one value for both modes. Surface contrast is a *colour-family* property whose headroom is genuinely mode-asymmetric — the shipped baseline (dark `surf−1.5`, light `surf−3`) can't be reproduced by any single value, and the clamps differ by mode (below). So it stores per-mode and the Surface control re-renders its slider range + value on a mode flip (like the palette sliders, unlike Corners/Spacing). It lives with the palette (mode-aware) family in the editor — since the June 2026 Edit-view redesign it renders as the **"Canvas contrast" slider inside the Colors section** (below a divider under the shared L/C/H trio), since it tunes the canvas derived from that same Background seed; the readout is an editable numeric input like every other slider.

**SLIDER — continuous, mode-aware range.** Unlike Corners/Spacing (discrete `.slider--discrete` ladders), Surface uses one *continuous* `.slider-range--panel` (the same control the per-accent L/C/H rows use) — contrast is a fine tonal axis with mode-dependent headroom, so `theme-editor.js → syncSurfaceUI` recomputes `min`/`max`/`value` per mode from `window.ThemeComposer.surfaceContrastRange(surf, mode)`:

| Mode | min recess | max recess | Ceiling — sunk no lighter than | Why the ceiling differs |
|---|---|---|---|---|
| dark | 0 | `min(surf, 24)` | **page** (`surf`) | the raised panel is *lighter* than page in dark, so the page is the binding "still reads recessed" floor |
| light | `surf − raised` (= 3) | `min(surf, 24)` | **raised panel** | the panel is *darker* than page in light, so the panel is the binding ceiling |

**THE ENTANGLEMENT — why the wiring differs from Corners/Spacing.** `--card-radius` / `--grid-gap-*` are NOT in `deriveTokens`, so `applyPalette` never touches them and a single paint sticks. `--surface-sunk` IS in `deriveTokens`' output, so **`applyPalette` writes a baseline `--surface-sunk` on every paint** — the surface override must therefore run AFTER `applyPalette` and re-run on every repaint:
- `theme-editor.js → paint()` calls `paintSurface()` last, so every palette/mode change in the editor re-asserts the recess.
- `shared.js → applyPresetById` + the `initBrandPalette` **mode-toggle observer** re-apply the active preset's surface after `applyPalette` (the toggle observer is the one that would otherwise snap the recess back to baseline on every light/dark flip).
- `swap.js` rail select/edit + `portal.js applyScoped` re-assert after `ThemeComposer.applyPresetScoped`.
- **No "else clear"** in the select paths (unlike gaps): `applyPresetScoped` already painted the theme's baseline `--surface-sunk`, so a surface-less theme correctly shows that baseline with no override. The sitewide-supersede path needs no explicit surface clear either — `clearScopedPalette` already covers `--surface-sunk` (it's a scoped `deriveTokens` token).

**Helper signature stays the 2-arg sibling parity.** `window.ThemeComposer.applySurfaceToScope(surface, scope)` mirrors `ThemeComposer.applyGapsToScope` / `ThemeComposer.applyRadiiToScope`. The palette context it needs comes from the scope itself — it reads the already-painted `--surface-page` / `--surface-raised` off `getComputedStyle(scope)`, derives the recessed L (clamped per the table), and writes `--surface-sunk` with the page's chroma + hue (keeping the surface family coherent). `window.ThemeComposer.readSurface(preset)` normalizes to `{dark, light}` with the 1.5/3 baseline defaults.

**Reach.** Enabled in **all three** editor mounts — the FAB composer (`scope: canvas ? [canvas] : null` → the content canvas when marked, else `:root`; enabled UNCONDITIONALLY since the recessed canvas is a universal surface property — **unlike gaps + panels**, which the FAB gates on a canvas being present) AND the Portal/Playground rail composers (paint the canvas). (June 2026 — the FAB scope flipped from always-`null`; see [[Theme Composer scope]].) Export: `buildCssExport(palette, radii, gaps, surface)` overrides `--surface-sunk` INSIDE each mode's block (it lands in BOTH `:root` and the light block, since it's per-mode — not appended to `:root` only like radii/gaps). Reset restores the seed's `surface` (or clears → baseline). **Default stays LI.FI 1.0** — the `lifi-1` preset carries no `surface`, cold load paints nothing, and the preview is ephemeral (no localStorage auto-apply), exactly like palette/corners.

**Authoring rules.**
- **Don't bake a literal `--surface-sunk`** on a consumer to change canvas contrast — tune the theme's `surface` recess so the whole system flexes, the same way you'd never bake a card background instead of using a `--surface-*` token.
- **A new override path that re-runs `applyPalette`** must re-assert surface after it (the baseline clobber). Mirror the existing select / mode-toggle / scoped paths.
- **Adding the control to a 4th mount** — pass `surface: ThemeComposer.readSurface(preset)` + `features.surface: true`; persist `next.surface` in `onChange` if the mount persists (the Portal modal is ephemeral, so it doesn't).

**Source of truth.** Helpers: `theme-composer-core.js → ThemeComposer.readSurface / ThemeComposer.applySurfaceToScope / ThemeComposer.clearSurfaceFromScope / ThemeComposer.surfaceContrastRange` + the baseline derivation in `deriveTokens` (`--surface-sunk`). Composer: `theme-editor.js → features.surface · the Canvas-contrast slider in renderColorsBody (renderSurfaceOnlyBody for the palette-less defensive case) · paintSurface · syncSurfaceUI`. Mounts: `shared.js → enterEditView` (FAB sitewide) + `swap.js → initRailThemeCards` mount/select/edit + `portal.js → openEditor` / `applyScoped`. Export + Reset: `theme-composer-core.js → buildCssExport` / `shared.js → #pcEditReset` + `swap.js` rail kebab. First-paint literals: `styles.css :root` / `[data-theme="light"]` (search "recessed canvas"). When the value model or composer feature evolves, update this section in the same commit.

### Semantic states control — per-theme state-token coefficients (sibling of Surface)

The four semantic state tokens (`--success`/`--danger`/`--warn`/`--info` + companions) derive from the PRIMARY seed via per-role coefficients (`L = anchor.L + lo`, `C = anchor.C × cm`, `H` role-locked) — with a **0.12 chroma-INPUT floor** (`max(c, 0.12)` in the styles.css formulas ≡ `SEM_C_FLOOR` in theme-composer-core.js + the editor swatch; keep all three in lockstep) so a muted/monochrome anchor can't collapse the states to grey — and are **editable per theme** from the Theme Composer's **Semantic states** section (`theme-editor.js features.semantics` — a **Default/Custom `.option-card--tile` pair** (the Text section's Neutral/Tinted shape; June 2026, the revert-contract port) above a resolved-tone SWATCH STRIP role picker (June 2026 redesign; was a text seg — colour picked by colour) + L-offset / Chroma× / Hue sliders with editable numeric readouts; dragging any slider flips the axis to Custom; the section's collapsed header shows the four resolved tone dots; hue bands keep each role in its legible territory; the cm ceiling is 2.0 so low-chroma brands can lift washed-out states back to legibility — the merge's reason for being). June 2026 — the standalone Semantic Composer (foundations.html workspace + `shared.js → initSemanticComposer`, ~500 lines) was retired into this axis, atomic, no aliases.

- **Record shape** — optional `semantics: { hues: {role: H}, dark: {role: {lo, cm}}, light: {…} }` on the theme record; `lo` on the CSS 0–1 scale; `lo`/`cm` per mode, hues shared (the styles.css shape — H declared only in `:root`). Absent = the formula defaults; the editor NEVER forces defaults onto a pristine record. **Revert contract = the text contract (June 2026):** the editor emits `payload.semantics = null` when the user reverts to Default (the key is present only when the mount enables `features.semantics`, so other mounts can never strip the field), and the persisting hosts do `if (semantics) p.semantics = …; else if (semantics === null) delete p.semantics;` (`shared.js → enterEditView` onChange — record + the `:root` closure palette; `swap.js → mountRailEditor` onChange) — so a theme can return to the live formula defaults from the editor, no Reset-to-seed needed (and user themes aren't stuck). The Custom tile restores the last custom coefficients (kept in the editor's `semSaved`, the textRaw-kept-on-Neutral cheap-undo pattern); reverting a BUNDLED record forks copy-on-write like any other edit (the revert fires the same onChange).
- **Paint model — coefficients at `:root`, literals on canvases.** Semantics ride ON the palette object (`palette.semantics`), so every path that re-applies a palette carries them: the mode-toggle observer (per-mode `lo`/`cm` re-assert per flip), `applyPresetById`/`ThemeComposer.applyPresetScoped` (attach from the preset record), the editor's `paint()`, and `buildCssExport` (emits `--sem-*` vars — dark + hues in `:root`, light `lo`/`cm` in the light block; semantic LITERALS stay excluded so a pasted export can't freeze the formulas).
- **Mounts** — enabled in all four (FAB `enterEditView`, Playground rail, Portal Settings both scopes, catalog demo). Works at `:root` AND on canvases — unlike gaps/panels it needs no canvas gate (the Surface pattern).
- **`deriveTokens(seeds, mode, semantics?)`** now uses per-mode default tables — this also fixed the latent bug where scoped canvases derived LIGHT-mode semantics from the dark coefficient table (canvas light semantics ≠ `:root` light semantics, pre-June-2026).
- **The states track the PRIMARY anchor's LIGHTNESS — a brand seed-L change ripples to them (June 2026).** Because `L = anchor.L + lo` and `--brand-anchor = --lifi-sapphire`, changing the primary seed's L shifts all four states by the same amount **in the mode whose anchor moved**. When the dark Sapphire anchor dropped 0.66→0.52 (the white-CTA-label unification), every dark state fell 0.14 L and `danger` hit **2.63:1 (failed AA + large-text)**; light was unaffected (already on an L0.52 anchor). Fix was +0.14 on each dark `--sem-*-L-offset` (styles.css `:root` + the `SEM_DEFAULTS.dark` mirror, in lockstep), restoring byte-identical output. **So: any change to the brand primary's LIGHTNESS needs same-mode `--sem-*-L-offset` compensation** — and it's invisible to an in-browser spot-check; catch it by regenerating `colors.json` and diffing (the ripple shows as N shifted `--sem-*`/`--danger`/… tokens). Full diagnostic: [`bug-history.md → Sapphire white-CTA-label flip`](.claude/skills/lifi-ds-docs/references/bug-history.md).

**Source of truth.** Engine: `theme-composer-core.js` (`SEM_DEFAULTS` · `normalizeSemantics` · `ThemeComposer.readSemantics` · the `paintRootSemantics` branch in `applyPalette`). Editor: `theme-editor.js → renderSemanticsBody` / `syncSemUI`. Formula system: `styles.css :root` (search `--sem-success-L-offset`). Spec: `design/theme-composer.md §3` + catalog `design-system/foundations.html#semantic-composer`. When the coefficient defaults are retuned, update styles.css AND `SEM_DEFAULTS` in the same commit.

### Text tint control — per-theme text-ladder tint (sibling of Semantic states)

The four text tokens (`--text-primary/-secondary/-muted/-faint` + the deprecated `--white`/`--text-2/-3/-4`/`--gray` aliases) are a neutral white/black ALPHA ladder by default — which can never render text MORE chromatic than the surface beneath it (alpha picks up surface tint ∝ transparency). Brand books that spec chromatic support tones (LI.FI 2.0 v1.2: body `#A9AEDC` c 0.066 on ink — 13× what white-alpha composites to) need **opaque derived tiers**: the optional per-theme **`text` axis** (June 2026), editable from the Theme Composer's **Text** section (`theme-editor.js features.text` — a Neutral/Tinted `.option-card--tile` pair + per-mode Tint chroma/hue sliders; the section header shows all four resolved tier dots).

- **Record shape** — `text: { dark: { c, h, tiers? }, light: { c, h, tiers? } }`: a per-mode TINT × the fixed `TEXT_DEFAULTS` per-tier ladder (`theme-composer-core.js` — lightness stop + chroma multiplier per tier; dark 98/.10 · 76/1.0 · 56/1.15 · 44/1.0, light 10/.30 · 49/1.0 · 62/1.0 · 70/.90). Tier stops are record-overridable via `tiers` (data-layer flexibility); the composer edits the tint only. Calibration reproduces the brand-book p5 support tones exactly at the deck tints (dark 0.066/280 → `#A9AEDC`/`#6A70A0`; light 0.026/274 → Slate `#5C6070`). PRIMARY stays near-pure by design ("paper/white on ink is the primary pairing").
- **Absent = the historical alpha ladder, byte-identical** — the editor never bakes the axis onto a pristine record (the `semCustom` discipline); the Neutral tile reverts (payload `text: null` → hosts DELETE the field; the key is only present when the mount enables `features.text`, so other mounts can't strip it).
- **Paint model** — rides ON the palette object (`palette.text`, the semantics pattern): every re-apply path carries it (mode-toggle observer — tints are per-mode, `applyPresetById`/`ThemeComposer.applyPresetScoped`, the editor's `paint()`, `buildCssExport` — text tokens are plain literals everywhere, so unlike `--sem-*` there is no :root formula split; the tinted literals land in both export mode blocks). **`DEFAULT_PALETTE` carries the default preset's `text`** — without it the cold-load `applyPalette(DEFAULT_PALETTE)` would repaint the alpha ladder over the tinted `:root` literals.
- **No paint-time WCAG clamp — deliberate**, matching the accent doctrine (nothing clamps a white accent on a white page either): legibility is enforced at AUTHORING time (the composer's tint-chroma slider caps at 0.12; the deck values pass AA with margin — `#A9AEDC` on ink 7.9:1, slate on paper 5.9:1).
- **Enabled in all four mounts** (FAB `enterEditView`, Playground rail, Portal Settings both scopes, catalog demo) — like semantics, no canvas gate (plain literals work at `:root` and on canvases).

**Source of truth.** Engine: `theme-composer-core.js` (`TEXT_DEFAULTS` · `normalizeText` · `ThemeComposer.readText` · the `txtTier` branch in `deriveTokens` — 4th param). Editor: `theme-editor.js → renderTextBody` / `syncTextUI`. Mirror guard: `scripts/check-theme-tokens.py` **TC5** (default preset `text` ↔ the styles.css `--text-*` first-paint literals — BOTH directions: tinted derivation when the record exists, neutral-alpha when it doesn't). Spec: `design/theme-composer.md §3`. When `TEXT_DEFAULTS` is retuned, update the styles.css literals in the same commit (TC5 enforces).

### Charts axis — per-theme categorical series ramp (sibling of Panels; never touches --spectral-*)

The Theme Composer's **Charts** section (`theme-editor.js features.charts` — a Neutral / Brand-ramp `.option-card--tile` pair) decides what the categorical chart tier (`--series-1..12`) renders, per theme: **`'spectral'`** (default — the fixed spectral palette; categorical data reads the same across brand presets, the original doctrine) or **`'brand'`** — a generated 12-hue ramp anchored on the theme's PRIMARY hue (`series-i hue = primary.h + (i−1)·30°`, so series-1 IS the brand hue), with L/C sampled from the **spectral v3 discipline curve** (`theme-composer-core.js → CHART_DISCIPLINE`, circular-interpolated per mode — yellows lift, blue-violets dip, exactly like the hand-tuned palette; a near-black or low-chroma accent still yields a legible ramp). Built June 2026 for the Portal partner case: org-tinted analytics without per-hue sliders.

- **Series only, NEVER `--spectral-*`** — avatar tones (`--avatar-tone` ← `--spectral-N`, deterministic identity colours) and decorative spectral consumers stay fixed. The override targets the chart-curated `--series-N` aliases exclusively.
- **Record shape** — optional `charts: 'spectral' | 'brand'` (mode-independent VALUE, per-mode PAINT: the ramp literals differ per mode, so every mode-flip path re-runs the paint).
- **Paint ownership** — `ThemeComposer.applyChartsToScope(charts, scope)` reads the painted `--accent-primary` off the scope (the applySurfaceToScope pattern), so it always runs AFTER `applyPalette`. **`ThemeComposer.applyPresetScoped` runs it internally** — rail/Portal selects, portal-render org themes, and the rail's mode-flip re-apply all get it for free; `applyPresetById` (both branches), the `:root` mode observer, and the editor's `paint()` carry their own calls. 'spectral' CLEARS, so switching away from a brand-ramp theme reverts. Painted names register with the scoped-token registry (a sitewide supersede sweeps them).
- **Export** — `buildCssExport(…, charts)` emits the per-mode `--series-1..12` literals in BOTH mode blocks when `'brand'` (computed from each mode's primary seed hue — same math as the live paint).
- **`CHART_DISCIPLINE` mirrors the spectral v3 primitives** in `styles.css` — keep the pair in lockstep if the spectral palette is retuned (same contract as `SEM_DEFAULTS`).

**Source of truth.** Engine: `theme-composer-core.js` (`CHART_DISCIPLINE` · `chartSeriesTokens` · `ThemeComposer.readCharts/applyChartsToScope/clearChartsFromScope`). Editor: `theme-editor.js → renderChartsBody`. Spec: `design/theme-composer.md §3` + `design.md §02 → Per-theme chart ramp`. The series-tier comment in `styles.css` (search `THE CHARTS AXIS`) carries the breadcrumb.

### Typography axis — per-theme brand font: SANS + MONO sub-axes (sibling of Charts; Google Fonts only)

Each theme can bake **two brand families**: an optional `font: '<id>'` (sans) + an optional `mono: '<id>'` (monospace) on the theme record, resolving through the **`THEME_FONTS`** (sans) + **`THEME_MONO_FONTS`** (mono) registries (`theme-composer-core.js`). Sans = 11 families (figtree · inter · urbanist · dm-sans · manrope · plus-jakarta-sans · outfit · space-grotesk · sora · instrument-sans · poppins). Mono = 6 (jetbrains-mono · ibm-plex-mono · space-mono · roboto-mono · fira-code · geist-mono; June 2026). Both edited from the Theme Composer's **Typography section** (`theme-editor.js features.font` — gates BOTH; two labeled `.dropdown`s — **Sans** + **Mono** — each previewing rows in their own face; "Default" = inherit the `:root` family; the closed-section summary reads "`<Sans> · <Mono>`"). Bundled brand fonts: **LI.FI 1.0 → figtree + jetbrains-mono · LI.FI 2.0 → inter + ibm-plex-mono** (the v3 Brand Book's two-font system: Inter "carries the argument", IBM Plex Mono "the data voice") · **Jumper 1.0 + 2.0 → inter** (changed from Urbanist June 2026 to match the Foundation "Colors (Jumper)" `UI (Inter)` widget mocks — the in-widget UI font; `--font-jumper` Urbanist stays the lockup/marketing lever) **· Kast → instrument-sans + space-mono · VilenDesign → poppins** (the June 2026 partner-font + mono ratification passes). **Kast is the ONLY partner brand with a distinct mono** — kast.xyz self-hosts Space Mono (`@font-face "Spacemono"`, SpaceMono-Regular/Bold `.woff` = the existing `space-mono` registry entry's 400;700 axis) beside Instrument Sans; **Jumper 1.0/2.0 + VilenDesign are RECORDED mono-less** (no named brand mono on their live surfaces — Jumper's sole mono ref is the generic `monospace` keyword, VilenDesign loads only Poppins + a Squarespace Typekit → both inherit the `:root` default JetBrains Mono). **Ledger + Rabby are RECORDED font-less AND mono-less** — Ledger's faces are all custom (Brut Grotesque / HM Alpha Mono / Ledger Mono), Rabby's is SF Pro + SF Mono (Apple proprietary; its Lato/Roboto GF load is fallback-chain only) — none on Google Fonts. Each preset carries the reason as a comment, so font-less/mono-less is a decision, not an omission. **Competitor presets carry NEITHER font nor mono** — the typography axis is deliberately scoped to the partner/sibling/own brands (mirroring how the font pass excluded competitors). Future brands add via the `lifi-brand-theme` flow + a `revision` bump.

- **Sans + mono swap; the type SCALE/weights/tracking stay shared.** Applying paints `--font-sans` (sans) and `--font-mono` (mono) on the scope as resolved stacks (family + the canonical system fallback chain). The type scale, weights, and tracking stay SHARED across brands (the Jumper-lever contract). `--font-jumper` (the lockup-label constant) is untouched by this axis. *(Before June 2026 `--font-mono` was a shared `:root` constant no theme touched — the mono sub-axis made it per-theme.)*
- **The mono sub-axis is a sibling, with ONE structural difference — `applyMonoToScope` paints `--font-mono` ONLY, no inline `font-family` re-anchor.** Mono is consumed exclusively by rules that declare `font-family: var(--font-mono)` (the code chip, numerics, eyebrows, folios, data) — a scoped `--font-mono` reaches every one of them, so it needs no inheritance re-anchor (unlike the sans, below). Helpers mirror the sans set: `readThemeMono` / `themeMonoStack` (mono fallback) / `applyMonoToScope` / `clearMonoFromScope`; `ensureThemeFontLoaded` resolves ids from BOTH registries. Same null-is-meaningful emit contract (`mono: null` = Default = hosts DELETE the field; rides `features.font`, so a mount with the axis off can't strip it).
- **The scope must re-anchor inheritance — `applyFontToScope` paints BOTH `--font-sans` AND inline `font-family`.** Plain text inherits `body`'s COMPUTED family (resolved at body, outside a canvas), so a scoped `--font-sans` alone only re-fonts rules that declare `font-family: var(--font-sans)` (buttons, inputs). The inline `font-family` on the scope re-anchors the chain; descendants with their own family rules (mono, var-consumers) still win over inheritance. Same lesson family as the `--panel-surface` canvas re-declaration.
- **Google Fonts, lazy-loaded.** `ensureThemeFontLoaded(id)` injects one `<link>` per family on first apply (deduped via `data-theme-font`); no page pre-loads the registry — the editor preloads the full registry only on the dropdown trigger's first click (for the in-face row previews). Each registry entry's `gf` query is clamped to the font's REAL variable-weight axis (e.g. Manrope `200..800`, Instrument Sans `400..700`) — an out-of-range css2 request 404s, so don't normalize the ranges. **STATIC families (no variable axis) must ENUMERATE weights with `;`** (Poppins `300;400;…;900`) — a range query on a static family 404s too.
- **The default brand's fonts are a hand-maintained CSS↔JS mirror — BOTH families.** `styles.css`'s `:root --font-sans` + `--font-mono` literals + the two top-of-file `@import`s ARE the first-paint mirror of the default preset's `font` + `mono` fields (currently `lifi-1 → 'geist' + 'geist-mono'`). Nothing auto-applies on cold load — the stylesheet literals are what paint. When the default brand's fonts change, flip them together: the `@import`s, the `--font-sans`/`--font-mono` literals, and the preset fields (+ revision bump). **`scripts/check-theme-tokens.py` TC7** (nightly **B9** · `/checkup`) enforces BOTH mirrors — the leading `--font-sans`/`--font-mono` families + their `@import`s against the preset's `font`/`mono` resolved through the two registries. A font-less default sans falls back to the historical Figtree (no `@import` required); the default MONO `@import` is always required (JetBrains Mono self-loads via styles.css, parallel to the sans), and a stale brand-mono `@import` is flagged.
- **Record shape + lifecycle** — both `font` and `mono` are mode-independent + NOT deriveTokens tokens (`applyPalette` never clobbers them; a single paint sticks across mode flips — the radii/gaps lifecycle, no observer re-assert needed). Apply-or-clear: a font-less / mono-less theme CLEARS the scope back to the `:root` default. **`ThemeComposer.applyPresetScoped` runs both internally** (rail/Portal/org selects free); `applyPresetById` (both branches), the FAB reset/delete paths, and the editor's `paintFont`/`paintMono` carry their own calls; painted names register with the scoped-token registry (a sitewide supersede sweeps them).
- **Export is typography-complete** — `buildCssExport(…, font, mono)` emits a Google Fonts `@import` per family ABOVE the blocks + the resolved `--font-sans` / `--font-mono` stacks in `:root` (mode-independent, like radii/gaps).

**Source of truth.** Engine: `theme-composer-core.js` (`THEME_FONTS` + `THEME_MONO_FONTS` · `ThemeComposer.readFont/applyFontToScope/clearFontFromScope` + `readMono/applyMonoToScope/clearMonoFromScope` · `fonts`/`monoFonts`/`fontById`/`monoById`/`fontStack`/`monoStack`/`ensureFontLoaded`). Editor: `theme-editor.js → renderFontBody` + `renderMonoBody` / `syncFontUI` + `syncMonoUI` / `paintFont` + `paintMono`. Glue CSS: `styles.css → .theme-editor-font` / `.theme-editor-typo`. Seeds: `presets.js` (`font` + `mono` fields). First-paint mirror: `styles.css` top `@import`s + `:root --font-sans`/`--font-mono` (TC7). Spec: `design/theme-composer.md §3`. When the registries or contract evolve, update this section + theme-composer.md in the same commit.

### Manual colour mode — per-theme literal token overrides (Auto is the default; sibling of the axes)

The Colors section carries an **`[Auto | Manual]` seg** (`theme-editor.js → renderColorsBody`). **Auto** (the default, and the cold-load default for every theme) is the unchanged seeds → `deriveTokens` generation with the input clamps applied. **Manual** does two things: (a) **lifts the seed input clamps** — `groupRanges()` returns the full `ACCENT_RANGES` for ALL four seeds including Background (so a vivid page surface is allowed; the 0.08 Background chroma cap is the Auto-only constraint), and (b) surfaces a **per-token override panel** — grouped `.swatch-edit` rows (Surfaces / Semantic states / Text) that pin a DERIVED token to an exact literal that **wins over derivation, including escaping the 0.12 semantic chroma floor**. This is the [[swatch-edit-primitive]] editor's reason for being.

**Data model — two fields on the preset record + the palette object:**
- `colorMode: 'auto' | 'manual'` — absent = auto (never baked onto a pristine record; the `text:null` revert discipline).
- `overrides: { dark: {token: literal}, light: {…} }` — per-mode literal pins, only the tokens the user explicitly detached (**detach-on-edit**: a row shows the live derived value until edited, then becomes an override). Absent / empty = pure derivation.

**Paint model — overrides ride the palette object like semantics/text; applyPalette pins them AFTER derivation.** `applyPalette` reads `palette.overrides[mode]` and writes those literals LAST on each target, **bypassing the `SEMANTIC_SCOPED_ONLY` skip** — so a pinned `--danger` literal lands even on `:root` (where semantics are otherwise formula-driven). That bypass IS the floor-escape. **`clearUnpinnedSemOnRoot`** removes any un-pinned base-semantic inline literal on `:root` so suspend/reset reverts to the formula (surfaces/text self-correct via the main token loop; only `:root` semantics — skipped there — need the explicit clear). **Overrides do NOT loosen the [[deriveTokens-is-complete]] contract / TC1** — `deriveTokens` still emits every token; overrides only SHADOW some at paint time, post-derivation.

**The plumbing — wherever a path attaches semantics, it attaches overrides too.** The shared **`ThemeComposer.attachOverrides(pal, src)`** (core) copies `colorMode` + `overrides` from a record onto a palette about to be painted (manual+overrides → clone; else clear). Called at every re-apply chokepoint so a Manual theme survives select / scoped-select / mode-flip: `paintChromeFromPreset` (closure → the mode-toggle observer carries it), `paintCanvasFromPreset` (canvas), `applyPresetScoped` (rail / Portal / org + their mode-flip), `exportById`, the reset-to-seed apply, the delete-active fallback, and the FAB onChange closure-attach. Persisted in each host's onChange (FAB `enterEditView`, rail `mountRailEditor`) + the `resetToSeed` store method. Carried into the editor via `colorMode` / `overrides` mount-opts at all 5 mounts (FAB · rail · `openChromeEditor` · Portal `openEditor` · catalog `ds-theme-composer.js`). **`paint()` attaches overrides only while in manual**, so toggling to Auto SUSPENDS without discarding (re-toggle restores).

**Export.** `buildCssExport`'s per-mode block emits `palette.overrides[mode]` literals LAST (shadowing the derived line, dropping the dupe) and bypassing the semantic filter — so a pinned `--danger` freezes as a literal in the exported CSS, per mode.

**Authoring rules.**
- **Don't bake a literal on a consumer to escape the floor / clamp** — pin it in Manual mode so the override travels with the theme + exports cleanly. Same discipline as never baking a `--surface-*` value.
- **A new override-able token** goes in `OVERRIDE_GROUPS` (`theme-editor.js`); it must be a `deriveTokens` output (the row's derived starting value comes from the token map). Don't add tokens with no derived value.
- **Auto stays the cold-load default** — the LI.FI-1.0-only-default rule is unaffected (nothing auto-applies; a manual theme only paints overrides on a user pick/edit).
- **EXCEPTION — the DEFAULT preset itself in Manual mode pins at cold-load (June 2026).** "Overrides only on a user pick/edit" holds for *non-default* presets, but when the DEFAULT brand (`lifi-1`) carries `colorMode:'manual'` + `overrides`, its pins MUST paint on cold-load (the default is painted every load via `applyPalette(DEFAULT_PALETTE)`). Two things make that work, both required: **(1) `DEFAULT_PALETTE` (shared.js ~2235) must copy `colorMode` + `overrides`** — it historically copied only `dark`/`light`/`text`, so without the addition the cold-load `applyPalette` DERIVES surfaces straight over the intended pins (the override silently never applies); **(2) the `styles.css :root` + `[data-theme="light"]` literals for the pinned tokens must mirror the override values** so the pre-JS first paint matches the pins (no FOUC). Used to lock LI.FI 1.0's dark surfaces to the exact Brand Book ladder (`--surface-raised` #161A38 / `--surface-card` #262C55) — bare derivation gives L26.5/28, the pins give the official 23.2/31. Non-default brands are unaffected (they derive normally; only the default's own pins paint at load). [[deriveTokens-is-complete]]

**Source of truth.** Engine: `theme-composer-core.js` (`applyPalette` override-apply + `clearUnpinnedSemOnRoot` · `ThemeComposer.attachOverrides` · `buildCssExport` per-mode override emission). Editor: `theme-editor.js` (`colorMode` + `overrides` state · `OVERRIDE_GROUPS` · `renderOverrideRow`/`renderOverrides` · the seg + `groupRanges` clamp-lift · `updateSwatchesAndValues` override-row refresh). CSS: `styles.css → "Manual colour mode"` block (the seg / override panel / `.is-pinned` / the `[data-color-mode="auto"]` auto-hide gate). Component: the `.swatch-edit` primitive ([catalog `#swatch-edit`] · `styles.css → .swatch-edit` · `shared.js → initSwatchEdit`). Spec: `design/theme-composer.md §3`. When the contract evolves, update this section + theme-composer.md in the same commit.

### Nested radius — concentric corners (the calc that derives a nested element's radius)

Companion to the Radius system above. The three radius **tokens** answer *"what radius does this surface get."* This rule answers *"what radius does a rounded element get when it's nested inside another rounded, padded element"* — and the answer is a **calc, not a token**.

**The rule.** Two rounded corners (a child inside a padded parent) read as *concentric* — curves parallel, a constant gap all the way around the bend — only when:

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

Match the radii without accounting for the padding (the naive `outer = inner`) and the curves are **not** concentric: the gap is uniform on the straight edges but pinches around the diagonal, and the inner corner reads as crowded. It's the single most common rounded-corner mistake.

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

- **Designing a frame around known content** → `outer = min(inner + padding, 32px)`. The `min(…, 32px)` is a **ceiling**: beyond a sensible cap the encompassing card stops tracking the child and reads as a calm rounded card rather than an over-bubbled one. At large padding, strict concentricity balloons the outer corner; the cap trades that concentricity for a better-looking corner — and since the curves are far apart at large padding, the lost concentricity is barely perceptible. `--space-32` is the recommended ceiling.
- **Adding a new nested element inside a known container** → `inner = max(outer − padding, 0px)`. The `max(…, 0px)` is a **floor**: a deeply-inset child goes square rather than negative. This is the default corner radius for a new nested element — the case the rule is usually reached for.

**The CSS pattern — two twin clamps.**

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

/* OUTWARD — deriving the frame / encompassing card. --space-N is the child's
   padding. Ceiling at --space-32 so a large padding doesn't over-round. */
.parent { border-radius: min(calc(var(--inner-radius) + var(--space-N)), var(--space-32)); }
```

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

**Scope — derive, don't retrofit.** Card / panel / button radius all default to a flat 16 px (see Radius system above) for top-level surfaces; that flat default is deliberate, not an oversight. This rule is for **deriving** a nested element's radius when you want the curves to track — it is **not** auto-applied to existing nested cards, and retrofitting the codebase's nested surfaces to step their radii is a separate, visible design decision to surface before doing. Reach for the concentric calc on **new** nested work where the parallel-curve look matters.

**Source of truth.** This section is canonical. Spec: `design.md → Nested radius — concentric corners`. Live visualization + worked examples: `design-system/foundations.html.html#nested-radius`. The calc pattern is also documented next to the radius tokens in `styles.css` (search `Nested radius — concentric corners`).

### Variant-aware states — route through a `--*-tone` token, never bake `--accent-primary`

When a component has multiple variants (`.foo--primary`, `.foo--secondary`, `.foo--tertiary`) and each variant carries its own accent colour, the shared `:hover` / `:active` / `.is-active` rules MUST consume a per-variant token (e.g., `--foo-tone`), never hardcode `var(--accent-primary)`. Per-variant `:hover` overrides cannot beat a `[data-theme="light"]` base rule on specificity, so in light mode every variant hovers in primary blue regardless of its own colour.

**The specificity arithmetic.** `[data-theme="light"] .foo:hover` = attribute + class + pseudo = (0,3,0). `.foo--secondary:hover` = 2 classes (base + variant) + pseudo = (0,2,0). The theme-qualified base rule always wins. If the base rule's `background: color-mix(--accent-primary 6%, …)` references the primary token directly, every variant inherits LI.FI blue.

**The pattern.** Introduce a tone token, override per-variant, then consume in the shared state rules:

```css
.foo { --foo-tone: var(--accent-primary); }
.foo--secondary { --foo-tone: var(--accent-secondary); }
.foo--tertiary  { --foo-tone: var(--accent-tertiary); }

.foo:hover { border-color: var(--foo-tone); }
[data-theme="light"] .foo:hover {
  background: color-mix(in oklch, var(--foo-tone) 6%, transparent);
  border-color: var(--foo-tone);
}
```

**Mix base — `transparent`, never `--surface-card` or `#FFFFFF`.** `--surface-page` in light mode is `oklch(100% 0 268)` — pure white but with a stored hue 268 (LI.FI blue). CSS `color-mix(in oklch, …)` interpolates that stored hue into the result even at chroma 0, drifting the painted hue toward 268 regardless of the variant's actual hue. Mixing with `transparent` lets the page bg show through the alpha while preserving the chromatic hue at 100% — the only fully brand-pure recipe.

**Source of truth & precedent.**

- `.tile` — `--tile-accent` resolved from `--tile-accent-light` / `--tile-accent-dark` (dual-theme contract). Consumed by `.tile:hover` and `.tile.is-active` in `styles.css`.
- `.brand-card` — extends `.tile`; per-card `--brand-c-light` / `--brand-c-dark` inline `style` feeds the tile-accent slots. State styling inherits automatically.
- `.accent-card` — `--accent-card-tone` per `.accent-card--{primary,secondary,tertiary}`. Consumed by the shared hover/active rules at `styles.css → .accent-card:hover`.

**Bug history.** April–May 2026 — `.brand-card` / `.accent-card` per-variant `:hover` rules worked in dark mode but failed in light (specificity) and baked `--accent-primary`; migrated to the `--*-tone` pattern (same pass caught the surface-card-vs-`transparent` mix-base leak). Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → Variant-aware states`.

### Hover transitions — `ease` curves at 0.2 s on bg/color/border, bouncy `transform` on lift, `rotate` for stateful icons

Interactive transitions on every button and button-shaped primitive (icon buttons, chips, segmented control items, drill rows, anything that consumes the canonical `.btn-*` shared rule) follow one uniform contract. The doctrine landed May 2026 after a long debugging chase where the chevron toggle on `.ui-quote-card` read as "popping" on hover even though the CSS appeared correct.

| Property | Curve | Duration | Notes |
|---|---|---|---|
| `background` | `ease` | `0.2s` | The bg wash IS the affordance signal; smooth fade is what makes hover feel "subtle" rather than "snap." Matches `.btn-icon.btn-borderless`'s reference feel. |
| `color` | `ease` | `0.2s` | Paired with bg for legibility transitions. |
| `border-color` | `ease` | `0.2s` | Active-rim reveal on selection (canonical UI card recipe). |
| `box-shadow` | `ease` | `0.2s` | Elevation step (--elev-1 → --elev-2 on hover/active). |
| `transform` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | `0.25s` | The "lift" cue — the overshoot IS the bounce. Only fires when a button opts into the lift via `.btn-lift` (static is the default — May 2026 inversion). Keep cubic-bezier here; this is the one curve where bounce reads as personality, not delay. |
| `rotate` | `ease` | `0.15s` | Stateful icon flips (chevron toggles, expand/collapse, sort direction). Listed in the base transition so per-component rules don't need to redeclare. |

**Why this exact set:** the base button shared rule used to declare `background 0.3s cubic-bezier(0.4, 0, 0.2, 1)` — Material's "Standard Easing." That curve holds at value 0 for the first ~30 % of the duration before accelerating. At 0.3 s that flat segment is roughly 90 ms of "nothing happens" → fast catch-up, perceived as delay-then-snap rather than smooth fade. `ease` (= `cubic-bezier(0.25, 0.1, 0.25, 1)`) starts moving immediately and decelerates throughout — the result is a fade you can FEEL animating across its whole duration, not a delay-then-pop.

**Authoring rules.**

- **Don't redeclare `transition:` on a child element to add one property.** `transition:` is a CSS shorthand — declaring it replaces every inherited transition. Hit this on the chevron toggle: `.ui-quote-card [data-action="quote-toggle"] { transition: rotate 0.15s ease; }` blew away the base button's `background 0.2s ease`, and the chevron's bg snapped instantly. The base rule now lists `rotate` natively so per-component scoped rules don't need to declare a transition at all — just set the `rotate:` value on the state selector. **See the `transition-shorthand-replaces` memory.**
- **For stateful icon rotations, use the standalone `rotate:` property** (not `transform: rotate()`). `rotate:` and `transform:` are independent properties — a hover `transform: translateY(-2px)` (from `.btn-lift`) doesn't collide with a state-toggled rotation, and a static button — the default — having no hover `transform` doesn't erase it either. **See the `rotate-property-for-state-toggles` memory.**
- **Button hover is static by default; `.btn-lift` opts in (May 2026 inversion):** a bare button's `:hover` paints only the bg / color / border wash — no `translateY`, no `box-shadow` glow. That's the right default for product UI (dense rows, toolbars, forms, cards, short-height containers where a lift clips or reads as fidgety); the bg wash alone communicates the affordance. Compose `.btn-lift` to add the hover lift + tightened cast-shadow glow — mostly a marketing affordance (landing-page hero CTAs), rarely wanted in product UI. `.btn-lift` is purely additive (per-variant `:hover` rules: `-2px` filled / outline / neutral, `-1px` secondary / tertiary; tertiary lifts with no glow). This **inverts** the prior contract — `.btn-static` used to be the opt-OUT of a lift-by-default and is retired (no aliases). Don't reach for `.btn-static`; reach for `.btn-lift` when you want the lift.

**Where this is enforced.**

- `styles.css` — the base `.btn-primary, .btn-secondary, ..., .btn-neutral` shared rule declares the canonical transition list (search `.btn-primary {` and read the multi-line `transition:` declaration + the explainer comment above it).
- `styles.css → .btn-lift` — per-variant `:hover` rules (each fully qualified `variant + modifier`) that add the lift `transform` + glow `box-shadow`, purely additive over the static base; outline selectors carry one extra class so their tighter glow wins on specificity.
- `design/components/actions.md → Modifier — .btn-lift` — written spec for the inverted contract.
- `design-system/index.html#btn-lift` — live catalog reference (static default row vs `.btn-lift` row).

**Don't `transform`-center an element that composes a button — the hover `transform: none` wipes it.** When an absolutely-positioned element is centered with `transform: translateY(-50%)` (or `translate(-50%, -50%)`) AND composes a `.btn-*` whose `:hover`/`:active` sets `transform: none` (`.btn-icon.btn-borderless` does, to suppress a lift), the hover rule overrides the centering transform and the element jumps by its offset on hover. Fix: re-assert the centering transform on `:hover`/`:active` (equal specificity → later-in-source wins), or center WITHOUT transform (`position: absolute; top: 0; bottom: 0; margin-block: auto` with a fixed height, or a flex parent). Hit June 2026 on the lightbox prev/next arrows (`.lightbox__nav`). Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → Transform-centered element jumps on hover`.

### Sub-panel scroll architecture — clip the panel, host the scroll in the body

When a glass panel has a head + a scrollable body (FAB sub-panels, side rails, drawers, popovers — anything where a title bar pins above scrolling content), **never use `position: sticky` on the head over the panel's own scroll**. The reflex is `overflow-y: auto` on the panel + `position: sticky` + transparent head, but on a translucent glass surface (`var(--surface-panel-glass)` resolves to ~70% alpha white) that fails in light mode: scrolled content shows straight through the head and overlaps the title. Adding `backdrop-filter` to the head doesn't fix it cleanly either — it stacks on top of the panel's own backdrop-filter and creates a visible step.

**The right shape — clip-and-host:**

```css
.fab-sub-panel {
  display: flex;
  flex-direction: column;
  overflow: hidden;          /* panel itself never scrolls */
}
.fab-sub-panel .screen-header--sticky-glass {
  position: relative;        /* normal flow, above the scroll context */
  flex: 0 0 auto;
  margin-bottom: 0;          /* breathing room moves to slot below */
}
.fab-sub-panel .body {
  flex: 1 1 auto;
  overflow-y: auto;          /* the body is the new scroller */
  padding-top: var(--space-12);  /* ← the gap that USED to live on the head */
}
```

Two non-obvious details:

1. **Move the head's `margin-bottom` to the slot's `padding-top`.** Same visual gap at scroll 0, but the gap is now INSIDE the scroll container — content passes through it and visually meets the head's border line on scroll instead of leaving a static white shelf below the line.
2. **The bleed pattern for the body.** If the panel has internal padding (e.g. `padding: 20px 20px 16px`), give the body matching negative `margin` + positive `padding` so the scrollbar sits in the panel's gutter instead of insetting cards. Reference: `.fab-sub-panel .fab-controls-slot` in `styles.css` (`margin: 0 calc(-1 * var(--space-20)) calc(-1 * var(--space-16))` + `padding: var(--space-12) var(--space-20) var(--space-16)`).

**Bug history.** Apr 2026 — `.panel-head--sticky-glass` (sticky + transparent bg) worked in dark mode but broke in light. **Don't re-introduce the sticky-over-own-scroll pattern on any future glass panel.** Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → Sub-panel scroll architecture`.

### Pane content — use the canonical primitives, never your own container

Inside a `.ds-doc-viewport-pane` (the engineer-facing tabs of every catalog card), use the canonical `.ds-doc-*` primitive class for every doc element. The primitives are **pane-aware**: standalone they carry full chrome (border, background, radius); inside a pane they drop the chrome and let the pane be the container. Without this, you get "container in a container" — pane border + codeblock border + double background.

| For | Use | Don't |
| --- | --- | --- |
| Code snippet | `<pre class="ds-doc-codeblock"><code>…</code></pre>` | `<div style="background:…;border-radius:12px;padding:16px">` |
| Table | `<div class="ds-doc-table-wrap"><table class="data-table">…</table></div>` | `<div style="border:1px solid …">` wrapper |
| Prose | `<p class="ds-doc-text">…</p>` | A bare `<p>` (no auto-styling for inline `<code>`) |
| List | `<ul class="ds-doc-list">` / `<ol class="ds-doc-list">` | A bare `<ul>` (no padding, no item spacing) |

**Color-code code blocks with eight canonical spans:** `<span class="comment|string|tag|attr|value|keyword|path|punct">`. Don't invent new ones. The eight cover HTML, CSS, JS, JSON, and shell — the formats this catalog actually shows.

The `.ds-doc-*` primitives live in `styles.css` (search `.ds-doc-codeblock`). They were promoted from a per-page inline `<style>` block in `design-system/index.html` so every DS page (catalog, dashboard, brand-guide, motion, AI, competitors) gets the same treatment. Full anatomy + token reference in `design.md §13 → Code blocks and tables inside panes`.

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

Spacing inside a `.ds-doc-viewport-pane` follows four tiers, each tighter than the next inwards. The system is the same growing-gap logic as the section-header rhythm cascade, applied to pane internals: tighter pairs read as one unit, looser pairs read as transitions between distinct things.

| Tier | Token | Pixel | Use when |
|---|---|---|---|
| **1.** Eyebrow → demo | `--space-12` | 12 | Always between any `.ds-doc-eyebrow` kicker and the demo immediately below. **Set globally** on the primitive's `margin-bottom` — don't author per-instance overrides. |
| **2.** Sibling examples in one demo | `--space-20` | 20 | When a demo stacks N example rows under a single eyebrow (or no eyebrow). Wrap them in `.ds-doc-stack` (or apply the class directly to the pane). |
| **3.** Eyebrow-labeled cells | `--space-32` | 32 | Cells that each carry their own eyebrow + demo. Use `.ds-doc-eyebrow-row` — adjacent rows auto-space at 32px and the row owns the internal eyebrow → demo gap. |
| **3+. Ruled cells (opt-in)** | `32 + 1px + 32` | 65 (hairline centred) | When variant demos are themselves small quiet tokens (eyebrows, kbd, status dots, swatches) and 32px gap can't separate them from the next cell's kicker. Apply `.ds-doc-viewport-pane--ruled` to the Preview pane. |
| **4.** Zones within one pane | `--space-64` | 64 | Between distinct content zones in a single pane (e.g., compact-zone → composition-zone, foundation-zone → variants-zone). Use `margin-top: var(--space-64)` on the second zone. |

**Pick the right primitive.**

- **Multiple labeled cells** (each "eyebrow + demo" repeats) → `.ds-doc-eyebrow-row` per cell. Don't put cells in a bare flex container — the primitive handles the rhythm.
- **Multiple unlabeled siblings** (4 `.field-inline` examples, 3 `.checkbox` rows, etc.) → `.ds-doc-stack` (or `--lg` / `--xl` modifier).
- **Eyebrow + a single demo** → bare `.ds-doc-eyebrow` + content. The 12px Tier 1 gap is the default.
- **Multi-zone pane** (compact + composition, etc.) → wrap each zone, separate with `margin-top: var(--space-64)`.
- **Multiple labeled cells where demos visually merge** (eyebrow family, kbd, status dots, small swatches) → keep `.ds-doc-eyebrow-row` per cell AND add `.ds-doc-viewport-pane--ruled` to the Preview pane itself. The modifier inserts a 1px hairline between adjacent cells with 32px breathing above and below. Same divider recipe as `.divider` and the inter-card hairline; same `> * + *` shape as `.ds-doc-card + .ds-doc-card`, one tier deeper.

**Authoring rules.**

- Never inline `style="margin-bottom: …"` on a `.ds-doc-eyebrow`. The 12px is global; if your demo needs more breathing, use a different tier (Stack a wrapper, or move to a labeled cell).
- Never stack 2+ direct sibling examples in a Preview pane without a flex/grid container. Wrap in `.ds-doc-stack` (or apply directly to the pane). Direct siblings without a gap touch each other — that's the bug this rule prevents.
- Ad-hoc inline `gap: var(--space-…)` on a custom flex wrapper is fine if the demo's grid shape is unique (e.g., the radio atom card's `auto-fit, minmax(280px, 1fr)` cell grid). For pure vertical stacking, prefer `.ds-doc-stack`.
- Don't use `.ds-doc-stack` for labeled cells — that's `.ds-doc-eyebrow-row`'s job. Mixing the two on the same children produces double-margin drift.
- `.ds-doc-viewport-pane--ruled` is **opt-in**, never default. Reach for it when **either** trigger is true: (a) **demos visually merge** — small quiet token demos (eyebrows, kbd, status dots, small swatches) where 32px gap can't separate the next cell's kicker from the previous cell's demo, OR (b) **count threshold** — the pane has **5+ top-level variant cells**, regardless of silhouette strength. Past 5 cells, even strong silhouettes (buttons, inputs, chips) start feeling like a flat list because the reader is paging through too many sections to track without a divider. The May 2026 sweep applied this to Buttons variants (8 cells), Avatar (7 cells), and Swatch (6 cells) — all had strong silhouettes but the cell count alone pushed them into ruled-row territory.

  - **Count TOP-LEVEL eyebrows, not nested ones.** Direct `<p class="ds-doc-eyebrow">` children of the Preview pane are cell labels; eyebrows nested inside demo grids (e.g., the Pane primitives card uses the eyebrow primitive AS DEMO CONTENT inside `.ds-doc-grid` cells) are uses of the primitive, not cell labels. A "kitchen-sink composition demo" can have 15+ nested eyebrows and still not be a ruled-rows candidate — refactoring would destroy the layout intent.
  - **Single-variant cards don't need it** (the `+` adjacent-sibling combinator needs two cells to render).
  - **When you reach for `--ruled`, drop the inline `style="margin:..."` overrides on every eyebrow.** The `.ds-doc-eyebrow-row` primitive handles spacing; inline margins fight the cell rhythm and make the markup repetitive.

**Flat zone-labels self-space at Tier 3 — no `.ds-doc-eyebrow-row` needed purely for spacing (May 2026).** A global rule gives any non-first bare `.ds-doc-eyebrow` that's a DIRECT child of a Preview pane a 32px (Tier-3) top margin: `.ds-doc-viewport-pane > * + .ds-doc-eyebrow { margin-top: var(--space-32); }`. This is the safety net for panes that stack bare eyebrow + demo pairs as flat siblings without the `.ds-doc-eyebrow-row` wrapper — previously those touched the preceding demo (0 gap), because the demo carries no bottom margin and the eyebrow no top margin. `.ds-doc-eyebrow-row` remains the canonical "labeled cells" primitive (it owns its own 32px AND the eyebrow→demo gap); the global fallback just means a bare flat zone-label no longer bumps. The avatar/icon-library grids override to Tier 4 (64px) via `.ds-doc-viewport-pane .ds-doc-avatar-grid + .ds-doc-eyebrow` / `.ds-doc-icon-grid + …` (specificity (0,3,0) beats the (0,2,0) fallback). First-child eyebrows and eyebrows nested inside `.ds-doc-eyebrow-row` (not direct children) are unaffected.

**Why a cascade?** Variants in one demo (Tier 2) should feel like siblings; cells in a labeled grid (Tier 3) should feel like neighbors; zones (Tier 4) should feel like sections. Same logic that makes the section-header cascade (12 → 20 → 64) read as a hierarchy. The ruled-rows opt-in (Tier 3+) lives alongside Tier 3 — same vocabulary, optional ornament for cards where the default gap can't carry the boundary.

**Bug history.** May 2026 — the eyebrow primitive's margin referenced an undefined token, so eyebrows collapsed onto their demos across every catalog card; fix bumped to `var(--space-12)` (Tier 1). Follow-up: the 9-variant Eyebrow card had variants visually merging despite the 32px gap; fix split it into three sibling cards AND introduced `.ds-doc-viewport-pane--ruled` (now the canonical answer for any future "small quiet demo" card). Later follow-up: the Avatars pane's 7 category zones (and Portraits' Personas/Team/Plate-options zones) bumped at 0px — grid-following eyebrows had no top margin; fix added the avatar/icon-grid → eyebrow Tier-4 (64px) rule, then a catalog-wide audit found 24 flat-zone bumps across ~16 cards, fixed with the global Tier-3 fallback above. Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → Pane breathing room`.

### Preview-pane variant grouping — group by axis, stack vertically, divide between zones

When a `.ds-doc-card` carries 3+ variants spanning **different concerns** — e.g., a Dropdown with variants for *panel content* (with icons / without icons / with sections), *trigger shape* (sizes / bare), and *panel positioning* (right-aligned) — group the variants **by axis** and stack the groups vertically with `.ds-doc-divider--loose` between them. Don't reach for a `display: grid` wrap-layout that mixes unrelated variants side-by-side just to save vertical space.

**Why.** Designers scan catalog cards by variant family. When a wrap-grid puts a size variant next to a content variant next to a positioning variant, the reader can't form a quick mental model of "what axes does this component vary on?" — they have to read every eyebrow to parse the grouping. Vertically-stacked zones with hairline dividers make the axes scannable: glance left, see three zones, immediately understand the component has three concerns.

**The canonical shape:**

```html
<div class="ds-doc-viewport-pane is-active" data-pane="preview">
  <p class="ds-doc-text">Intro / interaction note (optional).</p>

  <!-- Zone 1 — first concern (e.g., "Panel content") -->
  <div class="ds-doc-eyebrow-row"> <eyebrow + demo> </div>
  <div class="ds-doc-eyebrow-row"> <eyebrow + demo> </div>
  <div class="ds-doc-eyebrow-row"> <eyebrow + demo> </div>

  <hr class="ds-doc-divider ds-doc-divider--loose">

  <!-- Zone 2 — second concern (e.g., "Trigger shape") -->
  <div class="ds-doc-eyebrow-row"> <eyebrow + demo> </div>
  <div class="ds-doc-eyebrow-row"> <eyebrow + demo> </div>

  <hr class="ds-doc-divider ds-doc-divider--loose">

  <!-- Zone 3 — third concern (e.g., "Panel positioning") -->
  <div class="ds-doc-eyebrow-row"> <eyebrow + demo> </div>
</div>
```

**Spacing math** — every spacing token comes from the Pane Breathing Room cascade above; nothing is invented per-card:

| Spacing | Token | Where |
|---|---|---|
| Tier 1 — eyebrow → demo | `var(--space-12)` | Inside each `.ds-doc-eyebrow-row` (set globally on the primitive). |
| Tier 3 — between cells in one zone | `var(--space-32)` | Between adjacent `.ds-doc-eyebrow-row` siblings (auto-applied by the primitive's `+` selector). |
| Tier 4 — between zones | `32 + 1px + 32` ≈ 64 px | `.ds-doc-divider--loose` carries 32 px margin top + bottom; the hairline sits centered. The composed height equals the Tier-4 zone-break target. |

**When to apply.**

- **3+ variants across 2+ axes** → group by axis, stack with `--loose` dividers. (The Dropdown card with 6 variants on 3 axes is the canonical example.)
- **3+ variants on the same axis** → just stack as a flat list of `.ds-doc-eyebrow-row` cells, no dividers. The single concern doesn't need zone breaks. (Apply `.ds-doc-viewport-pane--ruled` if the variants are small-quiet-token demos that need hairline breathing — that's the existing Pane-breathing rule.)
- **1–2 variants** → a single `.ds-doc-eyebrow-row` (or a couple of them) is enough. No zones, no dividers.

**When NOT to apply.**

- **Wrap-grid layout (`display: grid; grid-template-columns: repeat(auto-fit, minmax(N, 1fr))`)** for variant cells. The visual saving (less vertical scroll) costs the reader the axis-grouping signal. Wrap-grids are appropriate for **same-axis** demos where the cells are siblings (e.g., a color swatch grid, an icon library cell grid), not for variant cells on different concerns.
- **Mixed grid + linear rows.** Don't put some variants in a grid and others in full-width rows below it — the reader can't tell if the grid cells and the linear rows are the same level or different levels.
- **A single variant per zone.** If a zone has only one variant, the zone break is unmotivated — drop the divider and let the cell sit alone. Zones imply "this is a family of related variants" — one-item zones violate the implication.

**The grouping question to ask before authoring.** "What axes does this component vary on?" Write them down. If the answer is one axis → flat stack. If 2+ axes → zones grouped by axis with dividers. The grouping should be **about the component**, not about visual density.

**Canonical implementation.** `design-system/index.html#dropdown-default` (Dropdown card) — 6 variants grouped into 3 zones: Panel content / Trigger shape / Panel positioning. May 2026 redesign retired a `repeat(auto-fit, minmax(220px, 1fr))` wrap-grid that mixed unrelated variants side-by-side. The Pane Breathing Room cascade (Tier 3 + Tier 4) drives every spacing decision.

**Migration note (May 2026).** The Dropdown card was the first card to apply this principle. When you encounter another multi-axis card using a wrap-grid (a few candidates likely exist in `#buttons`, `#cards`, `#chips`), the same migration recipe applies: group variants by axis, stack with `.ds-doc-eyebrow-row`, separate with `.ds-doc-divider--loose`. Don't bulk-migrate; do it as you touch each card for other reasons.

### Preview panes show live interactive behavior, not pre-opened snapshots

When authoring a catalog Preview pane for a component with **open / closed** or **expanded / collapsed** state — dropdowns, popovers, tooltips, accordions, modals, menus, anything with a discrete toggle — the demo should render **closed by default** and let the reader click the trigger to see the **real interactive behavior**. Don't reserve flow space for the open state with `min-height: 300px` + a `data-open` attribute that forces the panel visible at page load.

**Why.** Pre-opening was a pre-Popover-API convention: when panels were `position: absolute`, the catalog needed to show what the open state LOOKED like, and that meant rendering it statically in flow. Three problems:

1. **The frozen snapshot lies about positioning.** A pre-opened panel positioned via JS at runtime floats at a coordinate that's only correct for the moment the page loaded. Scroll the page, resize the viewport, and the panel sits at a stale position visually disconnected from its trigger.
2. **Vertical space cost is real.** A 6-variant card with `min-height: 300px` per demo eats ~1500 px of preview pane height — most of it empty white space below each anchored panel. Readers scroll past the same blankness six times.
3. **It hides the trigger affordance.** Designers learn the trigger by seeing it in its REST state, then clicking. A pre-opened panel obscures the trigger and the click affordance — the reader can't even practice the interaction the catalog is supposed to demonstrate.

**The right shape.**

- Closed triggers in the pane, one per variant cell.
- A single intro line at the top: *"Every trigger below is live — click to see the panel open"* (or equivalent for the component's interaction).
- The Popover API + JS positioning handles the open state correctly when the reader actually clicks — the panel renders in the browser's top-layer, anchored to the trigger via `getBoundingClientRect()`. See `floating-element-clipping → Popover-API` rule for the recipe.

**Exceptions** (rare — be honest about whether you're really in one):

- **Non-interactive demos** of the open state's CONTENT (item list, menu structure) where the panel chrome is incidental. Use a flat `.menu` or `.ds-doc-codeblock` rendering of the items, not the live `.dropdown__panel`. The catalog `#menu` card does this — shows `.menu` directly without wrapping in `.dropdown`.
- **Documenting the open state's visual recipe** (shadow, padding, surface) in the Colors or Anatomy pane. There, a single labeled "open state" snapshot is fine. The PREVIEW pane is what stays live.

**Migration note (May 2026).** The Dropdown card was authored with the pre-open convention (5× `<div class="dropdown" data-open>` + `min-height: 300px` reservations); the migration to closed-by-default reduced the preview pane from ~1500 px to ~775 px. When you find another catalog card using the old pre-open pattern (likely candidates: `#cmd-palette`, future popover / tooltip cards), apply the same retirement.

**Canonical implementation.** `design-system/index.html#dropdown-default` — six closed triggers across three zones, single intro line, click-to-test interaction.

### Do / Don't comparison illustrations — tie to the real examples, and vary only the taught variable

When a catalog card teaches a rule via a **Don't vs Do pair** (schematic diagram + the live-component examples below it — the nested-radius card is the canonical case), two principles govern the values used:

1. **Tie the schematic to the real examples — identical values across both surfaces.** The exaggerated diagram and the paired live `.ds-doc-well` / real-component cards must demonstrate the *same numbers*. A reader who sees the diagram say "inner 20, pad 12, outer 32" and then a live card built from different values (the old 28/8/20 vs 32/16/16 drift) can't connect the lesson to the implementation. Same sibling family as the [Catalog matches the live product] memory and the canonical-source-vs-rendering contract: the schematic is the *exaggerated rendering*, the live card is the *real rendering*, and both render the one rule. When you change the diagram's values, change the example cards' `--space-*` values **and** any Specs-table row that references "the diagram above" in the same edit.

2. **Make it a controlled comparison — hold constant everything except the variable being taught.** The Don't and Do cards should differ in exactly *one* dimension: the thing the rule is about. For the nested-radius card that meant holding the **outer frame (32) and padding (12) identical** across both, and varying **only the inner radius** (32 matched → wrong, 20 concentric → right). Mismatched frames (the earlier Don't 20/8/20 vs Do 32/12/20) read as two unrelated scenarios rather than one before/after — the eye can't isolate the lesson. Identical frames make the difference pop: same red/green outer arc + gridlines in both diagram cards, so the only thing that changes is the inner corner.

**The test before authoring a do/don't pair:** *"What single variable is this rule about? Is everything else held constant, and do the live examples use the exact diagram values?"* If a reader has to track more than one changing dimension, or the live card doesn't match the diagram's numbers, the comparison is muddier than it needs to be.

Note the inherent-subtlety caveat: at realistic component scale (radii 16–32, padding 8–12) some effects (e.g. the matched-radius uneven-gap "pinch") are genuinely quiet in the *live* cards — which is exactly why the exaggerated *schematic* earns its place above them. Don't drift the values away from the real rule to dramatize the live card; let the schematic do the dramatizing.

**Source of truth.** This section is canonical. Reference implementation: `design-system/foundations.html.html#nested-radius` (Don't 32/12/32 matched · Do 32/12/20 concentric, identical across diagram + example cards + Specs row).

### Pane eyebrows are short labels — never asides, footnotes, or captions

`.ds-doc-eyebrow` (and its `--accent` / `--ghost` variants) sits above a demo inside any pane (Preview / Specs / Anatomy). Its **only job is to label the demo below it**: "this next thing is X." A short noun phrase, maybe a class chip, maybe a brief purpose clause after a separator. **Never a sentence.** Never an explanation. Never a parenthetical spec callout. When the eyebrow grows into prose it reads as an aside-in-disguise — designers parse it as caption text instead of a kicker, the line wraps awkwardly, and the visual rhythm of "label → demo → label → demo" breaks down.

**Canonical shapes** (pick one — no others):

```
<purpose>                         Hero eyebrow
<class>                           .btn-primary
<purpose> · <class>               Compact alert · .alert--sm
<class> · <short purpose>         .swatch--lg · brand identity
<class> — <short purpose>         .eyebrow-code — developer / API sections
<noun> — <item> · <item> · …      Sizes — sm 32 · default 40 · lg 48 · xl 56
```

The last shape is the **enumeration form** — text after the em-dash contains 2+ `·` separators. Each item is itself a label and the reader scans the dots in parallel; these can run longer than prose-form eyebrows without becoming asides.

**The fix when you have more to say:** author a sibling `.ds-doc-text` below the eyebrow, NOT inside it.

```html
<!-- ✓ Correct -->
<p class="ds-doc-eyebrow">In-page · <code>.eyebrow-numbered</code></p>
<p class="ds-doc-text">Inside <code>.ds-doc-eyebrow</code>, the chip uses a
   louder override (1em) and the shared rule cancels parent uppercase + tracking.</p>

<!-- ✗ Drift — explanation crammed into the eyebrow -->
<p class="ds-doc-eyebrow">In-page · <code>.eyebrow-numbered</code> — inside an
   uppercase eyebrow, the chip uses a louder override (1em) and the shared rule
   cancels parent uppercase + tracking so the class name stays lowercase</p>
```

**Eyebrow smells** (any of these = trim):

- A **period (`.`)** mid-text that isn't part of a class name. Sentence boundary → drift.
- A **comma-laden clause** after the em-dash (`— A, B, with C`). Compound description → split.
- A **parenthetical spec** at the end (`(auto-fit ≥ 220 px)`, `(palette-independent)`). Specs belong in the Specs pane.
- A **conjunction** mid-eyebrow (`X with Y`, `X and Y`). Compound info → split.
- **More than ~90 chars** of rendered text. Validator W013 catches this mechanically.

**Enforcement.** `scripts/validate-doc.py → W013` warns on any `.ds-doc-eyebrow` whose rendered text (HTML entities decoded) exceeds 90 chars. Enumeration eyebrows pass the cap because each `·`-separated item is itself short. Full bad/good catalog in `.claude/skills/lifi-ds-docs/references/writing-style.md → "The pane eyebrow rule"`.

**Bug history.** May 2026 — the Code chip card crammed 190 chars of explanation INTO an eyebrow instead of a sibling `.ds-doc-text`; sweep fixed 6 more and validator W013 now prevents recurrence. Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → Pane eyebrows`.

### Caption modifier ladder — `--lead` / `--label` / `--centered`, never inline margin

Every `.ds-doc-caption` instance picks ONE of three modifiers based on placement; the base default covers "caption immediately follows a demo." **Never reach for `style="margin-top:..."`, `style="margin-bottom:..."`, or `style="text-align:..."` on a `.ds-doc-caption`** — the modifiers exist to make the intent explicit.

| Modifier | Recipe | Use when |
|---|---|---|
| (none — base default) | `margin-top: var(--space-8)` · left-aligned | Caption sits immediately under its demo with the tight default 8 px gap. |
| `.ds-doc-caption--lead` | `margin-top: var(--space-12)` | Caption follows a demo and the default 8 px feels cramped (bigger top gap). |
| `.ds-doc-caption--label` | `margin-top: 0` · `margin-bottom: var(--space-8)` | Caption acts as a LABEL ABOVE the demo it precedes — flips the spacing relationship. |
| `.ds-doc-caption--centered` | `margin: 0` · `text-align: center` | Figcaption under a centered demo **in a GAPPED figure / grid cell** — the cell's own `gap` provides the top spacing, which is why this modifier zeroes the margin. **Not** for a caption sitting directly under a demo with no gap provider (see the whitespace rule below). |

Three modifiers cover ~85 of the ~98 inline-styled captions on the catalog (40 label / 31 centered / 11 lead). Tail cases (margin-top 16+, mixed shorthand, grid-column overrides) stay inline by intent — the ladder is the 80% pattern, not 100%.

**Captions under a preview ALWAYS need top whitespace — never let one sit flush against the element above it.** This is the rule the whole ladder serves: a caption tight against its demo reads as part of the demo, not as an annotation. The base caption (8 px) and `--lead` (12 px) carry the gap; `--label` is the only one with no top gap *by design* (it sits ABOVE its demo). The trap is `--centered`: it zeroes the margin on the assumption that a gapped figure/grid cell provides the spacing — so when a centered caption is a **direct child of a non-gapped container** (most commonly `.ds-doc-eyebrow-row`, where the framed demo cell and the caption are siblings with no gap between them), `margin:0` leaves it tight.

- **The fix is structural, not inline.** `.ds-doc-eyebrow-row` now owns the demo→caption gap for centered captions (`.ds-doc-eyebrow-row > .ds-doc-caption--centered { margin-top: var(--space-8); }`) — the same "the row owns the rhythm" principle that already spaces the eyebrow→demo gap. So a centered caption inside an eyebrow-row breathes automatically; you don't add anything.
- **In any OTHER non-gapped container**, either give the parent a `gap` (so `--centered`'s `margin:0` is correct) or use the base caption / `--lead` (which carry their own top gap). Never reach for `style="margin-top:…"` to patch the tightness — that's the inline override the ladder exists to prevent.
- **Before shipping a caption under a preview, eyeball the gap.** If the caption hugs the demo with no breathing room, the container isn't providing the gap — fix it at the container/modifier level.

**Authoring rules.**

- **Don't suppress the typography with inline `style`.** If you need different rhythm than the three modifiers provide, the demo composition likely wants restructuring (e.g., wrap in `.ds-doc-stack` or `.ds-doc-eyebrow-row`), not a caption with a custom margin.
- **Don't reach for `style="text-align:center"`.** Use `.ds-doc-caption--centered`. Same for left-alignment under a label — use `--label`.
- **The modifier names describe SHAPE, not value.** `--lead` says "this caption leads INTO the next thing" (which means bigger top gap). `--label` says "this caption labels what follows." `--centered` says "this caption sits under a centered demo." Future contributors should reach by intent, not by trying to match a margin value.

**Where this is enforced.**

- `styles.css` — search `.ds-doc-caption--lead` for the four-rule block. Comment above documents the May 2026 v16 promotion that retired ~85 inline overrides. The eyebrow-row demo→caption gap lives next to the `.ds-doc-eyebrow-row` rules (search `.ds-doc-caption--centered` inside the eyebrow-row block).
- `design-system/docs.html.html#ds-doc-caption` — live catalog reference with side-by-side previews of all three modifiers.

### Always ask about visual previews when adding components

When the user asks to add a new component to the design system (new CSS class in `styles.css`, new entry in `design.md`, or anything that creates a reusable UI primitive), **ask explicitly whether to also add a visual preview to `design-system.html`** before finishing the task.

Ask once, clearly, using phrasing like:

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

If the answer is **yes**, add to `design-system.html`:

- A new `.comp-section` with an `id` matching the component's kebab-case class name (so it's deep-linkable, e.g. `#chips`, `#accent-card`)
- A live preview row showing **every variant at realistic size** using the real CSS classes (not mock markup)
- A variants table (class → visual → usage) using `.data-table` to match other catalog entries
- A spec/dimensions table when appropriate
- A short usage paragraph covering when to use, when not to use, and the relationship to sibling components

Place the new section inside the nearest semantic parent (chips inside `#chips`, card variants inside `#cards`, etc.).

If the answer is **no**, proceed with only the CSS + `design.md` spec. A preview can be added later.

The full rule + rationale lives in `design.md §13 — Contributing to the Design System`. Keep that section and this file in sync if either evolves.

### When promoting in-product code to the catalog — match the live rendering, never rely on defaults

When the user asks to document a component that **already exists in product code but isn't yet in the catalog** (the canonical case: the gas-fee + time-estimate metric pills lived inside `.ui-quote-card`'s footer for months before they were unified into `.ui-quote-metric` and promoted to `#ui-quote-metric` in the catalog), the catalog rendering MUST visually match what the live product surfaces show — pixel-for-pixel where possible. Reach for **measurement**, not assumption. The catalog is the designer-scanning contract; if it lies about size, weight, color, or spacing, designers author against fiction.

**The trap that recurs.** A primitive's typography (font-size, font-weight, color, line-height, letter-spacing) is often **inherited from the consumer context**, not pinned on the primitive itself. The primitive looks right in product because its consumer pins the right ambient — but the catalog has its own ambient (typically the page default, ~16 px), so the primitive renders at a different size in the catalog than it does in production. Same for color (consumer surface tints inherit through), spacing (consumer paddings cascade in), etc.

The metric-pill case (May 2026) is the canonical example:
- Live big-card footer pins `font-size: var(--text-caption)` (12 px) → metric pill value inherits 12 px
- Live mini-card pins `font-size: var(--text-sm)` (14 px) → metric pill value inherits 14 px
- Catalog `.ds-doc-viewport-pane` has no explicit pin → metric pill value inherits ~16 px (page default)

Three different sizes for "the same" primitive. The catalog preview was the LARGEST because the docs container has the loosest ambient — a designer scanning the catalog would think the metric pill is much bigger than it actually is in product.

**The contract — measure first, pin on the primitive.** Before authoring the catalog card for any existing in-product component:

1. **Inspect the live rendering's computed styles** for every typography + chrome axis (`font-size`, `font-weight`, `line-height`, `letter-spacing`, `color`, `background`, `padding`, `gap`) in **every production context** the primitive appears. Use `preview_inspect`, browser devtools, or `getComputedStyle()`.
2. **If values converge across consumers**, pin them on the primitive (`.foo { font-size: var(--text-caption); }`) so the catalog inherits the same. The catalog's ambient won't override the explicit pin. This is the right answer ~90 % of the time.
3. **If values diverge intentionally across consumers** (rare; usually drift masquerading as intent), surface the divergence to the user FIRST. Don't pick silently. The promotion likely needs to converge values too — that's an additional decision the user should make, not a guess for you to bake in.
4. **Document the pin in the catalog Anatomy table.** A typography or chrome property pinned on the primitive (instead of inherited from context) is a meaningful API decision; future contributors need to know it's intentional. A line like *"font-size: var(--text-caption) — pinned on the primitive (NOT inherited) so the pill reads identically in every context"* is the canonical phrasing.

**Don't rely on "the parent will style it."** That assumption fails the moment the primitive lands in a context with a different ambient — catalog, modal, sidebar, future product surface. Pin on the primitive itself; consumer context still owns its OWN typography (headings, body, labels) without touching the primitive. The pin is downward-overriding: consumer-level ambient styles the rest of the row; primitive-level pin holds the metric value.

**Where this rule was extracted.** May 2026 — the `.ui-quote-metric` unification (collapsing `.ui-quote-card__footer-cell` + `.ui-quote-mini-card__cost` into one shared primitive) initially shipped without pinning typography on the wrapper. Three contexts rendered three different sizes (catalog 16 px, big card 12 px, mini card 14 px). The user flagged the catalog ↔ playground mismatch; the fix was to add `font-size: var(--text-caption)` + `line-height: var(--lh-caption)` to `.ui-quote-metric` and document the pin in the catalog's Anatomy + Rules panes. The lesson: when promoting in-product code, **measure the live before authoring the catalog**, not after a user catches the drift.

**Sibling rules.** This pairs with [Reusable primitives must be useful standalone — the catalog Preview is the test] (above, about *content completeness* of the catalog preview) and [Always ask about visual previews when adding components] (above, about *whether* to add a preview at all). Together: when adding a preview, the preview must be content-complete AND visually-matched to the live product.

**Sub-trap: body-state-gated rules** (June 2026). The "measure first, pin on the primitive" path solves typography / chrome that *diverges* across consumer ambients. It does NOT solve the case where a primitive's spacing or visibility is gated on **body-state** — `body[data-layout="dual"]:is([data-widget-mode="bridge"], [data-widget-mode="swap"]) .foo { margin-bottom: var(--space-12) }` shape rules. On the catalog page the body lacks `data-layout` / `data-widget-mode`, so the rule never fires; the catalog preview renders without the spacing the live consumer takes for granted. The computed styles on the primitive itself look identical between catalog and live (no margins set on the element); the diff lives only in the body-anchored parent rule, which is what `getComputedStyle()` reports nothing about.

**The audit, before authoring any catalog preview of a widget-surface primitive:**

1. **Grep for body-state anchors targeting the primitive.** `grep "body\[data-.*\].*\.foo" swap.css styles.css` (or the matched-rules walk: `Array.from(document.styleSheets).flatMap(s => Array.from(s.cssRules)).filter(r => r.selectorText && el.matches(r.selectorText) && /body\[data-/.test(r.selectorText))`). Any rule with a `body[data-*]` anchor will not fire on the catalog.
2. **Compare rendered geometry, not computed styles.** `getBoundingClientRect()` on the primitive AND its siblings, computing inter-sibling gaps (`next.top - prev.bottom`). The catalog-vs-live diff in that gap is exactly the body-gated value.
3. **Inline ONLY the visual values the gate provides** on the catalog markup — `style="margin-bottom: var(--space-12);"` etc. — with a comment explaining the body-gate inversion. Don't try to set `data-layout` / `data-widget-mode` on the catalog body to fire the production rule; body-state on a catalog page bleeds into every other card.

**Sub-trap: JS-only `data-*-hook` attributes carry CSS `display: none` defaults**. When a body-gated rule keys off an attribute that's also a JS hook (`[data-chain-shortcuts]`, `[data-quote-list-host]`, etc.), the primitive often has a base `[data-foo] { display: none; }` rule that production overrides via body-state — the attribute is the JS hook AND the visibility default. Copying the production markup into a static catalog demo inherits the wrong half of the gate: the `display: none` default fires (no body-state needed), the body-gated override doesn't (body-state missing), and the demo goes blank. Two fixes: (a) **strip the JS-only attribute from catalog markup** — it serves no purpose without the JS, and reproducing only the visual values inline is cleaner; (b) override BOTH the default AND the production-active values inline (`style="display: grid; margin-bottom: var(--space-12);"`). Option (a) is the right default for documentation.

**Bug history.** June 2026 — `.ui-quick-settings` + `.ui-token-picker` catalog cards rendered blank / mis-spaced because production CSS was body-state-gated (it never fires on the catalog); fixed by inlining only the gated visual values. Full diagnostic + the matched-rules walk technique: `.claude/skills/lifi-ds-docs/references/bug-history.md → Catalog preview drifts from live when production CSS is body-state-gated`.

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

Before shipping any title sized at `--text-display` (56px) or larger, or any title that clamps its `font-size` up to one of those tokens, check both:

1. **Never set `line-height: var(--lh-display)` or `--lh-hero`** on that title. Those tokens are fixed-pixel values (64px / 80px) — paired with a clamped display font they produce a sub-1.0 ratio and lines overlap. Use a **unitless literal** instead:
   - `--text-display` (56px) → `line-height: 1.18`
   - `--text-hero` (80px), multi-line → `line-height: 1.1`–`1.18`
   - Mega sizes (112px+) → `0.94`–`1.0` is fine (canvas-fill compositions)

2. **Every gradient `<span>` inside display type needs this escape-descender block:**
   ```css
   .title .grad {
     background: var(--grad-brand-text);
     -webkit-background-clip: text; -webkit-text-fill-color: transparent;
     background-clip: text;
     display: inline-block;
     padding: 0.04em 0.08em 0.04em 0;
     line-height: inherit;
   }
   ```
   Without `display: inline-block` + vertical padding, `-webkit-background-clip: text` slices descenders on `g`/`y`/`p` — most visibly on words like `digital`, `Dashboard`, `typography`.

Full rationale + reference implementations in `design.md §03 — Typography → Display line-height & gradient descenders`. Already applied to `doc-shared.css .header-title`, `page.css .hero h1`, `styles.css .uc-title`, `plans.html .plans-title`, `sign-in.html .signin-hero-title` — use any of those as a template.

Applies system-wide: website heroes **and** any skill-rendered artifact (decks, proposals, reports from `lifi-design-press` / `lifi-investor-deck`). Fix at the CSS source; downstream consumers inherit.

### Document archetypes — the `.doc-*` primitive family

Proposals, reports, decks, and any other artifact-style HTML deliverable lives under `artifacts/<type>/` and uses the **`.doc-*` primitive family** rather than the website's `.uc-*` / marketing primitives. Documents are focused, single-recipient artifacts; they get their own vocabulary so they can evolve independently of the website surfaces without dragging marketing chrome along.

**Reference archetypes.** Three canonical `.doc-*` shapes. *(The original example files — the Anchorage proposals — were removed June 2026: confidential partner deliverables, retained only in a local off-repo export. The shapes below stand on their own; the `.doc-*` bug-history narratives further down still name those files as historical archaeology.)*

- **Single-section proposal** — flat `.doc-row` list, no page wrappers.
- **Multi-page partnership proposal** — `.doc-page` page-break wrappers, stats row, product cards, flow diagram, ordered next-steps list.
- **Self-contained portable export** — all CSS inlined, all SVGs embedded, no `styles.css` dependency.

When authoring a new document, start from whichever template is closest to your content shape, then add or remove `.doc-*` primitives as needed.

**The primitive family** (all page-local CSS inside each `.doc-*` HTML — NOT promoted to `styles.css`, intentionally per-archetype):

| Primitive | Role |
|---|---|
| `.doc` | Document container — `max-width: 960px`, padding for print + screen |
| `.doc-page` | Page-break wrapper for multi-page docs — `break-after: page` in print |
| `.doc-header` + `.doc-lockup` + `.doc-meta` | Header row: brand lockup left, single-line meta right (`Confidential · <Type> · <Month Year>`). See **Canonical Header & Footer framework** below. |
| `.doc-rule` | Horizontal hairline under the header |
| `.doc-hero` + `.doc-title` + `.doc-dek` | Hero zone — eyebrow + 52px title + 1.15rem dek |
| `.doc-row` + `.doc-row__lead` + `.doc-row__body` | 2-col grid — narrow lead (240–260px) + wide content. Hairline separator between rows. |
| `.doc-list` + `.doc-list--ordered` | Em-bullet list (`•` markers) + ordered variant with `01/02/03` accent sans counters (the sans default — Geist Regular — at the paired size; migrated from mono June 2026, mono is chrome-only) |
| `.doc-price` + `.doc-price__amount` | Pull-quote callout with accent left-bar (for commercial offers, key numbers) |
| `.doc-stats` + `.doc-stat__num` + `.doc-stat__label` | 3-stat row with hairlines top/bottom (hero-tier numbers) |
| `.doc-product-grid` + `.doc-product-card` | 2-card highlight pair with accent left-bar |
| `.doc-flow` + `.doc-flow__node` + `.doc-flow__role` | Horizontal flow diagram (4 nodes + auto `→` arrows between adjacent ones) |
| `.doc-partners` | Partner-distribution note (logos cloud prose + emphasized hosts) |
| `.doc-signoff` | Quiet document footer — entity (`LI.FI Services GmbH`) left + doc-marker (`© YYYY LI.FI` for HTML / `N / N` page number for paginated Figma) right. **Confidential now lives in the header meta, not the footer.** See **Canonical Header & Footer framework** below. |

**The mandatory print recipe** — every `.doc-*` document includes this `@media print` block. The three rules below compose; all are required.

```css
/* 1 — A4 portrait with comfortable document margins */
@page { size: A4; margin: 18mm 16mm; }

@media print {
  /* 2 — kill the site-wide ambient halo (body::before/::after in styles.css).
        Position:fixed pseudo-elements clip to the first page's printable box
        in Chrome's PDF rasterization, stamping a hard rectangle inside the
        @page margin. */
  html, body { background: #fff; }
  body::before, body::after { display: none !important; content: none !important; }

  /* 3 — zero out screen-only document width */
  .doc { max-width: none; padding: 0; }

  /* 4 — three-lever pagination fix: keep .doc-signoff on the trailing
        content sheet instead of orphaning it on its own page */
  .doc-row + .doc-row { margin-top: 32px; padding-top: 32px; }
  .doc-signoff {
    margin-top: 24px;
    padding-top: 16px;
    break-before: avoid-page;
  }

  /* 5 — keep grouped content together where breaks would hurt */
  .doc-row, .doc-price, .doc-flow__node, .doc-product-card, .doc-signoff {
    break-inside: avoid;
  }

  /* 6 — multi-page docs only: each .doc-page is its own physical sheet */
  .doc-page { break-after: page; }
  .doc-page:last-of-type { break-after: auto; }
  .doc-page + .doc-page {
    margin-top: 0; padding-top: 0; border-top: 0;
  }
}
```

**Why page-local, not promoted to `styles.css`.** The `.doc-*` family is tier-3 (artifact-surface) per the class-naming convention — narrower than `.uc-*` (use-case page primitives) and entirely outside the product/marketing/catalog tiers. Promoting these to `styles.css` would burden every page on the website with definitions that only apply to a handful of artifact files in `artifacts/`. Keeping them page-local means each document can evolve its layout glue independently while still consuming universal DS tokens (`--space-*`, `--text-*`, `--accent-*`, `--surface-*`, `--text-*` colors).

**Document defaults — what NOT to include.** Per [[documents-strip-site-chrome]] in memory: no `navbar-site`, no `footer` link grid, no theme toggle, no Next-Steps CTA blocks pointing at marketing pages. Pin `data-theme="light"` directly on the `<html>` element. Documents are read once by a specific recipient; they shouldn't compete with the deliverable's content.

**Source-to-DS translation rule.** When given a source PDF/Figma to rebuild as a `.doc-*` document, the source's visual design is discarded — only structure + content carry over. The source's typography, color palette, decorative motifs, and stylized eyebrow tokens (`solver_marketplace`, `//for_anchorage`) all get translated into standard DS vocabulary. Per [[strip-source-pdf-visual-design]] in memory.

#### Canonical Header & Footer framework — every proposal, Figma AND HTML

All proposal documents — Figma frames and `.doc-*` HTML alike — share ONE header/footer framework so the chrome reads identically across every proposal (Anchorage, Kraken, future). **Document skills are layered (June 2026):** the shared build canon (geometry, ladder, visual language, AL discipline, diagram language, the `use_figma` lessons, print pipeline) lives in the **`lifi-doc-foundation`** skill (`.claude/skills/lifi-doc-foundation/SKILL.md`); the typed skills carry only type-specific judgment — **`lifi-proposals`** (faithfulness, partner lockups, logo grids, this chrome's proposal content) and **`lifi-prd`** (internal PRDs: status chip, wayfinder footer, marker system). New document types (decks, reports) add a typed skill on the foundation, never a fork of it. This section is the canonical chrome spec.

**Header** — a `Header` group/frame containing three parts:

| Part | Content | Style |
|---|---|---|
| Lockup (left) | `LI.FI logo` `×` `<Partner> logo` | LI.FI mark + partner wordmark **32px tall** (scaled to fit the band) + Geist Regular 24px `×` in grey `#818596`, all centred in the header band |
| Meta (right) | **single line** `Confidential · <Proposal Type> · <Month Year>` | Geist Mono 14px, grey `#5C6070`, right-aligned (right edge at the content edge), `·` (middot) separators |
| Rule | full-content-width hairline beneath the lockup row | `#e5e7eb`, 1px |

**Footer** — a `Footer` group/frame:

| Part | Content | Style |
|---|---|---|
| Rule | full-content-width hairline above the footer text | `#e5e7eb`, 1px |
| Entity (left) | `LI.FI Services GmbH` | Geist Mono 14px, grey `#5C6070` |
| Doc-marker (right) | paginated Figma → page number `N / N` (ink `#030305`); single-signoff HTML → `© YYYY LI.FI` (grey) | Geist Mono 14px, right-aligned |

**The load-bearing rule: `Confidential` lives in the HEADER meta, never the footer.** Putting it in both is the duplication this framework retired (May 2026). The footer carries entity + doc-marker only.

**Match the chrome to the AUDIENCE — strip internal-only signals before any external send (June 2026).** Read the header/footer through an outside recipient's eyes before a document leaves the building, and remove anything that mis-frames it externally: **(a) a finality status chip** — a green `Complete` (or any "done/closed") chip reads to a partner as "this is the final, settled result," which mis-frames a routine drill, test, or interim deliverable; for an external send **remove the status chip** or relabel it to the neutral document TYPE (`Exercise` / `Drill`) in a grey, non-success tone. **(b) an internal classification** — `Internal` in the meta line is wrong the moment the doc is shared; use the audience-appropriate grade (`Confidential` for partners) or drop the token entirely. **(c) draft/version tags** (`v1.0`) the user may not want surfaced externally — confirm. This is a CONTENT call (what the chip/label SAYS), separate from the geometry; don't invent a replacement label — use the source's own vocabulary (the report's title gave `Exercise`) or ask. (June 2026 — the Emergency Pause report shipped with a green `Complete` chip + `Internal · v1.0 · May 2026` meta on all 8 page headers; both were misreads for an external-partner + auditor send. Per the user's call: chip removed entirely, meta reduced to `May 2026`.)

**Preserve source links as REAL clickable hyperlinks — bake, don't just colour (June 2026).** Whenever the source document has a link (a URL, a `github.com/…` reference, a "Learn more: domain.com/…" line), the rebuild must carry the destination THROUGH so the accent-styled anchor is actually clickable — accent-blue text alone is cosmetic and dead. In Figma: `node.setRangeHyperlink(start, end, { type: 'URL', value: '<full url>' })` on the EXACT styled substring (assert the substring before applying so the link lands on the right range; the section-number / eyebrow accents are decorative, not links). In `.doc-*` HTML: a real `<a href>`. If a link's destination URL isn't in the source, surface it to the user rather than guessing. Make this part of the build, not a post-hoc pass. Build mechanics + the `getStyledTextSegments` span-detection recipe: `lifi-doc-foundation → build lesson 44`.

**Chrome sizing — bumped May 2026 for readability.** Header/footer chrome originally shipped small (logos 24px tall, `×` 18px, all mono 11px) — too quiet against the body type once that was bumped. The current standard, applied to every proposal page: **lockup logos 32px tall · `×` 24px · meta / entity / page-number mono 14px.** When cloning a prior proposal's chrome, scale the cloned logos to 32px and bump the mono text to 14px (cloned chrome carries hardcoded sizes that the `Proposal/` text-style ladder does NOT govern — see below). Future proposals inherit these sizes.

**Figma frame specs** (page is 1224×1584, content margin 96, content width 1032). Chrome sits directly on each page frame (not a sub-frame); page-absolute child coords:
- **Header** — LI.FI logo `(96, 50)` **32px tall** (~88 wide); `×` Geist Regular 24px at `(96 + lifiWidth + 14, 54)`; partner logo **32px tall** (Anchorage ~141 wide) at `(× right edge + 14, 50)`; meta auto-width Geist Mono 14px, right-edge anchored to x=1128 at `y=52`; header rule full content-width at `y=120`. Re-space the lockup after scaling — bigger logos collide at the old gaps; use a ~14px gap between logo · `×` · partner.
- **Footer** — footer rule at `y=1496`; entity `(96, 1524)`; page-number right-edge anchored to x=1128 at `y=1524`. Footer text Geist Mono 14px.
- **Chrome text is NOT styled by the `Proposal/` text-style ladder** — it's cloned with hardcoded sizes, so a global ladder edit won't resize it. Set header/footer sizes explicitly per the table above.
- Reference: the Anchorage proposal (page `Anchorage Digital - Final`) carries this chrome on all 4 frames.

**HTML `.doc-*` mapping:**
- Header → `.doc-header` > `.doc-lockup` (logo + `.doc-lockup__x` + `.doc-lockup__partner`) and `.doc-meta` containing ONE eyebrow line `Confidential · <Type> · <Month Year>`, then `.doc-rule`.
- Footer → `.doc-signoff` with entity `LI.FI Services GmbH` left + `© YYYY LI.FI` right.
- Cross-media note: HTML `.eyebrow-word` renders uppercase (its tracked-eyebrow convention); Figma meta is grey Title Case. The *framework* (parts, order, Confidential-in-header) is what's canonical — exact casing follows each medium's eyebrow convention.

**Source of truth.** This section is canonical for content/structure; the shared Figma build steps + gotchas live in `lifi-doc-foundation`, and the per-type chrome content lives in `lifi-proposals` (partner lockup, `Confidential` meta) and `lifi-prd` (status chip, wayfinder footer). When the framework evolves, update this section AND the affected skills in the same change.

**Bug history.** The `.doc-*` family was extracted from three sequential authoring passes in May 2026:
- `proposal-anchorage.html` (Porto Wallet) — first document, established `.doc` / `.doc-header` / `.doc-row` / `.doc-list` / `.doc-price` / `.doc-signoff`.
- `proposal-anchorage-partnership.html` — added `.doc-page`, `.doc-stats`, `.doc-product-grid`, `.doc-product-card`, `.doc-flow`, `.doc-flow__node`, `.doc-list--ordered`, `.doc-partners`, `.doc-row__body`.
- `proposal-anchorage-standalone.html` — first portable export pattern: all CSS inlined (resolved tokens to literals), all SVGs embedded, no external CSS dependency. Only the Google Fonts link remains as a (graceful-fallback) network call.

Three uses crossed the pattern-extraction threshold and the family is now documented as a recognized archetype. Future document types (decks under `artifacts/decks/`, presentations under `artifacts/presentations/`) may extend the family or develop their own `.deck-*` / `.pres-*` siblings — to be decided per actual content shape, not pre-emptively.

#### Proposal typography — Figma text styles map to the `--text-*` ladder

Every Figma proposal is built on a `Proposal/` text-style ladder (registered as real Figma text styles in the Proposals file, so they show in the styles panel). **Each style pairs a `--text-*` size tier with its matching `--lh-*` line-height tier** (`styles.css :root` — search `--text-hero`): no in-between font-sizes (15 / 22 / 28 / 60) and no ad-hoc line-heights (30 / 26 / 19) — the line-height is always the `--lh-*` the size tier pairs with. The ladder is the single lever for global type changes — edit a style once and every page follows.

The `--text-*` size tiers (px): hero 80 · display 56 · h1 48 · h2-lg 36 · h2 32 · h3 24 · body-xl 20 · body-lg 18 · body 16 · sm 14 · caption 12 · micro 10.
The paired `--lh-*` line-heights (px): lh-hero 80 · lh-display 64 · lh-h1 56 · lh-h2 40 · lh-h3 32 · lh-body-xl 32 · lh-body-lg 28 · lh-body 24 · lh-sm 20 · lh-caption 16.

| Style | Font · size / line-height | tier (`--text` / `--lh`) | Used for |
|---|---|---|---|
| `Proposal/Display` | Geist Bold · 56 / 64 | display / lh-display | page titles / heroes |
| `Proposal/Stat` | Geist Bold · 56 / 64 | display / lh-display | feature stat numbers (`~$85B`) |
| `Proposal/Heading` | Geist Bold · 36 / 40 | h2-lg / lh-h2 | section headings (`Flexible SLAs.`) |
| `Proposal/Subhead` | Geist Bold · 24 / 32 | h3 / lh-h3 | column / sub-headlines |
| `Proposal/Counter` | Geist Mono · 24 / 32 | h3 / lh-h3 | next-steps counters (`01`/`02`/`03`) |
| `Proposal/Box Title` | Geist Bold · 20 / 32 | body-xl / lh-body-xl | diagram box titles |
| `Proposal/Body Large` | Geist Regular · 24 / 32 | h3 / lh-h3 | intro / lede paragraphs, diagram captions (kept in lockstep with Counter — same size) |
| `Proposal/Body` | Geist Regular · 20 / 32 | body-xl / lh-body-xl | body lists, next-steps |
| `Proposal/Caption` | Geist Regular · 18 / 28 | body-lg / lh-body-lg | stat labels |
| `Proposal/Box Body` | Geist Regular · 16 / 24 | body / lh-body | diagram box body copy |
| `Proposal/Eyebrow` | Geist Mono · 16 / 24 | body / lh-body | section eyebrows (mono accent) |
| `Proposal/Mono` | Geist Mono · 14 / 20 | sm / lh-sm | chips, diagram mono labels |

**Rules.**
- **No off-ladder font-sizes.** When authoring or adjusting a proposal style, its size must be a `--text-*` tier. If a value feels "between," pick the nearest tier — never invent 15 / 22 / 28 / 60 / 64. Sharing a tier across styles is fine (they differ by font / weight / role).
- **Line-height pairs with the size — no ad-hoc values.** Each style's line-height is the `--lh-*` token that matches its size tier (display→lh-display, h2-lg→lh-h2, h3→lh-h3, body-xl→lh-body-xl, body-lg→lh-body-lg, body→lh-body, sm→lh-sm). Never hand-set a line-height to a non-tier value (30 / 26 / 19). Same-size styles share the same pair — e.g. **Counter and Body Large are both h3 24/32 and kept in lockstep** (they pair in layout — a numbered counter beside its lede; change one, change both).
- **Edit the style, not the node.** Global type changes flow through the style (one edit → all pages). Per-node size overrides are drift.
- **Bold leads** in `Proposal/Body` items use a range `setRangeFontName` → Geist Bold; that overrides family/weight only, so the size still comes from the style and a ladder edit carries through.
- **Header/footer chrome is NOT on this ladder.** The lockup logos / `×` / meta / entity / page-number carry hardcoded sizes (see the Header/Footer framework above) — editing a `Proposal/` style does not resize them; set chrome sizes explicitly.

**Bug history.** May 2026 — the proposal styles were first bumped for readability into in-between values (Eyebrow 15, Body Large 22, Subhead 28, Stat 60, Display 64). A follow-up pass snapped every font-size back onto a `--text-*` tier. A **second** pass fixed the *line-heights*, which had stayed ad-hoc (62 / 44 / 30 / 26 / 19) instead of the paired `--lh-*` tokens, and snapped Counter's drifted 22 → 24 (h3): every style is now a clean `--text` size + paired `--lh` line-height. Going forward, all proposals inherit this ladder + pairing. **2026-05-29** — `Proposal/Body` bumped one tier by design decision (body-lg 18/28 → **body-xl 20/32**); it now shares the body-xl tier with `Proposal/Box Title` (differ by weight — Body Regular vs Box Title Bold), which the "sharing a tier is fine" rule explicitly allows. Edited as the shared Figma style, so it cascades to every proposal page on the `Anchorage Digital - Final` page (and any future proposal in the file). **June 2026 — brand flip: the ladder re-fonted Figtree → Geist + JetBrains Mono → Geist Mono, and the doc palette moved cobalt `#2335CE` → Sapphire `#405CCF` (action/eyebrow/index), ink → `#030305`, meta grey → Slate `#5C6070`, faint → `#818596`** — the live LI.FI 1.0 **light-mode** DS values (`styles.css [data-theme="light"]`, mirrored in `design/tokens/colors.json`). Figma can't read CSS tokens, so these literals ARE the rendering; re-pull from the manifest on the next brand change rather than hand-maintaining. Existing docs are re-fonted via the validated sweep (re-font the `Proposal/*` styles + a node/range font pass + a literal-fill colour remap — the Emergency-Pause re-export). Full procedure in `lifi-doc-foundation`.

### Proposal & document footer pagination — never orphan the sign-off on its own sheet

Any HTML proposal or document that's exported to PDF — every `.doc-*` patterned document — must **never** end with the sign-off / footer alone on a trailing physical sheet. The footer is short (~70-100 px); the preceding content typically ends just past the printable height, so without intervention the footer overflows to its own sheet and the reader's last impression of the deliverable is a blank page with one quiet line at the top.

**The fix — three levers in `@media print`**, applied together:

```css
@media print {
  /* 1 — tighten the inter-row rhythm on the final logical page so
        the trailing content ends earlier, leaving headroom for the
        sign-off. Saves ~32 px per gap × N gaps. */
  .doc-row + .doc-row { margin-top: 32px; padding-top: 32px; }

  /* 2 — reduce the sign-off's pre-margin. Default margin-top of 80 px
        plus padding-top of 32 px is screen rhythm; in print, 24 + 16
        keeps the visual break without burning the headroom you just
        clawed back. Saves ~72 px. */
  .doc-signoff {
    margin-top: 24px;
    padding-top: 16px;
    break-before: avoid-page;   /* 3 — keep with previous content */
  }
}
```

`break-before: avoid-page` is the most important lever — it tells Chrome's PDF rasterizer to actively try to keep the sign-off on the same sheet as the preceding content. The margin trims give the browser enough vertical headroom to honor that hint. Either alone usually isn't sufficient; both together reliably claw back the ~120-200 px needed to fit the sign-off on the trailing sheet.

**Pre-ship checklist** — before declaring a proposal/document PDF done:

1. Re-export the PDF (`Chrome --headless --print-to-pdf`).
2. Count physical pages — `python3 -c "import pypdf; print(len(pypdf.PdfReader('foo.pdf').pages))"`.
3. The physical page count should equal the count of `.doc-page` (or equivalent logical-page) sections in the markup. If it's *one more* than expected, the sign-off has been orphaned — apply the three-lever fix.
4. Render the last page as a thumbnail (`qlmanage -t /tmp/last.pdf`) — the sign-off should sit at the bottom of the same sheet as the trailing content, with a hairline rule above it, not alone at the top of a fresh sheet.

**When even the three levers aren't enough.** If the document's content genuinely overflows by more than ~200 px even after tightening, the document is structurally too long for its content. Two correct moves: (a) trim earlier rows too (apply the tightened `--row` rhythm to ALL inter-row gaps, not just the final page), or (b) split the trailing content into a fourth logical `.doc-page` so the natural page-break lands before the sign-off. Don't ship a document with a 5-page count where 4 is honest — readers register the trailing empty page as sloppy.

**Bug history.** May 2026 — both proposals hit the orphan-footer bug independently, which is what made the rule mandatory rather than defensive. (a) `proposal-anchorage-partnership.html` exported as 5 pages where page 5 contained only the sign-off bar; the audience-blocks logical page (3) overflowed by ~120 px past sheet 4. (b) `proposal-anchorage.html` for Porto Wallet — initially diagnosed as a "single-page document, never hit the issue" — turned out to also paginate to 3 sheets at A4 with the sign-off alone on sheet 3, because the Porto document's flat row-list (no `.doc-page` wrappers) still overflows at the A4 print height. The mistake — assuming "single logical page" meant "single physical sheet" — is the load-bearing reason this rule exists. **Pagination is a function of print height, not logical-page markup.** Any `.doc-signoff` shipping in any proposal/document is at risk; apply the three levers unconditionally. Applied the fix to both files: partnership dropped 5 → 4 pages, Porto dropped 3 → 2 pages, both with the sign-off landing cleanly at the bottom of the trailing content sheet. Full diagnostic + Chrome export commands in this session's transcript.

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

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

**What may change between states:** `color` / ink intensity (`--text-faint` / `--field-placeholder` for empty, `--danger` for error, `--text-muted` for disabled). Optional secondary affordance — dashed underline, ghost shimmer, dimmer secondary line — only if the ink shift alone isn't enough.

**What NEVER changes between states:** `font-weight`, `font-size`, `line-height`, `letter-spacing`, `font-family`. All structural.

The reflex when documenting an empty / zero / loading state for a value slot: dim the ink, leave everything else untouched.

**Where this applies.** Amount cards, stat tiles, KPI tiles, balance values, counters, numeric labels — anything that renders a quantity, on product OR marketing surfaces. **Does NOT apply** to content-type changes (a slot swapping a numeric value for a status pill is a structural change, not a state change — a different primitive is allowed to take over).

**Prominence corollary — reach for size + ink before weight (June 2026).** The same lever-ordering generalizes beyond state changes to *making a label or annotation more prominent*: bump its **size** (to a higher type tier) and/or its **ink** (a darker semantic token) before reaching for `font-weight`. Weight is the loudest, least-reversible lever and the one most likely to make an annotation compete with the real headings around it. Canonical case: the modify-order `.ui-modify-filled` note ("37% filled · editing remaining") — first made prominent via semibold, then corrected to match the `.ui-amount-card__label` *size* at `--fw-regular`, with the percentage emphasized by **ink only** (`--text-primary` for `.ui-modify-filled__pct` vs the line's `--text-secondary` base). The reflex when asked to make something "a bit more prominent": try size, then ink, and only reach for weight if neither carries it. (The trailing "· editing remaining" clause was later dropped — June 2026 — so the line now reads just "<pct>% filled" with the info-icon tooltip carrying the explanation; the ink-vs-weight lesson stands.) [[feedback_state-via-ink-not-weight]]

**Bug history.** May 2026 — the zero-state demo on `#ui-amount-card` shipped at weight 500 (`--fw-medium`) + 40% ink (`--field-placeholder`) to communicate "empty"; both axes shifted at once. Reading: the `0` looked like a label, not like an empty amount field. Fix: rolled the weight back to default (700); ink shift alone communicates state cleanly. Full rationale + bad/good pairs in `design.md §03 → State changes shift ink, not weight`.

### Edgeless state-change affordances use glyph-traced text-shadow — never box-bound paints

When a state-change affordance is meant to read as "subtle / soft / no defined visual bounds" — a flash, glow, highlight, spotlight, accent radiance — the paint must come from layered `text-shadow` only. **Drop every box-bound paint primitive:** `background-color`, `box-shadow`, `border-radius`. They all paint relative to the element's box edges, so any visible difference at the box boundary traces the rectangle — even at very low opacity, even with matched-opacity tricks at the seam. `text-shadow` paints from glyph outlines, so the painted area follows the digits / letters and has no rectangular contour anywhere.

**The four traps in order of subtlety:**

1. **`background-color`** — paints the box interior as an obvious rectangle, even at 4 % opacity.
2. **`box-shadow` with positive spread** — brightest pixel sits AT the border-box edge; the outline is unmistakable.
3. **`box-shadow` with zero spread + large blur** — softer falloff, but the brightest band is still at the edge.
4. **`background-color` + matched-opacity `box-shadow` outside** — edge transition disappears, but the empty corners INSIDE the box (where text doesn't reach) reveal the rectangle inward.

The canonical recipe (`.num-count[data-flash="bright"]` / `[data-flash="hue"]`, May 2026 v4) uses three text-shadow layers with no other paint:

```css
.foo[data-flash].is-flashing {
  text-shadow:
    0 0 8px  color-mix(in oklch, var(--accent-primary) 55%, transparent),  /* tight */
    0 0 20px color-mix(in oklch, var(--accent-primary) 35%, transparent),  /* mid */
    0 0 36px color-mix(in oklch, var(--accent-primary) 18%, transparent);  /* wide */
}
```

Three layers compose the falloff: tight glyph-trace + mid halo + wide soft reach. A single 36 px blur at 25 % reads too misty (no concentration on the glyphs); stacking gives "lit-up" feel AND soft fade. Three is the minimum to feel both bright AND subtle; more layers don't help.

**Color token rule — pair with hue-stable tokens.** Use `--accent-*` (brand) or semantic tones (`--success` / `--danger` / `--warn` / `--info`) for the shadow color. **Never** `--text-*` or `--surface-*` — those invert across themes and the affordance inverts with them (the v1 `--num-count` bright halo built from `--text-primary` darkened the area in light mode instead of brightening it). Hue-stable tokens stay in the same hue family across themes; only their luminance shifts.

**When NOT to apply this rule.** Affordances that intentionally show a defined edge — a hard selection ring, an outlined focus state, a tinted "this row is active" backdrop on a list — are NOT edgeless; they want the rectangle (or rounded rectangle) and should reach for `box-shadow` / `outline` / `background-color` deliberately. The rule is for affordances where the spec says "subtle, no defined edge anywhere."

**Authoring checklist for a new flash / glow / highlight:**

1. Does the spec say "subtle" / "no defined edge" / "soft highlight"? If yes → text-shadow-only.
2. Pick a hue-stable token for the shadow color. Never `--text-*` or `--surface-*`.
3. Three layers (tight 6-8 px / mid 18-22 px / wide 32-40 px), opacities in the 55-35-18 % family. Start subtle; tune up only if asked (see [[subtle-first-state-changes]]).
4. No `background-color`, no `box-shadow`, no `border-radius` on the flashing state. If layout needs a border-radius (e.g., the element already has one for its resting state), leave it on the base rule — but the FLASH itself adds zero box-bound paint.
5. Reduced-motion: suppress the transition (the value still updates; the flash just snaps without animation).

**Where this is enforced.** `.num-count[data-flash="bright"]` and `.num-count[data-flash="hue"]` in `styles.css → § 8f. NUMERICS` are the reference implementations. Catalog rendering at `#num-count` shows all three states (none / bright / hue-up / hue-down) side by side. Memory cross-refs: [[edgeless-affordances-text-shadow-only]] (the recipe + traps), [[state-paint-hue-stable-tokens]] (the token rule), [[subtle-first-state-changes]] (the intensity calibration).

**Bug history.** May 2026 — `.num-count[data-flash]` v1→v4: every box-bound paint showed a rectangle until v4 dropped them for three text-shadow layers (clean). Full v1→v4 diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → Numerics bright-flash design chase`.

### Alert `.spot-icon` is mandatory — color alone fails WCAG 1.4.1 (Level A)

**Invariant — every `.alert`, every surface** (product / marketing / dashboards / catalog-meta; static markup AND JS-rendered; plus any future sibling primitive with a tinted tone surface): the leading `.spot-icon.spot-icon--{info|success|warn|danger}` is the FIRST child and is never dropped "to save space." Color alone can't communicate tone to readers with colour-vision deficiency (~8 % of men) — that's a WCAG 1.4.1 *Use of Color* (Level A) failure, not a stylistic choice. The icon's SHAPE (triangle / check / circle-x / circle-i) is the disambiguator; the tinted surface is supporting, not load-bearing.

**Scope — alert MESSAGES, not all tonal display.** The rule is for standalone messages where the tint IS the meaning (urgency / status / error). It does NOT apply to inline tonal data that carries its own sign/magnitude and uses colour only as enhancement — a red `-1.34%`, a `.chip-delta-neg`, a muted `.ui-quote-list__empty`. The triage test (*does the element need triage where colour is the primary signal?*) + the full ✗/✓ worked examples live in the `#alert-component` Rules pane. **`.error-detail` is the documented tonal-DETAIL carve-out** (June 2026) — the alert family's code-bearing sibling (verbatim machine echo: RPC reverts, webhook failures) shares the `--a-*` slots + borderless-pastel surface but deliberately carries NO spot-icon: its meaning lives in its own mono text, and it always sits under a tone-bearing header (result disc, alert) that carries the triage signal. Don't "fix" it by adding an icon, and don't force it into `.alert--danger` (double danger iconography + a mono fork in the alert's prose slots). Spec: `design/components/feedback.md → Error detail`; catalog `design-system/index.html#error-detail`.

**Authoring.** Pick the icon by tone, never a novel shape (a CVD user learns the four). `aria-hidden="true"` on the icon — the `.alert-message` text already carries the meaning for screen readers. Don't substitute a colored dot (a dot says "different," not "which"). Wrapper is `.alert-content`, not the deprecated `.alert-body`.

**Source of truth.** Full a11y rationale + WCAG long-form: `design/components/feedback.md → Alert` (canonical). Invariant + worked scope examples + triage test: `design-system/index.html#alert-component` Rules pane. `styles.css → .alert` reserves the icon slot via flex, so a dropped icon leaves a visible gap. Bug history (May 2026 — 7 instances shipped without the icon, rule strengthened to a Level A failure): `.claude/skills/lifi-ds-docs/references/bug-history.md → Alert .spot-icon dropped despite the spec`.

### Preserve user-typed intent through dependency loss — typed values stay loud, derived values fade

When a value slot loses its computational dependency (wallet disconnect, session expiry, websocket drop, API source offline — any "the data source went away" event), do NOT clear the form. Apply a **two-tier visual treatment** instead:

- **User-typed values** keep their full ink. The slot's content is the user's intent, not the dependency's data — losing the connection doesn't invalidate what they typed. The visual reads "you have a value ready, just need to reconnect" rather than blowing away their work.
- **User-picked selections** (token chips, chain pickers, persona slots — anywhere the user committed to a specific choice via a picker) stay populated with the picked identity at full ink. Same principle as typed values: a pick is the user's intent, not derived data. **The chip's own DOM state IS the truth** — a placeholder tandem (`.avatar-tandem--placeholder`) means "never picked yet"; a populated tandem with real `<img>` children means "user picked this". Disconnect doesn't reset; reconnect doesn't auto-fill. No `userPicked` boolean flag is maintained — keeping a flag in sync with the DOM is the failure mode this principle exists to prevent.
- **Derived values** (anything the dependency computed: fiat equivalent, price impact, balance, quoted output, route ETA, last-updated timestamp) fade to the empty / zero treatment — typically `--text-faint` ink at the slot's regular weight, per the "State changes shift ink, not weight" rule above. The visual reads "this number isn't real right now."
- **Per-card meta captions** (Balance: X, Connected to: Y, Price impact: Z) hide entirely. They have no honest value to render while the dependency's offline, and duplicating "reconnect" inside every card adds noise without information — the widget's primary CTA already carries that signal.

**Reconnect behaviour.** When the dependency returns, the connected branch recomputes derived values against the preserved user-typed values + picks and re-activates everything to full ink in one render pass. The user resumes from where they left off; they don't start from a cleared form OR a cleared picker.

**Where this is enforced.** The canonical reference is `.ui-amount-card--zero` (May 2026) — applied to the Receive card whenever the wallet is disconnected (always-derived; can never be "real" without a quote), applied to the Send card only when its input is empty (preserves typed intent at full ink when present). Implementation in `ui-amount-card.js → renderEmptyState()`; the modifier itself lives in `swap.css`; the contract is documented in the Rules pane at `design-system/playground.html#ui-amount-card`. Future widget primitives that face the same shape — entered input → derived output → dependency on a service — should reach for this two-tier pattern rather than re-deciding it.

**Bug history.** May 2026 — the initial `renderEmptyState()` cleared the Send input value on disconnect, so reconnecting required the user to retype "100" they'd just entered. The reset read as punitive for a state the user didn't choose to enter (the disconnect was system-initiated, or a deliberate persona swap). Designer feedback: this is the wrong default — the right default is preserve. Refactored to the two-tier `--zero` trigger; the disconnect path now keeps the input value, and the receive card alone carries the full faint treatment. **May 2026 (v2) extension** — the rule expanded from typed values to user-picked selections (token chips). Implementation: chips ship as `.avatar-tandem--placeholder` on cold load; the picker calls `liftTandemPlaceholder()` (`swap.js`) to rebuild the tandem with real `<img>` children on first pick; disconnect doesn't touch the chip; reconnect doesn't auto-pick. The chip's DOM is the single source of truth for "has the user picked." [[playground-cold-load-disconnected]]

### Quote preview is wallet-OPTIONAL — picks + amount unlock the routes panel, wallet only gates execution

Quote panel, Receive amount, Send fiat, Receive fiat are **derived from (fromToken, toToken, amount)** — not wallet. Pre-connect preview of routes is a first-class feature: the user can pick tokens, type an amount, see Send/Receive fiats compute, and **browse the available routes** without ever connecting a wallet. Connection is required only for execution.

**The wallet-required vs wallet-optional split:**

| Slot / behavior | Needs wallet? |
|---|---|
| Send / Receive token picker | ❌ |
| Send fiat (`price × amount`) | ❌ |
| Receive amount (from `quoteSim.getQuote()`) | ❌ |
| Receive fiat (from quote `toUsd`) | ❌ |
| Receive price impact | ❌ |
| Quote list panel (`data-quotes-state="visible"`) | ❌ |
| Quote card selection (click a route → updates Receive) | ❌ |
| **Balance row** (`/ 1,234.56`) | ✅ |
| **MAX chip** (and `25% / 50% / 75%` quickpicks) | ✅ |
| **Insufficient-balance alert** (`.alert--warn`) | ✅ |
| **Review modal + signing flow** | ✅ |

**The architectural choice:** Lift `renderPair`'s wallet-required early-return; gate ONLY the wallet-specific slots in `if (wallet)` branches. Without a wallet, the function still computes from price + quote sims and renders the same DOM the connected branch would — just with balance + MAX + insufficient hidden. The `--zero` treatment applies when the typed amount is empty OR when either picker is in placeholder state (handled by the same `userAmount <= 0` toggle + the `readPickedToken` returns-null guard).

**Why this matters beyond friction reduction:** Every modern DEX/bridge (Uniswap, 1inch, Jumper itself) does this. Quotes are public, address-independent information; gating them on wallet was a legacy assumption from when the data layer happened to require wallet RPC. With the parametric sim model — and with real APIs that quote against `(fromToken, toToken, amount)` without an address — there's no data reason to gate. Hiding routes pre-connect is now the regression, not the feature.

**Footer CTA gate.** Execution still requires a wallet: the footer CTA reads "Connect wallet" via `body[data-playground-wallet-connected]` and only flips to "Review" when connected. Route-click selection updates the visual rim + the Receive card's number (route comparison works pre-connect), but doesn't OPEN Review — Review opens only via the footer button. Two-click flow ("pick favorite route + connect") is intentional; it preserves route comparison.

**Where this is enforced.** `ui-amount-card.js → renderPair()` — the wallet check is no longer an early-return; `if (wallet)` wraps balance fetch + MAX-chip gate + insufficient-balance alert. `setQuotesReady(pair, userAmount > 0)` (line ~545) runs unconditionally on `(tokens picked + amount > 0)`. Don't reintroduce a wallet gate in `renderPair` — the failure mode is "panel doesn't render despite all inputs being valid." Pairs with [[playground-cold-load-disconnected]] (cold-load architecture) and the "Preserve user-typed intent" rule above (picks + amounts persist through connect/disconnect cycles).

### Playground wallet-connect wizard + ecosystem re-scope — the connect flow is a 4-step modal that re-scopes the picker

**Shared module (June 2026) — the connect machinery is no longer playground-only.** It was extracted from `swap.js` into **`wallet-connect.js` → `window.LifiWalletConnect`** so the **Portal reuses the EXACT same wizard + persona card** (see "LI.FI Portal — partner console" → Wallet connect). The module owns the surface-agnostic core: `connect` / `disconnect` / `setPersona` / `listPersonas` / `getActivePersona` / `isConnected`; `initModal()` (the wizard — was `initWalletConnectModal`); `initDisconnectButtons()`; `statusCardHTML(persona, opts)` — the **single source** for the connected-status card markup (a `.list-item` connect / identity row in a single-item `.list--cards`) the rail mount AND the Portal footer both render; `statusPillHTML(persona, opts)` — the sibling **single source** for the COMPACT connected pill (a `.chip-avatar` default-tier button: persona avatar + chain badge + shortened address, `data-action="open-wallet-modal"` to switch wallet), used by the playground site-preview **host nav** (June 2026); and the `PROVIDERS` / `ECOSYSTEMS` catalogs. The core sets the three body signals + active persona, then **fires `lifi:wallet-change`** `{connected, personaId, provider, ecosystem}` and runs **NO surface side-effects** — each consumer subscribes and runs its own: swap.js re-scopes the picker / re-renders amount card / orders / rail mount / **host-nav wallet pill** (`initHostNavWallet` → both `.host-nav` slots, only the active one visible) / `syncPrivateGate`; portal.js renders the footer card / gates nav / switches the active org. `window.lifiPlayground.*` stays a thin delegating wrapper over the core (console hooks unchanged). `.ui-wallet-mount` was promoted swap.css → styles.css ("8q0. WALLET MOUNT") as the 2nd product consumer (rail + Portal footer share it). **Load order:** `wallet-sim` → `wallet-connect.js` → `swap.js` / `portal.js`. Wherever the rest of this section says `swap.js → initWalletConnectModal` / `initWalletMount`, read the module equivalents. **Persona ids are archetype slugs** (`shrimp` / `fish` / `whale` / `stables` / `hodler` / `ceo`), NOT the display names — `connect('whale')` works, `connect('mabel')` throws "Unknown persona"; the names are Kim / Finn / Dale / Mabel / Vilen / Philipp. **Philipp (`ceo`, June 2026) has NO portrait yet** — his fixture declares `avatar: { kind: 'initials', label: 'Philipp Zentner', tone: 9 }` and `wallet-connect.js → personaAvatarHTML` renders the canonical `.avatar--initial` monogram (PZ, spectral-9) wherever the other personas show a portrait `<img>`; when a headshot lands in `portraits/personas/`, flip the fixture's avatar kind back to `image` + add `philipp` to personas.html's `PHOTO_PERSONAS`.

The playground "Connect wallet" affordance opens `#walletConnectModal`, which is a **multi-step wizard**, not a one-shot persona picker (June 2026). The flow mirrors the real Jumper connect sheet:

```
persona → wallet provider → ecosystem → connecting → dismiss
```

**The whole modal inner (header + body) is JS-rendered per step** into `[data-wallet-flow]` by `swap.js → initWalletConnectModal` — the header variant (`screen-header--main` vs centered + back) and the body differ by step, so a single `renderStep()` owns both. A module-level `flowState = { step, personaId, provider, ecosystem }` + `connectTimer` drives it. Navigation is **WIZARD (popOne)** — the back arrow goes to the PREVIOUS step, a deliberate divergence from the modal `.screen-header` "back always returns to Main" two-tier contract (a connect wizard is the depth-bearing flow that contract carves out).

- **Providers** — `WALLET_PROVIDERS` (8, each id → `avatars/wallets/<id>.svg`: metamask, phantom, walletconnect, coinbase-wallet, base-account, porto, ledger, slush). `multichain:true` (MetaMask, Phantom) routes through the **ecosystem** step; the rest — including ledger + slush (`tag:'Installed'`) — connect directly with EVM. **Adding a provider:** source the *authentic* brand mark (not an approximation — per the brand-asset-first rule), wrap it `32×32` with a full-bleed bg square (`M0 0h32v32H0z`) + centered foreground mark at ~62% fill (match the Phantom ghost's `x=6→26`; for a mark of size `m` center via `translate((32−m)/2)`), save to `avatars/wallets/<id>.svg`, add a `WALLET_PROVIDERS` entry. Rows render via `.list--cards` automatically — no CSS. (Slush's official `favicon.svg` was used verbatim with its white disc swapped for a full-bleed white square so the avatar circle has no transparent corners.)
- **Ecosystems** — `WALLET_ECOSYSTEMS` = Ethereum (`evm`) / Solana (`solana`) / Tron (`tron`); `chain` field picks the representative chain avatar for the connected identity pair. The ecosystem step **leads with the picked provider's mark** (`ecosystemIntroHTML()` → a `.wallet-flow-intro` centered column) above a **provider-aware prompt** — `<Provider> supports multiple chain ecosystems. Select which one to connect to.`, reading `flowState.provider.name` (always a multichain provider here, so it resolves). The mark is the **base `.avatar` primitive at `--2xl` / `--circle`** (96px disc) — NOT a bespoke logo class; the full-bleed wallet SVG clips into the disc via the base rule's `object-fit:cover`. The wizard's hero disc is the provider's ONE visual appearance — it does NOT reappear as a badge on the connected card (the `--bl` provider badge was retired June 2026 under the one-badge rule). Catalog rendering: `design-system/index.html#wallet-connect-flow` (Step 3).
- **Connecting step** — simulates the wallet handshake with `setTimeout(connectFromFlow, 1600)`; back/close clears the timer.
- **All three steps are the `.list--cards` primitive** (`<div class="list list--cards">` + `.list-item--interactive`): provider/ecosystem rows AND the persona step all at the default `.list-item` tier (persona rows carry: uniform brand-primary tone — the `.list--cards` default; balance via `.list-item__trail--value`; gap via `[data-wallet-modal-list]`). The bespoke `.persona-connect-card` was retired June 2026 — see the [connect / identity row](design/components/lists.md). (`--list-card-tone` stays a `.list--cards` capability for tone-coding *other* lists; the connect rows deliberately don't.)

**Connection signals — three body attributes (the single source of truth, CSS + JS + console all read them):**

- `body[data-playground-wallet-connected="true"]` (existing — the connection flag)
- `body[data-wallet-ecosystem="evm|solana|tron"]` (the picker re-scope source)
- `body[data-wallet-provider="metamask|…"]` (the picked provider's identity — wizard state, not rendered as a badge)

`window.lifiPlayground.connect(personaId, { provider, ecosystem })` sets all three (opts optional — console-hook `connect('whale')` defaults `ecosystem:'evm'`); `disconnect()` clears them. The connected **rail mount** renders an **identity pair** — `.avatar-tandem` with persona portrait (main, no size class — the tandem owns the 40px tier) + chain badge (`--br`). **One badge only (June 2026):** the `--bl` provider badge was retired under the avatar one-badge rule (see `design/components/imagery.md → Token + chain tandem`) — a second corner mark overloads the avatar; the provider stays on `body[data-wallet-provider]` and surfaces in the wizard's ecosystem-step hero disc.

**Ecosystem re-scope — picking Solana/Tron constrains the picker to that ecosystem.** One signal (`body[data-wallet-ecosystem]`), read at every picker render:

- `picker-data.js → ecosystemOf(chain)` — the single source of the EVM-vs-non-EVM rule (`'evm'` for any numeric-`chainId` chain; named ecosystem for the `solana`/`tron`/`sui`/`bitcoin` extensions; `null` unknown). Accepts a ChainView/TokenView or a bare chainKey. Exported on `window.LifiPickerData`.
- `picker-render.js → getActiveEcosystem()` + `ecoMatches()` filter the chain list, chain shortcuts, popular tokens, and portfolio tokens. **Unset (disconnected / cold load) = NO filter = full universe** — preserves the wallet-optional pre-connect browse behavior.
- `picker-render.js → applyEcosystem()` (exported, called by `connect`/`disconnect`) resets each stage's browse chain-filter and re-renders. **Picked from/to tokens are intentionally PRESERVED** on ecosystem change (per "Preserve user-typed intent") — they re-scope naturally on the next pick.
- Token coverage: EVM is rich; Solana = SOL + USDC; Tron = TRX + USDT (both populate). `snapToFollowFromInLimit` is deliberately NOT ecosystem-filtered — `listTokens({chainKey})` is already single-chain (one ecosystem), so a filter there would be dead code AND would desync the limit to-side from the from-chain.

**The "All networks" preview is ecosystem-aware (June 2026).** The 2×2 mini-grid on both All-networks surfaces (the wide chain-list row + the narrow shortcut tile) was a fixed cross-ecosystem mix (eth/sol/btc/arb) regardless of scope; it now reflects the active ecosystem via `picker-render.js → allPreviewKeys()` — unscoped (disconnected/cold-load) keeps the curated mix, scoped (evm|solana|tron) shows the first up-to-4 in-ecosystem chains in canonical order. Single-chain ecosystems (Solana, Tron) collapse to one mark; a content-driven `:only-child` rule (swap.css) expands the lone avatar to fill the box so it reads as a single chain mark, not a corner dot. Scoped to the All surfaces so the shared `.card-avatar--cluster` primitive (always fed four marks elsewhere) is untouched.

**Known minor edge (acceptable for the demo):** bridge within a single-chain ecosystem (Solana/Tron) shows the same chain on both sides — bridging needs ≥2 chains and the ecosystem re-scope locks the picker to one ecosystem, so single-chain-ecosystem Bridge degenerates to a same-chain swap. Not hidden or messaged (a Bridge-tab gate would touch the view-rail mode tabs — deferred).

**Where this is enforced.** `swap.js → initWalletConnectModal` (wizard + `WALLET_PROVIDERS`/`WALLET_ECOSYSTEMS`), `window.lifiPlayground.connect/disconnect`, `initWalletMount.render()` (triple). `picker-data.js → ecosystemOf`. `picker-render.js → ecoMatches` (4 render fns) + `applyEcosystem` + `allPreviewKeys` (the All-networks preview). Markup: `playground.html → #walletConnectModal` (a thin `[data-wallet-flow]` shell). Connecting-step CSS: `swap.css → .wallet-flow-connecting`. Pairs with [[playground-cold-load-disconnected]], [[wallet-provider-selection-planned]] (the SEND-wallet surface still hardcodes its provider — separate from this connect flow).

### Unified transactions data layer — `window.LifiTransactions` is the source; LifiActivity / LifiOrders are translators

Every persona's transaction history — Activity feed, Transaction-details panel, Orders panel — sources from **one persona-keyed mock**, `window.LifiTransactions` (`lifi-transactions-data.js`). The prior per-surface mocks (`window.LifiActivity`, `window.LifiOrders`) are now **thin shape translators** over it. Same canonical-source-vs-rendering discipline as the dashboard snapshot and the catalog renderings: the unified mock is the source; each translator pivots it into the legacy shape its untouched renderer already consumes.

**The discriminated-payload schema.** Every transaction carries universal fields plus exactly one type-specific payload, keyed by `tx.type`:

```js
{
  id, personaId, walletId,
  type: 'swap' | 'bridge' | 'gas' | 'limit' | 'perp' | 'dca',   // discriminator
  status: 'pending' | 'completed' | 'failed' | 'cancelled' | 'refunded',
  timestamp: '2026-05-29T14:33:00Z',                             // ISO 8601 — never pre-formatted strings
  from: { symbol, amount, usd, chainName, chainId, tokenIcon, chainIcon },
  to:   { symbol, amount, usd, chainName, chainId, impactText, impactIsLoss, tokenIcon, chainIcon },
  // exactly ONE of these, matching `type`:
  swap?:   { providerName, providerIcon, providerLabel, transferId, rows[], receipts[] },
  bridge?: { … same as swap … },
  gas?:    { … same as swap … },
  limit?:  { triggerPrice, expiresAt, filledAt },
  perp?:   { … RESERVED — slot exists, no fixtures yet … },
  dca?:    { … RESERVED — slot exists, no fixtures yet … },
}
```

**Why discriminated, not a flat union.** Perps and DCA will need fields with no swap/limit analog (leverage, liquidation price, interval, child-tx ids). A flat `route` slot shared across types calcifies the wrong shape. The discriminator keeps each type's payload independent while the universal `from`/`to`/`timestamp`/`status` cover what every renderer needs. ISO timestamps (not the legacy `date: "March 31"` + `time: "6:49 AM"` strings) let the layer sort/filter; `LifiTransactions.formatTs(iso)` derives the date/time/started strings at read time so each translator picks the format it needs.

**Persona-keying.** `LifiTransactions.getActivePersonaId()` tracks `window.LifiWalletSim`'s active persona; `getByPersona(id, opts)` filters by `{ type | types | status }` and returns newest-first. A persona swap fires `LifiWalletSim.on('persona-change')`, and **every consumer must re-render on that event** — the data layer staying synced is necessary but not sufficient; the renderer has to re-read. Wired today in `swap.js → initActivity` (refreshes the activity list, kicks tx-details back to activity since the displayed tx belongs to the prior persona) and the orders-init IIFE in `playground.html`.

**The public API (stable):** `getByPersona(personaId, opts?)` · `getById(id)` · `getActivePersonaId()` · `formatTs(isoString)` · `addTransaction(personaId, tx)` (June 2026 — the widget execution flow records every attempted run — completed / refunded / failed — at its outcome via `swap.js → recordTxRun()`, so it surfaces newest-first in Activities + tx-details; in-memory, resets on reload, the createLimit source-mutation contract).

**Per-persona cadences match the @lifi/personas bios** (180-day rolling window): Kim/shrimp 15 (sparse, **zero limits** — a first-time user's empty Orders panel IS a demo story), Finn/fish 95, Dale/whale 11 (rare large ETH/stETH), Mabel/stables 75 (bridge-heavy, includes a refunded Solana bridge — the [[non-evm-bridge-unreachable]] edge case), Vilen/hodler 48 (power-user across every surface), Philipp/ceo 58 (founder dogfooding — CEO-scale amounts, Solana legs via Mayan, a CCTP treasury move, one failed route test, full limit lifecycle incl. partial/open/expired). ~302 total.

**Authoring rules.**

- **Add a transaction** → append to the persona's array in `DATA` (source order is oldest-first for editing ease; `getByPersona` reverses to newest-first). Reference icons via the centralized `T` / `C` / `B` catalogs at the top of the file, never raw paths — a missing-asset failure then surfaces at the catalog, not silently in the renderer ([[avatars_only_existing]]).
- **Add a new tx type** (perps, DCA) → fill the reserved `perp` / `dca` payload slot, add a translator (or extend an existing surface) the same way `LifiActivity` / `LifiOrders` translate. Don't widen the universal fields to carry type-specific data.
- **Never re-point a renderer at `LifiTransactions` directly** unless you're also retiring its translator. The translator boundary is what keeps the renderers untouched; bypassing it for one surface fragments the contract. If a translator is genuinely retired, do it atomically ([[Primitive retirement is atomic]]). **A NEW reference surface reading `getByPersona` directly is fine** — that's the public API's purpose; the rule only governs re-pointing the EXISTING translator-bound widget renderers. Precedent: `design-system/personas.html`'s persona-detail transfer-history table (June 2026) reads `getByPersona(personaId)` directly to show every tx type (swap/bridge/gas/limit) in a `.data-table`, distinct from the widget's swap/bridge/gas-only Activity feed.
- **Status mapping is the translator's job.** The orders translator collapses `failed` + `refunded` → `cancelled` for the legacy 3-state orders enum; that mapping lives in `orders-data.js`, not in the unified mock.
- **Price derivation is the translator's job too — BOTH translators (June 2026).** Neither `orders-data.js` nor `activity-data.js` is a *pure shape* translator anymore — each RECOMPUTES its price-bearing display from the live oracle at render time, keeping only the fixture's INTENT, so neither surface can drift from token prices. Source priority `LifiPickerData` (tokens.json, comprehensive) → `getPriceUsd` (oracle, persona-held subset — `null` for UNI/wstETH/LINK) → stored string (defensive, never blanks a cell). The two differ in WHICH amount is intent vs. derived:
  - **Orders** (`orders-data.js`) keeps the sell amount + `distancePct`, derives `market` / `limit` / `buy` / Sell+Buy USD (the trigger drives the buy).
  - **Activity + tx-details** (`activity-data.js`, commit `74adba6`) keeps the SENT amount, derives the received amount + both USD + the `Exchange rate` row. The received amount is what the sent value buys at current market AFTER the stored price-impact haircut (`toUsd = fromUsd × (1 + impact)`), so `from ≈ to` stays coherent instead of reading as a phantom loss (keeping both stored amounts would have shown e.g. "$150 → $64" against a −0.23% impact line). The rate row keeps the volatile-as-base convention every fixture string uses (`1 ETH ≈ 1,554 USDC`, not `1 USDC ≈ 0.00064 ETH`); same-token bridges reproduce their fee-ratio strings (`1 USDC ≈ 0.9968 USDC`) untouched.

  The shared price-lookup + number-format helpers (`STABLES` / `priceMap` / `priceOf` / `num` / `commas` / `fmtPrice` / `fmtAmount` / `fmtUsd`) live in **`tx-price-derive.js`** (`window.LifiTxPrice`), consumed identically by both translators — load it before either (it has no other global dependency). Only the surface-specific derivation is local (orders: `derivePrices` / `mapStatus` / `expiresText`; activity: `impactFrac` / `deriveTrade`). See `CLAUDE.md → "Token prices — sync pipeline"` + `bug-history.md → "Orders table prices drifted from the synced token oracle"`.

**Where this is enforced.** Source + schema header: `lifi-transactions-data.js`. Translators: `activity-data.js` (`window.LifiActivity`), `orders-data.js` (`window.LifiOrders`). Renderers (untouched): `swap.js → initActivity` + the `.ui-tx-summary` primitive; `orders-list.js` (single-source skeleton dispatch). Persona-change wiring: `swap.js → initActivity` + `playground.html` orders-init IIFE. Pairs with [[playground-cold-load-disconnected]] and the [Activity + tx-details — `.ui-tx-summary`] memory (which now reads through the translator). [[unified-transactions-data]]

### Wallet-scoped surfaces gate their empty-state on connection — connect prompt for standalone surfaces; hide-the-card for paired/secondary surfaces

Surfaces whose content IS the user's own wallet data — Orders, Activity, balances — must distinguish **disconnected** from **connected-but-empty**. The right disconnected treatment depends on whether the surface is **standalone** (the primary content of its pane) or **paired/secondary** (sitting next to another always-visible card with a primary Connect CTA already present in the layout). Two shapes:

| Shape | Disconnected treatment | Connected + zero treatment | When |
|---|---|---|---|
| **Standalone wallet-scoped surface** | **Connect prompt** — icon + "Connect your wallet to view X" + a "Connect wallet" CTA (`data-action="open-wallet-modal"`) | Generic zero-state ("nothing here yet") | The surface IS the pane. No paired always-visible card. No other primary Connect CTA on the same surface. |
| **Paired/secondary wallet-scoped surface** | **Hide the entire card** (`display: none`) | **Hide the entire card** (`display: none`) | A paired always-visible card exists in the same panel AND the layout already carries a primary Connect CTA elsewhere. The card only appears when there's something honest to show. |

**The test that picks the shape.** Ask: *if I render a connect prompt in this surface, will it duplicate a "Connect wallet" CTA that already lives somewhere else on this page?* If yes → hide-the-card (the duplicate would be noise). If no → connect prompt (the surface is the only place the user could discover the connect action from this view). Same logic on the zero-state side: *if I render a "your X will appear here" empty state, does the user gain anything from the message, or am I adding weight to a panel that has nothing to tell them?* For first-time-user states in a paired panel, hiding is cleaner; the panel naturally reappears when populated.

**The standalone reference — Activity feed (FAB sub-panel).** A standalone wallet-scoped surface in its own sub-panel; no paired card sits beside it, and the FAB sub-panel doesn't carry a primary Connect CTA elsewhere. The connect prompt + generic zero-state pattern applies. The wallet mount in the playground rail follows the same shape (its identity row IS its disconnected affordance).

**The paired reference — Orders card (Limit-mode receive column, June 2026 retirement).** Two flags gated off `.ui-orders-card`: `data-disconnected` (set by the orders wiring IIFE in `playground.html` reading `body[data-playground-wallet-connected]`) and `data-empty` (set by `orders-list.js` when `getOrders()` returns empty). Both flags trigger `display: none` via `swap.css` → "Body-state hide". The card only renders when the user is *connected AND has orders*. Reasoning: it sits next to the always-visible Market Price chart in the receive column, AND the form panel beside it already carries the layout's primary "Connect wallet" CTA — so an in-card connect-prompt would duplicate that affordance, and a generic zero-state would add visual weight to a panel with nothing to tell a first-time user.

The retirement was atomic per [Primitive retirement is atomic — no aliases]: the `[data-orders-disconnected]` and `[data-orders-empty]` empty-state DOM was dropped from `playground.html` and from the catalog's markup pane in the same commit; the catalog's Disconnected + Zero-orders Preview snapshots were retired (replaced with a `.ds-doc-aside` note in the Preview pane); the swap.css `.ui-orders-card[data-empty="true"], …[data-disconnected="true"] { flex: 0 0 auto; }` size override was removed (no longer needed once the card is hidden, not just resized). The `data-disconnected` / `data-empty` attributes themselves stay on the card — they're the hide triggers now, same shape, different effect.

`orders-list.js` stays a pure `LifiOrders → tbody` renderer (connection is a playground concern). The persona-change + connect/disconnect wiring still flows through `window.LifiOrdersPanel.render` which `lifiPlayground.connect/disconnect` call alongside `LifiAmountCard`/`LifiWalletMount`.

**Future paired surfaces.** When you add a new wallet-scoped surface to a panel that already has an always-visible card AND a primary Connect CTA, reach for the hide-the-card recipe. When adding a wallet-scoped surface to a standalone sub-panel (FAB, drawer, dedicated overlay), reach for the connect prompt. Don't author both within the same panel — that's the duplicate-CTA failure mode this rule prevents. (Memory: `wallet-scoped-paired-surfaces-hide`.)

### Quick settings composition — `.ui-quick-settings`, per-mode blocks, drill-to-`.ui-settings`

The pre-CTA summary cluster above the action footer (the `.ui-quick-settings` block) is a **reusable composition paradigm**, not a one-off. Any widget config whose pre-action review benefits from being both **reviewable AND act-on-able** reaches for the same shape. The composition is the primitive; the row vocabulary is what varies per mode.

**The contract (every consumer keeps it):**

- **Shape.** `.ui-quick-settings` wrapper (flex column, `gap: --space-8`, `margin-top: --space-16`) → N `.setting-row--sm .setting-row--drill` children → each carries `data-quick-link="<key>"` naming a **registered sub-screen of `.ui-settings`**. Click navigates the user INTO that setting's full sub-screen (a full-panel takeover that returns to the form on back), where the value is actually changed. The drill key vocabulary is 1:1 with the full settings menu's drill rows.
- **Values mirror, never snapshot.** Each row's `.setting-row__value` carries `data-settings-value="<key>"`; `swap.js → initQuickSettings` writes the live value to **every** `[data-settings-value="<key>"]` surface at once (the quick row + the full-menu main-list row + the sub-screen). Choice settings register in `CHOICE_VALUES` (radio + checkmark); toggle settings register in `TOGGLE_KEYS` (count recompute). A value that drifts here erodes the trust the cluster exists to provide.
- **Navigation primitive, not selection control.** Rows carry no `.is-active`. Per the *List-row state recipe* rule, navigation rows use `border: 0` (no reserved active-rim slot) — which `.setting-row` ships with. If a value needs picking inline, the row's drill destination is where that happens.
- **Anchored above the action CTA, capped at ~4 rows.** It's "the last thing the user reviews before the final button," so it sits bottom-most above `.action-bar` inside `.ui-form-stack`. Past ~4 rows it stops being a glance-able summary and becomes a separate drill (the full settings menu).

**Per-mode blocks — "different mode = different cluster" is literal.** When two widget modes want different row vocabularies, author **one sibling `.ui-quick-settings[data-quick-settings-mode="<mode>"]` block per mode**, not one shared block. Each is gated `body[data-layout="dual"][data-widget-mode="<mode>"] .ui-quick-settings[data-quick-settings-mode="<mode>"]` (Dual layout AND the matching mode); only the active mode's block shows. Don't overload one mode's block with another mode's rows, and don't fork the wrapper *class* per mode — the differentiator is the `data-quick-settings-mode` attribute, same primitive. Modes whose axes don't share the vocabulary (Limit's price/expiry, Gas's native gas) get **no** block. Current consumers:

| Mode | Quick rows |
|---|---|
| **Swap** | Route type · Exchanges · Max. slippage (3) |
| **Bridge** | Route priority · Exchanges · Bridges · Max. slippage (4) |
| **Limit / Gas** | none — gated out (Limit's pre-CTA space goes to the Orders panel + limit-price card) |

**Icons OFF on quick rows; icon + label on the full Settings menu.** This is a deliberate tier split, not an inconsistency:

- **Quick-settings rows are the compact `.setting-row--sm` tier → no leading icon.** In this short cluster the labels alone are unambiguous, and leading glyphs added visual noise without disambiguating value. The icon stays in the DOM (the grid collapses `--space-20 1fr auto` → `1fr auto`) so the toggle is **pure CSS** — `body[data-quick-settings-icons="on"]` (page-wide; the Workshop "Show icons" card that drove it was cleared June 2026 — set the attribute directly) or `.ui-quick-settings[data-icons="on"]` (per-instance) re-shows it. Default is OFF.
- **The full Settings menu rows are the larger `.setting-row--lg` tier → icon + label.** A longer scannable list of distinct settings, where a leading glyph genuinely aids recognition. Icons stay ON there.

The rule of thumb: **`--sm` summary clusters drop the icon (label-only); `--lg` full menus keep icon + label.** When you author a new quick-settings consumer, match the sm/label-only treatment; don't import the full menu's icon+label.

**Where this is enforced.** Markup: `playground.html` — the two `data-quick-settings-mode` blocks + the matching `route-type` sub-screen / main-list row in the `.ui-settings` block. CSS: `swap.css` § *Quick settings — Dual + Bridge/Swap only* (per-mode visibility gate + icon gate). JS: `swap.js → initQuickSettings(card)` (drill wiring + `CHOICE_VALUES` / `TOGGLE_KEYS` mirroring). Catalog rendering: `design-system/playground.html#ui-quick-settings` (per-tab compositions + icon variant). Spec rendering: `design/components/swap.md → Quick settings`. This CLAUDE.md section is the canonical source; the catalog + spec are renderings that point back here.

### Skeleton + shimmer — universal loading-state primitive

Every loading state in this codebase paints `.skeleton` atoms — never an empty slot, never an ad-hoc gray block, never a per-component placeholder fork. The skeleton is the visual contract for "content is on the way; layout is reserved." Sibling of `.spinner`; pick by what the loading communicates:

| Reach for | When |
|---|---|
| `.skeleton` | New CONTENT is arriving (list, card, page body, value slot). Layout reserved; user sees the shape they'll get. |
| `.spinner` | Existing UI is mid-ACTION (signing transaction, refreshing, polling). Compact; doesn't claim slot space. |

**The primitive.** Four shapes; text-line carries a four-tier size ladder; always-on shimmer with `prefers-reduced-motion: reduce` fallback. Theme-aware via `--text-primary` mix — auto-flips light/dark.

| Class | Shape | Sized via |
|---|---|---|
| `.skeleton` | Block — `var(--space-12)` radius | inline `style="width:...; height:...;"` |
| `.skeleton--text` | Text-line — `var(--space-5)` radius, inline-block, `1em` tall | `width: Nch | N%` (height tracks host font-size unless a size modifier applies) |
| `.skeleton--circle` | Circle — 50 % radius | `--skeleton-size` (defaults to `--avatar-size` per the avatar-size cascade) |
| `.skeleton--pill` | Pill — `var(--r-full)` | inline width |

Text-size ladder (pairs with the typography tier of the host slot when `1em` isn't enough):

| Modifier | Height | Pairs with |
|---|---|---|
| `.skeleton--sm` | `var(--text-caption)` — 12 px | Caption rows, footer meta, balance lines |
| (default) | `1em` | Body copy, generic text |
| `.skeleton--lg` | `var(--text-body-lg)` — 18 px | Lead body, large list-item labels |
| `.skeleton--xl` | `var(--text-h3)` — 24 px | Amount-display slots, h3 headings |

**Anatomy.** Two layers — base tint on the `.skeleton` element + sweep on `::after`. Base = `color-mix(--text-primary 8 %, transparent)`. Sweep = `color-mix(--text-primary 14 %, transparent)` — +6 % delta over the base, readable in either theme. Sweep is a linear-gradient at 100° that translates left → right via `transform` (GPU-accelerated, cheaper than animating `background-position`). The 100° tilt (not 90°) gives wide text-line skeletons a visible sweep along their entire length rather than a vertical bar racing past.

**Loading-state contract — four rules:**

1. **Compose into the slots the real content will fill — and route the skeleton THROUGH the real render function, not a parallel one.** A loading quote-card paints chip-skeleton where the badge will land, circle-skeleton where the avatar will land, text-xl skeleton where the amount will land. Don't render a single big block where structured content would arrive — the layout shift on data arrival is what the primitive exists to prevent.

   **The single-source rule — for any critical-component skeleton.** A "critical component" is anything load-bearing for the user's decision (quote cards, amount cards, picker rows, balance values, anything they read to make a choice). For these, the skeleton MUST share its render path with the real card via slot helpers that dispatch on a null-data branch. Each value-bearing region becomes a small helper `slotFoo(opts)` that returns either real content or a `.skeleton` atom in the SAME wrapper. The shell (the card-level renderer) composes slots blindly — its only branch is the article-level class set (`.is-skeleton` vs `.is-active`). Changing a wrapper structure (new chip in the meta row, new footer cell, new variant) updates both modes in one edit; drift becomes architecturally impossible.

   Maintaining two parallel implementations of the same component — a hand-tuned `renderSkeletonCard()` that mirrors `renderRouteCard()` "in spirit" — is the failure mode this rule prevents. Even with the best intentions, parallel implementations drift the moment the real card evolves and the skeleton doesn't get the memo. The drift is silent until a designer flags the structural mismatch — months later, after every new contributor has reinforced the gap. Per the [Primitive retirement is atomic — no aliases] rule, when refactoring an existing parallel skeleton into the single-source pattern, delete the old function in the same commit. No aliases.

   **For less-critical surfaces — generic skeleton fallback is fine.** Page-load fallbacks for arbitrary panels, secondary affordances, low-traffic surfaces where designing a precise skeleton match isn't worth the engineering cost. A small `.skeleton-block` sized by its container is enough. The two-tier strategy (critical custom vs. less-critical generic) is a STRATEGY, not an implementation shape — the critical tier's implementation is single-source-render, NOT "hand-tuned parallel function."

   **Canonical reference — `ui-quote-list.js → renderRouteCard(route, isFirst, fromTok, toTok, variant)`.** Both real and skeleton modes route through this function. `route === null` is the skeleton path; each slot helper (`slotHeaderTag`, `slotAvatar`, `slotAmount`, `slotChevron`, `slotDetail`, `slotFooterRate`, `slotFooterCell`) gates on `opts.isSkel` and returns either real content or a `.skeleton` atom in the same wrapper. `renderSkeletonList(listEl, count)` loops `renderRouteCard(null, i === 0, null, null, variant)` and reads `listEl.dataset.variant` so the Variants Rail's current selection drives both modes through one truth. The conditional header logic — chip emitted only when the route carries a tag — is shared: skeleton mode emits the chip-pill ONLY on the first card (matching the typical real layout where exactly one route is tagged "Best return"). **The orders panel (`orders-list.js → renderRow(order)`, June 2026) is the second canonical reference** — same single-source dispatch (`order === null` is the skeleton path) ported to `<tr>` rows inside a `<table>`, with column-slot helpers (`slotAvatarCell`, `slotMonoCell`, `slotStartedCell`, `slotStatusCell`, `slotKebabCell`) instead of the article-body slots. Two shapes — `<article>`-rooted and `<tr>`-rooted — flow through the same dispatch, which proves the pattern isn't shape-coupled. The same single-source approach applies when porting other critical components (amount cards, picker rows, future activity / portfolio rows).

2. **Mark the loading STATE on the container, not on the skeleton.** Container carries `data-state="loading"` + `aria-busy="true"`. Each skeleton atom is `aria-hidden="true"` — decoration of the state, not content. Screen readers announce the container's loading status once; per-atom announcement is noise. The CLEAR exit path removes BOTH attributes in every branch (success, empty, error) — drop one and the announcement is stuck.

3. **Preserve user-typed intent through the loading window.** Per [Preserve user-typed intent through dependency loss] above: only DERIVED values fade through loading. User inputs (Send amount, custom limit price, search query) stay at full ink. The skeleton replaces what the system is computing; what the user typed is what they typed. Reference: `ui-amount-card.js → enterLoadingState()` skeletons the four derived slots (Send fiat / Send balance / Receive amount / Receive fiat) and never touches the Send `<input>` value or the picked token chips.

4. **Stamp on entry-to-loading-when-empty AND on deliberate user refresh; preserve previous render on passive refresh.** Three stamp triggers, one preserve trigger:
   - **Empty list** (first render after picker / persona change that cleared content, OR the empty-state div is showing) → stamp skeletons.
   - **Deliberate user refresh** (manual click on the refresh-timer button — `refresh-timer:expired` with `detail.source === "click"`) → stamp skeletons. The user pressed a button asking for fresh data; the loading window should be unmistakable. Once per user action ≠ jitter.
   - **Passive refresh** (typing debounce, sim event, timer auto-expiry — anything triggered by the system, not the user) → preserve the previous render. Flashing skeletons on every 400–800 ms input cycle or every 60 s auto-tick reads as jitter, not progress. The previous routes are usually numerically close enough to bridge the loading window.

   Pass the trigger through the consumer's render API explicitly — don't infer from DOM state. Reference: `ui-quote-list.js → renderList(listEl, opts)` gates on BOTH `hasRealCards = listEl.querySelector('.ui-quote-card:not(.is-skeleton)')` AND `opts.forceSkeleton`; the playground bridge in `playground.html` reads `e.detail.source` and forwards `forceSkeleton: source === 'click'`.

**Always-on shimmer; no opt-in modifier.** A static skeleton looks like a layout shift, not a load state — the shimmer IS the affordance signal. Reduced-motion users get a static tinted block via `@media (prefers-reduced-motion: reduce)` — the slot still reads as "content pending" without the continuous motion. If a future consumer ever needs a truly static skeleton (documentation thumbnails, print stylesheets), extend with `.skeleton--static` as a targeted opt-out — but it's not the default. Per [Primitive retirement is atomic — no aliases]: never author a `--shimmer` modifier alongside the always-on default.

**Skeleton-card consumers wear `.is-skeleton`, not a `--loading` modifier.** When a clickable card primitive renders a skeleton variant (e.g., `.ui-quote-card.is-skeleton`), the state class suppresses `cursor: pointer` + hover bg + active rim — the placeholder doesn't pretend to be interactive — while the surface chrome (bg / border / shadow / padding) stays so layout reads identically when real cards swap in. Same pattern for `.list-item.is-skeleton` (picker rows). No need to fork into `.ui-quote-card-skeleton` / `.list-item-skeleton` parallel primitives; the modifier composes.

**Where the rule is enforced.**

- **CSS** — `styles.css` § search `.skeleton {` (component) and `@keyframes skeleton-shimmer` (animation). `.ui-quote-card.is-skeleton` rules in `swap.css`; `.list-item.is-skeleton` rules in `swap.css → .token-list-module / .chain-list-module` blocks.
- **Catalog** — `design-system/index.html#skeleton-component` (the canonical rendering — shape gallery + size ladder + in-context mock compositions).
- **JS helpers** — `ui-quote-list.js → renderRouteCard(route, …)` is single-source: `route === null` is the skeleton path (slot helpers dispatch on `opts.isSkel`); `renderSkeletonList(listEl, count)` loops the null call. `ui-amount-card.js → enterLoadingState()` + `renderAllWithLoadingPhase()` (persona/wallet swap). `picker-render.js → renderSkeletonTokenList()` + `renderSkeletonChainList()` (picker lists, exposed on `window.LifiPickerRender`) — these remain parallel for now; converge to the single-source pattern when the picker rows next need a structural change.
- **Live wirings** — `.ui-quote-list[data-state="loading"]` (stamps on every fresh render), `.ui-amount-card[data-state="loading"]` (600 ms phase on `persona-change` / `wallet-change` events). The picker list helpers are turn-key but not yet wired to a live trigger — see follow-ups below.

**Open follow-ups (live wiring lands when the data layer comes online).**

- `.token-list-module[data-list-variant="popular" | "portfolio"]` — wire `renderSkeletonTokenList()` to the eventual `tokens.json` / portfolio-fetch path. Today the playground ships pre-populated server-rendered ready data, so there's no fetch window to paint into.
- `.chain-list-module` — same shape; `renderSkeletonChainList()` ready to call.
- `.ui-limit-price-card` — the spot rate is currently synchronous from the sim. When the real API replaces `getMarketRate()`, wire a skeleton paint on the input slot during the fetch.
- Future portfolio rows / activity feed / order history — all consume the same `.list-item.is-skeleton` pattern that the picker rows demonstrate.
- The page-level `.refresh-timer` composite already communicates "refreshing" via the spinner — DO NOT layer skeletons over the refresh window. The countdown → refreshing → done cycle is "UI mid-action" (spinner territory), not "new content arriving" (skeleton territory).

### Flex containers + `text-overflow: ellipsis` — recurring bug

`text-overflow: ellipsis` does **not** render the "…" character on a `display: inline-flex` (or `display: flex`) container that holds an anonymous text child. The text gets pixel-clipped by `overflow: hidden`, but the ellipsis never paints — so a too-long label looks broken (mid-word cut, no dots) instead of clearly truncated. Same applies to `display: grid`. The fix has three shapes; pick whichever matches the component's needs:

1. **Pure-text items** — use `display: inline-block` (or `block`). Ellipsis works natively.
2. **Items that may carry an icon AND text** — keep `display: inline-flex` for layout, but flip back to `inline-block` when there's no icon via `:has()`:
   ```css
   .item { display: inline-block; text-align: center; /* + truncation props */ }
   .item:has(> svg) { display: inline-flex; align-items: center; gap: var(--space-8); }
   ```
3. **Items that ALWAYS carry icon + text and need to truncate** — keep `inline-flex` but wrap the label in a child `<span>` with the truncation props applied to the span (not the parent). The flex container is no longer the truncation context; the span is.

The bug history that prompted this rule: Apr 2026 — `.seg-item` shipped with `display: inline-flex` so a leading `<svg>` + label could share a centered baseline, plus `overflow: hidden; text-overflow: ellipsis; white-space: nowrap` for truncation. The CSS LOOKED right; long labels DID clip; but no ellipsis rendered, making the truncation feel broken. Reference: `.seg-item` in `styles.css` and the catalog anatomy note at `#segmented-control` → Layout modifiers → Anatomy.

### `flex-basis` is not a definite size in a shrink-to-fit container — recurring bug

When a flex item must hold a fixed size regardless of its content, `flex: 0 0 <size>` is **not enough** if the flex container is sized under an intrinsic constraint (shrink-to-fit, `fit-content`, `max-content`, floated, absolutely positioned). In that pass a `flex-basis` is treated as a **content-based contribution, not a definite size**, so the item resolves to its content's max-content width — and `flex-shrink: 0` never enters into it, because nothing is "shrinking." Give the item an explicit **`width`** alongside the basis; a definite `width` is authoritative where a basis isn't.

**The tell:** the same element renders correctly in one state and collapses in another, purely because the content changed width — while `getComputedStyle` insists the basis and shrink are what you set. Grid layouts are immune (an explicit track is definite), so "broken in the flex layout, fine in the grid layout" is the signature.

**Don't chase the matched-rules walk** — it reports the basis and looks right. Diff the SAME element across both states, walk the ancestor chain for the container whose width is far below its parent's (that's the shrink-wrap), then empirically pin candidates (`el.style.width = …`) to find the level the fix belongs at.

Canonical case (July 2026): `.widget-stage__form` computed `flex: 0 0 416px` / `flex-shrink: 0` yet rendered 336 px in the Settings state, because `.widget-stage` shrink-wraps. Fixed with `width: 416px`. Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → \`flex-basis\` collapses to content width in a shrink-to-fit flex container`.

### Flex:1 sibling visibility — hide the container, not the children — recurring bug

When a flex container holds multiple `flex: 1` siblings and you want to swap one out for an alternate-state sibling (an `.empty-state`, a loading skeleton, an error region, a "no results" panel), **hide the sibling ITSELF, not its children.** Hiding only its children collapses the visible content but leaves the parent flex item still claiming its flex share — and the alternate-state sibling (also `flex: 1`) then splits the available height with the invisible-but-still-flexing original, ending up off-center.

```css
/* ✗ Hides what you SEE but keeps the flex contribution.
       The .original is still flex:1 — claims half the container.
       The .empty-state sibling (also flex:1) gets pushed to the
       other half instead of centering across the full height. */
.container[data-empty] > .original > .header,
.container[data-empty] > .original > .canvas {
  display: none;
}

/* ✓ Hides the WHOLE flex item — out of layout entirely.
       The .empty-state is now the only flex child; it fills the
       full container and its content centers correctly. */
.container[data-empty] > .original {
  display: none;
}
```

**Why the "hide children" pattern is tempting and wrong.** It feels surgical — preserve the parent's identity, just hide what you don't want visible. And in non-flex contexts (block siblings, grid items with `grid-area: auto`) it works fine. The trap is specific to flex containers where the hidden sibling has `flex: 1` (or any non-zero `flex-grow`). The parent flex item's geometry is determined by the flex algorithm against its `flex` value, not by its content — so empty content doesn't shrink the slot.

**Generalizes to every sibling-swap in a flex container.** Loading-state swaps (chart canvas → skeleton), result-state swaps (data table → "no results" empty), error-state swaps (form → error panel), persona swaps (real content → placeholder) — same trap whenever the original sibling has `flex: 1` and shares a flex container with the alternate sibling. The original sibling has to come out of layout entirely (`display: none`), not just have its content hidden.

**Diagnostic.** When a centered alternate-state child isn't centered, read every direct child's bounding box + computed display. The bug looks like:

```js
const parent = document.querySelector('.flex-container');
[...parent.children].map(c => ({
  el: c.tagName + '.' + (c.className || ''),
  display: getComputedStyle(c).display,
  flex: getComputedStyle(c).flex,
  rect: c.getBoundingClientRect()
}));
// A flex child with display:flex + flex:1 + non-zero height
// whose inner content is all display:none is the trap.
```

**Library compatibility — display flips are safe for canvas-based widgets.** Lightweight Charts (and most modern chart / map libraries with `autoSize: true`) handle the `display: none` ↔ `display: flex` transition cleanly via `ResizeObserver`. The canvas re-renders when its container un-hides; no manual re-init / re-fit needed. Verified on the `.ui-price-chart` consumer May 2026 — canvas resumed at 348 px width on un-hide.

**Bug history.** May 27 2026 — Limit-mode `.ui-price-chart` empty-state wasn't centered because the chart's CHILDREN were hidden (the `flex:1` slot still claimed height) instead of the chart itself. Full diagnostic + recovery checklist: `.claude/skills/lifi-ds-docs/references/bug-history.md → Flex:1 sibling visibility — hide the container, not the children`.

### Swap flex visual order with `order: -1`, never `flex-direction: row-reverse` — recurring bug

When you need to swap the visual order of two flex children WITHOUT changing markup (e.g., a modifier that flips `[avatar][label]` to `[label][avatar]`), the reflex is `flex-direction: row-reverse`. **That's wrong** — reversing the main axis ALSO inverts what `justify-content: flex-end` means. With `flex-direction: row-reverse`, `flex-end` packs items to the LEFT (the visual end of the reversed axis), not the RIGHT. Any layout that relied on right-pack now drifts to the left half of the container.

The surgical answer: `order: -1` on the target child. This bumps that child ahead of its siblings in flex flow WITHOUT touching the main axis direction. `flex-direction` stays `row`, `justify-content` keeps its original semantics, the right-pack stays at the right edge.

```css
/* ✗ Bug — row-reverse inverts justify-end, drags the cluster to the wrong side */
.foo--swap .container {
  flex-direction: row-reverse;  /* now flex-end means LEFT */
}

/* ✓ Fix — order on the target child, axis untouched */
.foo--swap .label {
  order: -1;  /* visually first, but main axis still left-to-right */
}
```

**When to reach for which:**

| Goal | Use |
|---|---|
| Swap two children's visual positions, keep main axis + justify semantics | `order` on the target child |
| Reorder FOCUSABLE children whose visual order is a decision/reading sequence | **Reorder the MARKUP** — not `order` (see below) |
| Actually reverse the whole flow direction (e.g., RTL-style layout, or a row that genuinely reads right-to-left) | `flex-direction: row-reverse` (and audit every `justify-*` and `align-*` for the inverted axis) |

**Focus-order caveat — `order` is visual-only; it does NOT move DOM / tab / reading order.** CSS `order` (and `flex-direction`, and `grid` placement) repaints children at new positions but leaves the DOM sequence untouched — so keyboard tab order, screen-reader reading order, and "what comes next" all still follow source order. When the reordered children are **focusable** AND their visual order conveys a sequence the user reads or acts on (the canonical case: a primary CTA that must be the *last* thing reached, sitting visually below a settings summary), a CSS `order` swap desyncs visual order from focus order — a WCAG 2.4.3 (Focus Order) failure: a keyboard user tabs to "Review" before the settings they're meant to review. **Reorder the markup instead** so DOM = visual = tab = reading order. Reserve `order` / `row-reverse` for reordering non-focusable or order-insensitive content (icon-before-label, decorative swaps). Bug history: May 2026 — the Dual-layout swap footer was moved below the quick-settings rail via a **markup reorder** (not `order`) precisely so the Connect/Review buttons stay last in tab order; quick-settings is `display:none` outside Dual, so one markup order is correct in every layout.

**Bug history.** May 2026 — `.bar-chart--label-first` v1 used `flex-direction: row-reverse` to flip the avatar/label order. The rotation pivot expects the cluster's right edge at SVG local x=0 (the bar's center x after `-45°` rotation). With row-reverse, `justify-end` packed the cluster to the LEFT half of the foreignObject, so after rotation the avatar+label rotated DOWN-LEFT past the SVG bottom edge — labels painted off-chart. v2 switched to `order: -1` on the label; cluster right-pack preserved, rotation behaved correctly. Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → .bar-chart--label-first design tension`.

### `mask-image` clips `box-shadow` — use `filter: drop-shadow()` instead — recurring bug

The moment you put `mask-image` on an element, its `box-shadow` is silently clipped to nothing visible. Per the CSS spec, filters paint BEFORE masks — and `mask-clip` defaults to `border-box`, which trims the element's painted output (including outset shadow and outset drop-shadow filter results) back to the box. The CSS LOOKS right (`box-shadow: var(--elev-1)` or `filter: var(--elev-1-filter)` is still computed), but the shadow is invisible — and the element looks flat against whatever surface is behind it.

**The right fix depends on whether the masked element stands alone or sits in a group.** There are two patterns:

| Situation | Pattern |
|---|---|
| **Group of masked elements** whose cutouts touch each other (e.g., paired cards with a FAB cutout at the seam) | **Host the filter on an unmasked parent.** Drop `box-shadow` on the children, set `filter: var(--elev-N-filter)` on the parent. The parent's filter 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. **Preferred when applicable** — no per-element overrides, the shadow naturally traces the group's shape. |
| **Standalone masked element** with no parent to host the filter | Drop `box-shadow`, apply `filter: var(--elev-N-filter)` to the element itself, AND set `-webkit-mask-clip: no-clip; mask-clip: no-clip;` so the drop-shadow output can paint past the border-box. The `no-clip` is non-negotiable in this case — without it the filter is computed but the mask trims it back to invisibility. |

The parent-filter pattern wins when available because it solves the order-of-operations issue at the architectural level: the parent has no mask, so its filter operates on the already-composited children (masked silhouette + cutouts) without being clipped. Standalone consumers can't reach for it — they need the `mask-clip: no-clip` workaround instead.

**The system has companion filter-form tokens for every elevation tier:**

```css
/* Companion to --elev-N — same recipe, drop-shadow form, theme-aware. */
--elev-1-filter
--elev-2-filter
--elev-3-filter
```

Defined in `styles.css` `:root` (dark) and `[data-theme="light"]` (light), right next to their `--elev-N` siblings. The inset rim-highlight layer (which sells the lift in dark mode) doesn't have a `drop-shadow` equivalent and is omitted; the outset lift layers carry the visual weight.

**`box-shadow` is the canonical elevation method; the filter form is its value-matched twin, used ONLY where masking forces it.** Reach for `box-shadow: var(--elev-N)` on every surface by default (63:1 in the codebase). A non-masked element must **never** use the filter form. Leaking `filter: var(--elev-N-filter)` onto an unmasked card was the `.ui-destination-wallet-card` mistake (June 2026): it sits next to the masked `.ui-amount-pair` and got switched to filter "to match," which spread a second method instead of reconciling. The fix was to revert it to `box-shadow` and **value-match the filter token** so the lone masked surface matches its box-shadow neighbours.

**Author each `--elev-N-filter` to value-match its `--elev-N` box-shadow twin — mirror the OUTSET layers, chained.** For a multi-layer box-shadow, chain one `drop-shadow()` per outset layer at the same offset / blur / opacity:

```css
/* --elev-1 box-shadow (light): two outset layers */
--elev-1: 0 1px 2px rgba(0,0,0,0.06), 0 1px 1px rgba(0,0,0,0.04);

/* ✓ Value-matched twin — mirror both layers, chained. Renders the same shadow. */
--elev-1-filter: drop-shadow(0 1px 2px rgba(0,0,0,0.06))
                 drop-shadow(0 1px 1px rgba(0,0,0,0.04));

/* ✗ Approximation — single drop-shadow summing the opacities (6%+4%≈10%). Reads
   SOFTER than the box-shadow (no tight inner layer); a designer caught the
   mismatch against box-shadow cards. Don't use it for parity. */
--elev-1-filter: drop-shadow(0 1px 2px rgba(0,0,0,0.10));
```

**Chained `drop-shadow()` does NOT self-cancel at small blurs — both layers render.** A prior version of this rule claimed chaining "compounds, self-cancels, renders near-invisible" and mandated a single summed drop-shadow; that was **wrong** (refuted June 2026 by an isolated A/B test at both 6%/4% and amplified 40%/30% — the chained square rendered a full shadow comparable to box-shadow). The compounding is real but tiny: the second `drop-shadow` also casts a faint shadow OF the first shadow, which at 1–2 px offsets is imperceptible. `--elev-2-filter` / `--elev-3-filter` were already chained for exactly this reason. The genuine caveat is only at VERY large blur radii (elev-3's 56 px), where the shadow-of-a-shadow can smear — accept it or tune; the negative box-shadow spread is dropped there anyway (filter has no spread param).

**The `inset` rim-highlight layer has no `drop-shadow` equivalent and is dropped** — `filter` can't do `inset`. This is the one unavoidable delta: in dark mode a masked surface lacks the 4 % white top-edge rim its box-shadow neighbours have (imperceptible in practice; verified). Light mode has no rim, so no delta there. The dark `--elev-1-filter` stays a SINGLE drop-shadow because the dark `--elev-1` box-shadow has only ONE outset layer (its second layer IS the inset rim) — single already value-matches the dark outset.

**Pattern A — group of masked elements (preferred).** Host the filter on the unmasked parent; the children keep their masks but drop their own shadow:

```css
.masked-group {
  filter: var(--elev-1-filter);      /* paints around combined silhouette */
}
.masked-group > .masked-child {
  mask-image: radial-gradient(…);    /* per-child cutout */
  --child-shadow: none;              /* or box-shadow: none if not slotted */
}
```

Canonical example — `.ui-amount-pair` hosts the filter, the two `.ui-amount-card` children carry the FAB cutouts:

```css
.ui-amount-pair {
  filter: var(--elev-1-filter);                  /* elevation on the unmasked parent */
}
.ui-amount-pair > .ui-amount-card:first-of-type,
.ui-amount-pair > .ui-amount-card:last-of-type {
  --amount-card-shadow: none;                    /* suppress per-card box-shadow */
  /* mask-image: …radial cutout… */              /* set in the per-child rule below */
}
```

**Pattern B — standalone masked element (fallback).** When there's no parent to host the filter, apply the filter to the masked element itself AND set `mask-clip: no-clip` so the drop-shadow output isn't trimmed:

```css
.masked-element {
  mask-image: radial-gradient(…);    /* the cutout */
  -webkit-mask-clip: no-clip;        /* don't trim drop-shadow to border-box */
          mask-clip: no-clip;
  box-shadow: none;                  /* mask-clip would clip outset paint */
  filter: var(--elev-1-filter);      /* free to paint past the box */
}
```

This works but the shadow on the cutout-facing edge will have a gap where the cutout chord interrupts the silhouette — for a standalone element that's usually fine (no neighbour to make it look broken). For a paired/grouped case, prefer Pattern A.

**Bug history.** May 2026, four sequential fixes (v1 filter form → v4 hoist the filter to the unmasked parent — the architectural fix). **Full v1–v4 diagnostic** + recovery checklist for compounded filter tokens: `.claude/skills/lifi-ds-docs/references/bug-history.md → mask-image clips box-shadow`.

**When to reach for which form:**

| Element state | Use |
|---|---|
| Not masked | `box-shadow: var(--elev-N)` |
| Masked AND in a group with a shared parent | **Pattern A** — `filter: var(--elev-N-filter)` on the parent, drop per-child shadow |
| Masked AND standalone (no parent available) | **Pattern B** — `filter: var(--elev-N-filter)` + `mask-clip: no-clip` on the element |

`filter` is generally more expensive than `box-shadow` (it triggers compositing), so don't switch every component over. Use it only when masking forces you to.

### Slide-in containers — swap overflow via `allow-discrete`, not `overflow-clip-margin` — recurring bug

When a container hides via `flex-basis: 0` (or `width: 0`) and slides in to its target size — `.widget-stage__receive` is the canonical case, 0 → 416 px over 280 ms — it needs **some** form of overflow containment during the animation so the inner full-width content doesn't leak out of the 0-width slot. At rest, that same containment trims descendant shadows. The reflex to reach for `overflow: clip` + `overflow-clip-margin: var(--space-24)` as a static workaround **fails the moment a descendant has elev-3** (24 px y-offset, 56 px blur — the panel-material tier) — the clip margin trims the elev-3 paint into a hard band cutoff. A clip margin that tracks the largest descendant elev tier is fragile (silently breaks if a child is upgraded to elev-3, or if elevation tokens move), and the better answer is no-clip-at-rest.

**The right shape — discrete swap via `transition-behavior: allow-discrete`:**

```css
.slide-in-container {
  /* default / transitioning — containment for the 0-width slot */
  overflow: clip;
  flex-basis: 0;
  opacity: 0;
  transform: translateX(-16px);
  transition:
    flex-basis 280ms ease-out,
    opacity    220ms ease-out 40ms,
    transform  280ms ease-out,
    overflow   280ms allow-discrete;   /* discrete prop flips mid-transition */
}
.host[data-state="visible"] .slide-in-container {
  flex-basis: 416px;
  opacity: 1;
  transform: translateX(0);
  overflow: visible;                   /* at rest — shadows paint freely */
}
```

`allow-discrete` flips discrete properties (like `overflow`) at 50 % of the transition by default. By 140 ms into a 280 ms slide-in the container is mostly extended; the brief content reveal in the remaining slide is imperceptible against the transparent flex slot. At rest, `overflow: visible` lets descendant shadows — including the container's own panel-material `--elev-3` cast shadow — fade naturally.

**Why not `overflow-clip-margin` for animated cases:** the margin would need to track the largest descendant elev tier. Today `--elev-1` (≤ 2 px blur) and `--elev-2` (≤ 14 px blur) clear comfortably at 24 px, but `--elev-3` (56 px blur) needs ≥ ~80 px. The next contributor who adds an elev-3 child silently re-introduces the hard-band cutoff. `allow-discrete` is mechanism-stable — it doesn't care how big the shadows are.

**When to reach for which form:**

| Container state | Use |
|---|---|
| Static, no animation, audited descendant shadows ≤ elev-2 | `overflow: clip` + `overflow-clip-margin: var(--space-24)` |
| Static, no animation, any descendant at elev-3 | `overflow: visible` (don't clip) — let shadows paint, the container box is the layout boundary |
| Animated slide-in (flex-basis or width animating from 0) | `overflow: clip` (default) + `overflow: visible` (at rest), swapped via `transition-behavior: allow-discrete` |

**When chasing shadow clipping, walk the ancestor chain.** Multiple layers can independently clip — the fix may need to touch more than one. The diagnostic that surfaces it:

```js
(() => {
  const el = document.querySelector('.your-clipped-child');
  const out = []; let p = el;
  while (p && p !== document.body) {
    const s = getComputedStyle(p);
    out.push({
      el: p.tagName + '.' + (typeof p.className === 'string' ? p.className.split(' ').slice(0,3).join('.') : ''),
      overflow: `${s.overflowX}/${s.overflowY}`,
      clipMargin: s.overflowClipMargin,
    });
    p = p.parentElement;
  }
  return out;
})()
```

**Bug history.** May 2026, two passes on `.widget-stage__receive` (v1 `overflow: clip` + clip-margin trimmed elev-3 → v2 `allow-discrete`). **Full v1–v2 diagnostic** + the ancestor-walk technique: `.claude/skills/lifi-ds-docs/references/bug-history.md → Slide-in containers — overflow vs. shadows`.

**Related — STATIC scroll containers clip child shadows too (June 2026).** The animated case above is one face of a broader trap: ANY `overflow: auto|scroll|hidden|clip` container clips a child's outset `box-shadow` at its box edge **even when the content fits and nothing is scrolling** — and `overflow-y: auto` silently forces `overflow-x: auto`, so BOTH axes clip. For a non-animating scroll container (a modal body, a list panel), the fix isn't `allow-discrete` — it's a **padding gutter on the scroll content** (≥ the shadow's extent on every edge the shadow shows). Diagnosed twice on the wallet-connect wizard's `.list--cards` inside `.modal-body`. Full entry: `.claude/skills/lifi-ds-docs/references/bug-history.md → overflow: auto clips child shadows even when NOT scrolling`.

### Grid-rows `0fr → 1fr` accordion — the collapsing item must be bare; padding goes on a nested child — recurring bug

The modern layout-shift-free accordion/drawer reveal is `display: grid; grid-template-rows: 0fr` on the wrapper (transitioning to `1fr` when open) + a single child with `min-height: 0; overflow: hidden`. The child clips while the track animates from 0 to content height. It's the right mechanism — no `max-height` magic-number jank.

**The trap.** If that collapsing child carries its OWN `padding` (or border), the track won't collapse to zero — it floors at the padding height. The symptom: an "open/closed" drawer that's supposed to be hidden still shows a sliver of content (the padding band) peeking below the header. `min-height: 0` zeroes the *content-box* minimum, but the item's padding sits outside the content box and still contributes to the grid item's minimum contribution, so the `0fr` track resolves to `padding-top + padding-bottom`, not 0.

```css
/* ✗ Bug — padding on the collapsing item floors the 0fr track at 16px */
.drawer { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.28s ease; }
.drawer-inner { min-height: 0; overflow: hidden; padding: 4px 12px 12px; }  /* 4+12 = 16px sliver */

/* ✓ Fix — collapsing item is bare; padding lives on a nested body wrapper */
.drawer { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.28s ease; }
.drawer-inner { min-height: 0; overflow: hidden; }          /* bare — collapses to 0 */
.drawer-body { padding: 4px 12px 12px; display: flex; flex-direction: column; gap: 16px; }
```

Note **horizontal** padding (`padding-left` / `padding-right`) is safe — it doesn't affect the vertical track sizing. Only `padding-top` / `padding-bottom` (and top/bottom borders) floor the collapse. So the three-level nesting (grid → bare collapser → padded body) is only needed when you want vertical interior padding on the drawer.

**Diagnostic.** When a grid-rows drawer won't fully hide, inspect the wrapper's computed `grid-template-rows` — if it reads a non-zero px value at the closed state (e.g. `16px`) instead of `0px`, the collapsing child has vertical padding/border. Measure the child's `padding-top + padding-bottom`; it'll match the leak.

**Bug history.** May 2026 — `.option-card--expandable` (the playground Corners "Custom" card, since retired) shipped its first pass with `padding: 4px 12px 12px` on the collapsing grid item. Closed state leaked 16px (4+12) of the first slider's "Panel radius" row. Computed `grid-template-rows` read `16px` not `0px`. Fix: a bare collapser (min-height:0 + overflow:hidden only) wrapping a padded body that owns the flex layout + padding. The canonical grid-rows accordion shape is grid → bare collapser → padded body — **encoded once as the universal `.reveal` / `.reveal__inner` primitive (June 2026 standardization sweep)**; reach for `.reveal` instead of hand-rolling the mechanics.

### Hover-expand / collapse animation — animate horizontal-only, never flip a layout property at the boundary — recurring bug

When a container animates its **width** on hover-expand (a collapsible nav rail, a drawer, a slide-out panel — the canonical case is `.app-rail--mini`, the Portal mini rail: 72 collapsed → 320 on `:hover`), the reflex is to also flip a layout property at the hover boundary so the collapsed state "looks right" — `justify-content: center` ↔ `flex-start`, `display: grid` ↔ `flex`, `padding: 0` ↔ full, `min-height` on/off, `display: none` on trailing content. **Every one of those flips instantly at t0** (CSS toggles a discrete layout property in one frame) **while the width eases over ~260 ms** — so the marks snap to their expanded positions immediately and then sit still while the rail keeps widening around them. The eye reads it as "the icons jump, then the rail catches up." Four rules keep it smooth:

1. **Don't flip a discrete layout property at the hover boundary while width animates.** `justify-content` / `display` / `padding` / `min-height` all snap at t0. Keep them identical in both states.
2. **Center collapsed marks via `transform: translateX(offset) → 0`,** not a `justify-content: center → flex-start` flip. A justify flip snaps center→left in one frame; a transitioned transform travels proportionally with the width. Measure each mark's flex-start-center → rail-centerline delta and animate that offset to 0.
3. **Transition heights/paddings; set an explicit expanded height** (CSS can't transition to `auto`) so a growing card grows smoothly instead of popping.
4. **Pin a zone's `min-height` in BOTH states.** A `min-height` gated on `:not(:hover)` is *removed* at t0 — so while a card inside is mid-grow the zone collapses and yanks every sibling below it up, then they settle back down (the "jump up then down in transit" symptom). Pin it unconditionally.

To clip trailing content to zero width without losing height (so the row's vertical metric stays put), use `max-width: 0; min-width: 0; margin-inline: 0; padding-inline: 0; overflow: hidden; white-space: nowrap` — **never `display: none`** (it drops the height and re-breaks vertical stability) and **never zero the vertical padding** (it shrinks the row). Fade text-bearing trailing slots (wordmark, label) in *after* the rail opens via `opacity` + `transition-delay ≈ expand duration`. Use asymmetric `transition-delay` for intent friction: a short OPEN delay on the `:hover`/`:focus-within` rule (graze guard) + a longer CLOSE linger on the resting rule.

**Verify with slow-mo proportional-travel sampling** (see bug-history) — a smooth mark travels in proportion to the rail width; a snapping mark front-loads. Confirm `dTop` / `dH` = 0 between collapsed and expanded for every row.

**Source of truth.** The full reusable spec (standard ease, durations, the four rules, fade-after-expand, slow-mo method) lives in `design/components/navigation.md → Navigation expand/collapse — motion rules`; the implemented primitive is `styles.css → .app-rail--mini`. This CLAUDE.md entry is the operative one-liner + pointer. Diagnostic archaeology: `.claude/skills/lifi-ds-docs/references/bug-history.md → Hover-expand rail transit jump`.

### Data-table row alignment — rows on the 4-grid, flex-center cell content (NOT `vertical-align: middle`) — recurring bug

When a data-table row mixes content of different heights (avatar + text + chip), getting them to vertically align is a sub-pixel minefield. The vertical misalignment that designers catch but `getBoundingClientRect`/`Range` center math misses comes from **half-pixel positions** introduced by three things, all of which must be killed:

1. **`line-height: normal` on table cells** → fractional strut → fractional cell heights. Use an integer/token line-height.
2. **`border-collapse: collapse` splits a shared 1px border** — a header `<th>` with `border-bottom: 1px` computes to `32.5px` (0.5px of the split border), which knocks the entire tbody onto half-pixel tops. Use **`border-collapse: separate; border-spacing: 0`**, and render header dividers with `box-shadow: inset 0 -1px 0` (zero layout height) instead of a border.
3. **`vertical-align: middle` centers on the font's fractional x-height** — so any inline content centered that way lands ~0.5px off. The tell: a chip with no avatar is *also* off, proving it's the centering method, not the content.

**The fix, in order:**
- **Pin every row + header to a `--space-*` token height with `box-sizing: border-box`** (e.g. a 40px-avatar row → `--space-56`; header → `--space-32`). Rows must land on the 4-grid — a half-pixel row rasterises the avatar cell off the text cells.
- **Center cell content with a block-level flex wrapper, not `vertical-align: middle`.** Wrap each cell's content in `<div class="…cell"><…></div>` styled `display: flex; align-items: center` (justify-end for right-aligned cells). The `<td>` then centers that BLOCK geometrically (integer), and flex centers the content inside — neither touches x-height. This is the only reliable way to align avatar + text + chip on one line.

**Don't reach for a magic line-height nudge** (e.g. `calc(23/16)` to flip the rounding on one label) — it patches a single element, leaves the chips off, and drifts if the font/size/avatar changes. The flex approach is geometric and survives those. **Residual to accept:** chip pills sit ≤0.5px off the text line (pill internal metrics vs the row line-box — the sub-pixel floor; negligible for pills).

**Diagnose by trusting the eye over the number.** Box-center measurement is layout-level and blind to sub-pixel rasterization — if the user says misaligned but your math says aligned, inject a 1px reference line at the row center + CSS-zoom and look. (See [[screenshot-before-defending]].) Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → "Data-table vertical alignment"`. Reference implementation: the W4 Orders table (`design/components/orders-table.md`).

### Floating element clipped by ancestor `overflow: hidden|clip` — recurring bug, Popover-API is the system-wide answer

`z-index` controls stacking order within a context — it does NOT control paint extent. When a floating element (tooltip, dropdown panel, popover content) is `position: absolute` inside a wrapper nested under any ancestor with `overflow: hidden | clip`, the floating element is clipped at that ancestor's box edge regardless of stacking. Bumping z-index will not help. Same family of trap as `mask-image clips box-shadow` (filters/layers vs. clipping) — the painter trims paint output at the ancestor boundary, end of story.

**Where it bites.** `.ui-card` carries `overflow: hidden` to support the viewport-cap feature (`body.playground-body[--widget-max-h]`). Tooltips inside quote-card footer cells, dropdowns rendered below settings rows (the limit-expiry dropdown was clipped 113 px from its panel bottom in May 2026), or any anchored popover that extends past the card's edge gets trimmed.

**Diagnostic.** Walk the ancestor chain looking for `overflow: hidden | clip`:

```js
(() => {
  const tt = document.querySelector('.your-floating-element');
  const chain = [];
  let p = tt && tt.parentElement;
  while (p && p !== document.body) {
    const cs = getComputedStyle(p);
    if (cs.overflowX !== 'visible' || cs.overflowY !== 'visible') {
      chain.push({ tag: p.tagName, cls: typeof p.className === 'string' ? p.className.slice(0, 60) : '', overflow: `${cs.overflowX}/${cs.overflowY}` });
    }
    p = p.parentElement;
  }
  return chain;
})()
```

If the chain returns ANY ancestor with `overflow: hidden | clip`, that's the clipper. z-index won't fix it.

**The fix — Popover API + position:fixed + JS positioning.** Popovers render in the browser's top-layer, escaping every overflow clip on the page. Browser support is GA as of May 2026 (Chrome 125+, Safari 17+, Firefox 125+). The recipe:

1. **Markup** — add `popover="manual"` to the floating panel element. `manual` keeps app-level control over open/close (vs. `auto` which adds light-dismiss the app may already handle).
2. **CSS** — `position: fixed` + drop static `top`/`left`. Add `:popover-open` for the visible state and `@starting-style { :popover-open { … }}` to fire the entry transition (popovers go from `display: none` → `display: block` so a normal `from → to` transition wouldn't kick in without it). Use `transition: … display 0.18s allow-discrete, overlay 0.18s allow-discrete` so the exit transition runs before `display: none` lands.
3. **JS** — on open, `panel.showPopover()` then compute `getBoundingClientRect()` of the trigger and write `top` + `left` (or `right`, for align-end variants) on the panel. Reposition on `scroll` + `resize` while the panel is open. On close, `panel.hidePopover()`.

The canonical implementation lives in `shared.js → initDropdown()` and `styles.css → .dropdown__panel` (May 2026). Anchor Positioning (CSS-only positioning via `anchor-name`/`position-anchor`) would be cleaner but isn't yet GA in Firefox (as of May 2026), so JS positioning stays the universal fallback.

**Per-instance flip-inward fallback.** For tight one-off cases where a full popover migration isn't worth the scope, the legacy per-instance flag (e.g., `data-tooltip-align="end"` on the tooltip primitive) anchors the floating element's edge to the trigger's edge so it extends INWARD instead of past the card. Useful as a tactical patch before a primitive's popover migration lands.

**Bug history.**
- May 2026 — ETA tooltip extended 34 px past the receive panel's `.ui-card` right edge. Applied per-instance `data-tooltip-align="end"` patch; `.tooltip-wrap` / `.tooltip` migration to popover API is still pending (a follow-up task).
- May 2026 — limit-expiry dropdown panel clipped 113 px at the .ui-card bottom. Confirmed via the ancestor-walk diagnostic above. Migrated the `.dropdown` primitive (CSS + JS + 7 catalog demos + 1 playground instance) to the Popover API in one atomic edit. Same recipe applies to any future floating primitive that hits this trap.

The retired-z-index-was-wrong lesson is the critical one: don't waste cycles bumping `z-index` when `overflow: hidden` is the clipper.

### Reveal animations — content and its chrome share ONE state gate — recurring bug

When a component's reveal animates TWO things — the content plus a backdrop / pill / frame (the navbar mega-dropdown + its glass-pill extension is the canonical case) — drive BOTH off a **single state class** (`.nav-open`-style, owned by JS), never one off a JS class and the other off `:hover`. Two independent gates desync the instant one's trigger fires without the other's, and the `:hover`-as-open-gate variant is especially treacherous: (a) it's invisible to synthetic-event tests (`dispatchEvent` doesn't set UA `:hover`), and (b) `:hover` on an ancestor stays true across internal child↔child cursor moves that the JS state machine treats as "close" — so content lingers painted on the canvas while the chrome collapses. Prefer JS to own the open state via `mouseenter`/`mouseleave` on the WHOLE subtree (a panel that is a DOM child of its trigger's `<li>` needs NO separate panel handlers — the parent's events already cover both), and let CSS read only that class. **Corollary — one expansion paradigm per surface:** when a bar/menu grows a second expansion style (e.g. a compact popover beside a mega panel), unify on the established one rather than letting two reveal mechanics coexist; the More overflow reusing `.nav-dropdown` (June 2026) is the precedent. Full diagnostic (both passes — the measurement-hack first fix AND the gate-independence recurrence): `.claude/skills/lifi-ds-docs/references/bug-history.md → Navbar dropdown desync`.

### Measuring hidden elements — `offsetHeight` reads layout directly; force-visible hacks snap CSS-driven transitions — recurring bug

`offsetHeight` returns the laid-out content height regardless of `visibility: hidden`, `opacity: 0`, or `position: absolute`. Those are paint/transparency properties, not layout. **Only `display: none` zeros it.** So a "force-visible" measurement hack — temporarily flip `dd.style.transition = 'none'` + `visibility: visible` + `opacity: 0` + `transform: none`, read `offsetHeight`, restore inline styles, force reflow, restore transition — is unnecessary for measurement AND actively dangerous when any CSS rule targets the element's open state.

**The trap.** When the open-state CSS uses multiple selectors — most commonly `:hover` paired with a state class:

```css
.foo:hover > .panel,
.foo.is-open > .panel {
  opacity: 1;
  transition: opacity 0.22s ease 0.22s, ...;   /* delayed reveal */
}
```

…and the measurement hack runs DURING a real mouseenter (when `:hover` is already true), the parent already matches the open rule. The restore step that clears the inline `opacity` override WHILE `transition: none` is still in effect snaps the computed opacity straight to the open-rule's target value — **killing the in-flight transition**. The panel content paints instantly at full opacity while the surrounding chrome (a glass pill, an expanding container) is still mid-animation. Page content visibly bleeds through.

**The fix.** Don't reach for force-visible/transition-none hacks to measure hidden elements. Just read `offsetHeight`. If the layout legitimately needs the dropdown to be rendered (e.g. lazy-loaded content), use a clone off-screen — never mutate the live element's inline styles in a way that has to be restored across the open-state CSS rule.

**Diagnostic-methodology angle.** When a CSS-state bug seems "fixed" via synthetic `dispatchEvent` testing but the user reports it's still broken, check whether the open-rule includes `:hover` (or `:focus`, `:focus-visible`) alongside the state class. Synthetic events don't activate UA `:hover` — so an eval test can falsely confirm a fix that only addressed the state-class path. Memory: [[synthetic-event-misses-hover]].

**Bug history.** June 2026 — `shared.js → updateBackdrop`'s force-visible measurement hack killed the DS top-nav dropdown's open/close transitions (snap-in on open, ghost-pill on close); fixed with a single `offsetHeight` read. Full diagnostic + the synthetic-event misdirection: `.claude/skills/lifi-ds-docs/references/bug-history.md → Navbar dropdown desync`.

### Brand-asset rendering bugs — asset-first hypothesis (working-with-Claude rule)

When a logo, icon, or any vector brand asset clips, renders inconsistently, or behaves differently across tools (HTML, Figma, Sketch, decks, partner proposals) — the **first hypothesis should be the asset itself**, not the renderer. This rule exists because I (Claude) default to renderer-first debugging, and it has cost real time across multiple deck builds.

**The behavioral trap I fall into.**

I treat surfaces as independent problems by default. "HTML clips" and "Figma clips" look like two different bugs needing two different fixes (`overflow: visible` for HTML, `clipsContent: false` for Figma). They're often the **same** asset-level cause manifesting differently across renderers. Per-tool fixes feel like progress but compound rapidly and burn tokens. The right fix is in the asset and propagates to every consumer.

**Symptoms that an asset-level bug is the real cause.**

- The same visual error appears across 2+ rendering tools
- Each tool needs its own workaround to make it look right
- "Buffered" or "expanded" workarounds keep needing to be widened
- A per-tool fix that looks right in isolation breaks when the context changes (tighter parent, new ancestor with `overflow: hidden`)
- The user has reported the same bug across multiple sessions on different builds

**The diagnostic question that breaks the loop.**

*"What about the asset is forcing each renderer to compensate?"*

If the answer is "nothing, it's just rendering-engine quirks" — per-tool fixes are legitimate. If the answer is "the viewBox is too tight" / "the path overshoots the bounding box" / "the color profile is wrong" — fix the asset and the per-tool fixes become unnecessary.

**Anti-patterns to push back on — whether suggested by me or any tool.**

1. **Adding `overflow="visible"` as an SVG attribute without the matching CSS property.** Browsers' UA stylesheets override the attribute via the cascade. Always pair attribute + CSS, or use CSS alone.
2. **Incrementally widening a "buffer" without measuring the actual painted extent.** For bezier curves, use the formula below. Don't guess.
3. **Setting `clipsContent: false` on one ancestor in Figma.** Every ancestor frame between the SVG wrapper and a structurally large container must allow overflow. Audit the full chain.
4. **Treating HTML, Figma, and slide-tool clipping as separate problems.** Often the same asset cause, single fix.

**For any cubic-bezier corner curve — the formula for max painted x.**

Given control points P0, cp1, cp2, P3, the curve's max x is at the local extremum, often at t=0.5 for symmetric corners:

> **B(0.5).x = ⅛·P0.x + ⅜·cp1.x + ⅜·cp2.x + ⅛·P3.x**

If the viewBox right edge is less than this value, the corner clips. The same formula works for y. This is mechanical — use it whenever evaluating a curved logo's viewBox, instead of guessing buffer widths. The LI.FI mark spec below grounds this — B(0.5).x = 38.628, so viewBox width must be ≥ 40.

**Bug history that prompted this rule.**

May 2026 — the LI.FI mark clipped across **4 sessions on 3 different builds** (HTML deck, Figma deck, prior decks). Each session I reached for a renderer-side fix first (overflow attribute, then buffered viewBox `-3 0 38 48`, then `clipsContent:false` on the Figma wrapper). The user had to explicitly push back — *"This is not a Figma error, there's something wrong with the SVG asset itself"* — before I measured the bezier extent and edited the canonical viewBox. Two months of intermittent debugging closed in one commit once the asset was the target. Process retrospective: `artifacts/report-mark-bug-autopsy.pdf` in the working tree.

**When the user invokes this rule.** If I suggest a per-tool workaround for a brand-asset rendering bug, the user can quote this section and ask: *"What's the asset-first fix?"* That phrase is the trigger word — when I see it, default to auditing the asset (viewBox, path data, color profile, embedded fonts, file format) before suggesting any renderer-side patch.

### LI.FI mark — viewBox 0 0 40 48, plus CSS `overflow: visible` — recurring bug

**The trap.** The canonical LI.FI mark's path uses cubic-bezier corner curves with control points at x=39.732 (right side) to round the diamond corners. The *visible* curve genuinely paints to **x≈38.628** — past the path endpoints at x=32, past common crops at x=36, and past every "buffered viewBox" attempt narrower than 40. The math:

> For the right corner: `c4.418 4.418 4.418 11.582 0 16` starting at (35.314, 16). Control points at (39.732, 20.418) and (39.732, 27.582). Cubic-bezier B(0.5).x = ⅛·35.314 + ⅜·39.732 + ⅜·39.732 + ⅛·35.314 = **38.628**.

The path is correct — this is how the rounded corners are drawn. The viewBox needs to accommodate it.

**Canonical asset files** — use these, do not re-derive:

- `logos/lifi/lifi-mark.svg` — mark alone. viewBox `0 0 40 48` (5:6).
- `logos/lifi/lifi-lockup.svg` — mark + wordmark. viewBox `0 0 132 48` (wordmark area is wide enough on its own).

**Two non-negotiable consumer rules.**

1. **If you crop the lockup down to a mark-only SVG, the cropped viewBox width MUST be ≥ 40.** Anything narrower clips the rounded right corner flat. Don't crop to 32 (path endpoints), 36 (intuitive but wrong), or 38 (still wrong). Use `lifi-mark.svg` and stop re-deriving.

2. **Every consumer must set CSS `overflow: visible` on the SVG element.** Chrome's UA stylesheet sets `svg { overflow: hidden }` by default — which **overrides** the SVG `overflow="visible"` attribute via the CSS cascade. The attribute alone is not enough. The shared rule lives in `styles.css`:

   ```css
   .lifi-logo-svg,
   .lifi-mark,
   .brand-lockup__glyph-idle,
   .brand-lockup__glyph-hover { overflow: visible; }
   ```

   If you introduce a new selector that hosts the mark, add it to this rule.

**For Figma builds** (the `lifi-design-press` skill): the same viewBox applies. In addition, set `clipsContent = false` on **every frame between the SVG wrapper and the next frame that's structurally large enough to contain the curve** — the SVG wrapper alone is not sufficient because intermediate auto-layout containers re-clip at their bounds. Defense-in-depth: SVG wrapper + Mark container + Chapter column.

**Bug history.**
- Apr 2026 — original SVG ships with viewBox `0 0 132 48` (full lockup) — fine for the lockup because the wordmark area is wide; the diamond's bulge at x=38.628 sits comfortably inside 132. But every time a designer cropped the viewBox to ~32–36 for a mark-only use, the right corner clipped flat. Skill ref `figma-build-guide.md` documented "buffered viewBox" as the fix but the buffer was undersized (`-3 0 38 48` extends to x=35, still 3.6px short of x=38.628).
- May 2026 — HTML deck used `viewBox="0 0 36 48"` + SVG `overflow="visible"` attribute. Visually still clipped because Chrome's UA `svg { overflow: hidden }` cascaded over the attribute. The CSS-cascade angle was the missing piece. Fixed in `styles.css` with the rule above + canonical asset files at `logos/lifi/`.

The lesson: **the bug is in the asset's viewBox + consumer CSS, not in the path data.** Don't try to "buffer" the viewBox by widening it slightly — measure the actual bezier extent (the formula above is reusable for any rounded-corner SVG) and round up. Width 40 is the floor for this glyph.

### Gradient text in PDF exports — Canvas rasterization (recurring bug)

CSS `background-clip: text` + `color: transparent` is the canonical web-platform recipe for gradient text. It works correctly in every modern browser viewport. **Two PDF renderers disagree about how to interpret the resulting PDF primitives** — and they disagree in *opposite* directions:

| Approach | Chrome PDF viewer | macOS PDFKit (Preview, Quick Look, Mail) |
|---|---|---|
| `background-clip: text` | ✅ renders gradient on text shape | ❌ renders gradient as solid-color **rectangle** covering the text |
| Inline SVG `<text>` + `<linearGradient>` | ❌ Chrome's printToPDF **drops the text** | ✅ renders gradient on text shape |

Neither pure technique survives both engines. **The universal fix is Canvas rasterization at print time** — render the gradient text into an HTML `<canvas>`, take a PNG via `toDataURL`, embed it inline as `<img src="data:image/png;base64,…">`. Bitmap raster is a PDF primitive that every renderer handles correctly.

**Where the fix lives.**

- **Canonical helper** — `scripts/pdf-gradient-rasterize.mjs`. Exports `getRasterizeExpression({ scale, selectors })` which returns a JS expression suitable for CDP `Runtime.evaluate`. Walks the DOM for `.u-grad-text` / `.grad` spans, replaces each with a Canvas-rendered PNG `<img>` sized to the measured rect width × line-height. Preserves inline baseline alignment via a negative `margin-bottom` equal to `lineHeight − baselineY`. Reads brand gradient stops from CSS custom properties so it honors whatever palette the page is using (LI.FI 1.0, 2.0, competitor preset).
- **Pipeline integration** — both `artifacts/print-pdf.mjs` (1920×1080 decks) and `artifacts/print-portrait-pdf.mjs` (1224×1584 reports) call this transform via `Runtime.evaluate` between font-ready and `Page.printToPDF`. The transform is idempotent (skips spans already marked `data-grad-rasterized="1"`).
- **HTML authoring stays unchanged** — every gradient text span uses the canonical `.u-grad-text` class with the standard `background-clip: text` CSS. It renders correctly in the browser, in claude.ai/design previews, and in screen capture. The rasterize transform only kicks in at PDF print time.

**Authoring contract for HTML reports / decks that will be PDF-exported.**

- Mark every gradient text span with `class="u-grad-text"`. The `.grad` legacy class is also picked up by the selectors but new code should use `.u-grad-text`.
- Define brand gradient stops as CSS custom properties on `:root` so the transform can read them:
  - `--lifi-sapphire` or `--blue`
  - `--lifi-purple` or `--purple`
  - `--lifi-ink`, `--pink-solid`, or `--pink`
- The text content of the span IS the rendered text. Don't put non-text inside (icons, nested spans with different fonts) — the rasterize function captures `textContent` only.
- Multi-line gradient text within a single span is supported because the transform measures the actual rendered rect width and uses line-height for the canvas height. If a gradient span wraps, only the last line's width is captured — split into multiple spans if the span legitimately needs to wrap.

**Trade-offs.**

- **Vector → raster.** Gradient text is now a 3× DPI bitmap instead of vector. At print or projection scale the difference is invisible. File size impact ~30–150 KB per gradient word (PNG compression).
- **Accessibility preserved.** The `<img>` carries `alt="<original text content>"`. Screen readers announce the text.
- **Live-edit fidelity.** In claude.ai/design, the HTML still uses live `<span>` text. Only the PDF export rasterizes.
- **claude.ai/design preview.** The preview tool renders the source HTML (with the live `<span>`), not the rasterized PDF. So the gradient looks correct there too.

**Do NOT bypass the rasterize step.** If you author a new print pipeline (a new artifact format, a Figma-to-PDF flow), thread the rasterize transform through it. The bug recurs the moment a gradient text span hits a PDF engine without the Canvas pre-pass.

**Bug history.**

- May 21 2026 — autopsy report PDF shipped with `background-clip: text` for "a root-cause autopsy" hero verb. User reported "giant square clipping mask in a solid color" — confirmed reproducible in macOS Preview but NOT in Chrome PDF viewer. Initial response: stripped gradient, shipped with solid Blue accent.
- May 21 2026 — same bug confirmed across the entire 2-week deck (6 gradient spans: cover "consolidated", NET CODE "+102K", 3 section dividers, closing "What to watch."). Decks stripped to solid accents as well.
- May 22 2026 — investigation. Built `artifacts/grad-text-probe.html` with 4 approaches (solid / background-clip / SVG text / SVG overlay) side by side. Printed to PDF, opened in both Chrome PDF viewer and `qlmanage -t` (macOS PDFKit). Found that background-clip:text fails in PDFKit AND SVG `<text>` fails in Chrome. Pivoted to Canvas rasterization — works in both. Implemented `scripts/pdf-gradient-rasterize.mjs` + integrated into both print pipelines. Restored gradients in both build scripts; the rasterize transform handles them at print time.

The lesson: **when a PDF rendering bug appears, test in BOTH Chrome's PDF viewer AND macOS PDFKit before declaring a fix.** Chrome's `printToPDF` output is valid PDF but uses primitives that other renderers interpret differently. The reverse is also true — PDFs that look right in Preview can break in Chrome. Test both before shipping any printable artifact.

### Icon library is Lucide v1.8.0 — `/icons/` is the source, catalog is the rendering

All UI icons in this codebase source from Lucide v1.8.0 (`stroke-width="2"` for Product surfaces, `1.5` for Marketing / Catalog) — **except a shared DS primitive keeps its own canonical stroke even on a product surface**: `.side-nav__icon` is 1.5 and `.search-trigger__icon` is 1.6 on the Portal (a product surface) too, because the primitive's identity is its stroke; "product = 2" is the default only for bespoke / ad-hoc icons, not for shared primitives that already standardized a different stroke. Every icon used inline anywhere — `<svg viewBox="0 0 24 24" …>` — must have a matching `/icons/<name>.svg` file AND a `<figure class="ds-doc-icon-cell" aria-label="<name>">` in `design-system/assets.html#iconography`. **The library is the source of truth; the catalog is the rendering**, exactly like the canonical-source-vs-rendering rule for the git workflow and dashboard data.

**Three drift modes catch contributors out:**

1. **Doc-without-file.** Catalog has a `<figure aria-label="<name>">` but `/icons/<name>.svg` is missing. Inline usage that wanted to "source from `/icons/`" silently fails because the canonical file isn't there. May 2026 examples: `info`, `circle-alert`, `layout-grid` — all documented in the catalog but missing from `/icons/`. Fix: add the SVG file.
2. **File-without-doc.** New `/icons/<name>.svg` added without a catalog `<figure>`. Catch via `ls icons/*.svg | wc -l` vs `grep -c '<figure class="ds-doc-icon-cell"' design-system/index.html` — counts should match. **Anchor the cell-count grep on the `<figure>` tag, NOT a bare `grep -c "ds-doc-icon-cell"`** — the bare substring over-counts by ~8 (it also matches the `.ds-doc-icon-cell*` CSS selector definitions in the `<style>` block + prose code-references like the `#avatar` card's catalog-meta note). Same class-substring-isn't-a-structural-anchor trap as bulk-edit regexes. For a 1:1-by-name check (a matching count can still mask a simultaneous add+drop), diff the filenames against the `#iconography` figures' `aria-label`s.
3. **Inline drift.** An inline `<svg viewBox="0 0 24 24">` uses path content that doesn't match any library file — usually because the author pasted a Feather Icons / Lucide v0.x form OR hand-tuned a shape. Marketing pages (index.html, contact-us.html, sign-in.html) were the worst offenders before the May 2026 sweep.
4. **Glyph-in-CSS one-offs.** A trailing arrow / chevron / symbol rendered via a pseudo-element `content: '\NNNN'` (a Unicode glyph) is an icon too — but it's *invisible to an `<svg>` grep*, so icon audits silently miss it. The `.btn-with-icon` trailing arrow was a Unicode → (`content: '\2192'`) for months: it rendered thinner and smaller (14px Figtree glyph) than the library `arrow-right`, and only surfaced when a designer placed it beside an explicit Lucide child in `#btn-justify`. Fixed June 2026 by masking the library glyph into `::after` — `background: currentColor` + a data-URI `mask-image` of `icons/arrow-right.svg`, sized `var(--space-20)` (the icon-button md tier) — so the zero-markup default and an explicit child render identically and the arrow follows the button's text colour in both themes. **When auditing for one-off icons, grep `content: '\` and `content: url(` in the CSS too — not just `<svg>` in markup.** The mask-image + `currentColor` recipe is reusable for any pseudo-element icon; `.tile-cta::after` (`styles.css`) is a remaining Unicode-glyph consumer.

**Authoring contract — when you reach for an icon:**

- **First, check `/icons/<name>.svg` exists.** If not: STOP. Add the SVG file from the canonical Lucide v1.8.0 source (`https://unpkg.com/lucide-static@1.8.0/icons/<name>.svg`) and add the `<figure>` to the catalog BEFORE inlining. Bump the catalog count prose ("<N> icons").
- **Reach for the v1.8.0 name, not a legacy alias.** Lucide renamed several glyphs between v0.x and v1.x; the library on disk uses the v1.8.0 names. Most-hit case: the meatball / kebab dots are **`ellipsis`** (horizontal) and **`ellipsis-vertical`** — the legacy `more-horizontal` / `more-vertical` do NOT exist in `/icons/`. When a `more-*` reach dead-ends, try the `ellipsis*` name before assuming the icon is missing. (Same shape applies to other renamed pairs — grep `ls icons/` for the concept before adding a "missing" file.)
- **Inline by sourcing from `/icons/<name>.svg`.** Read the file, paste the inner shape elements. Don't paste from the Lucide website — the file format on disk has a `<!-- @license lucide-static v1.8.0 - ISC -->` header and a specific single-line shape; matching matters.
- **`aria-label` is the semantic source of truth, not the path.** When auditing an inline SVG to figure out the Lucide name, read the `aria-label` first ("Pin token" → `pin`, even if the path looks trophy-like), then the surrounding markup context (button on a token row → token-action affordance), THEN the path. Lucide path forms drift across major versions; same shape can have different markup. Don't let an unusual path push you to misclassify.

**Brand logos go to `/logos/`, not `/icons/`.** The Lucide-only convention is strict — the X/Twitter, Mirror, GitHub, Discord, Telegram brand glyphs live (or will live) in `/logos/` with their own catalog section. `/icons/` is the outline-stroke Lucide library, period. Discovered May 2026 when the audit surfaced 5 brand glyphs inlined in 10 marketing pages — spawned as a separate centralization task.

**Latest counts (June 2026).** Library = 118 files. Catalog = 118 cells. Counts match (verified 1:1 by name — every `/icons/*.svg` has a matching `#iconography` `<figure>` and vice versa). Run the path-signature audit periodically (Python script: index `/icons/` by sorted `(tag, attrs)` tuple, compare to every inline `<svg viewBox="0 0 24 24">` across UI files) to surface drift.

**The `#iconography` catalog grid lazy-loads its cells (June 2026).** On `design-system/assets.html` the 113 library cells ship as sized 24×24 placeholders (`<figure class="ds-doc-icon-cell" data-icon="<name>"><span class="ds-doc-icon-ph"></span><figcaption>`); a per-cell `IntersectionObserver` (`#lucide-icon-grid`, 300px rootMargin) fetches `../icons/<name>.svg` and injects the inline `<svg>` at `stroke-width="1.5"` + `stroke="currentColor"` only when the cell scrolls into view — preserving `currentColor` retints and the inspectable-inline-path goal while shedding ~660 path nodes from the initial render (~23 KB lighter file). **Audit implication:** the catalog grid no longer holds inline icon `<svg>` markup, so the path-signature audit above will NOT find the 113 library icons inlined in `assets.html` — that's expected, not drift: the catalog now renders `/icons/` directly, so it *can't* drift from the library. The `grep -c '<figure class="ds-doc-icon-cell"'` figure-count check still returns 113 (the placeholders are static). This lazy treatment is the CATALOG's own rendering of the library grid only — the "inline icons sourced from `/icons/`" rule for product / marketing consumers is unchanged.

### Isometric illustrations — `scripts/iso/` generators + the `iso-illustration` skill

**Animated isometric illustration** assets — a distinct brand-asset family generated from JS scene scripts, never hand-authored SVG. Reach for the **`iso-illustration` skill** (`.claude/skills/iso-illustration/SKILL.md`) — it's canonical for the method, the contracts, and the per-scene worked examples; this is the awareness pointer.

- **Two render modes, both theme-following (skill contract 13).** WIREFRAME (default, outline-only) draws from the `C` palette — ink-at-opacity off `--iso-ink` (default `--text-primary`), so the SAME SVG reads ink-on-paper in light / light-on-ink in dark AND re-tints with the Theme Composer. SURFACE (filled) shades faces off the `S` palette — a 3-tone OKLCH ladder off `--iso-surface` (default `--accent-primary`, composer-driven; a tile overrides inline for multi-hue, `--iso-fill-opacity` dials translucency). Two hooks: `--iso-ink` (wireframe line colour) + `--iso-surface` (surface fill hue); a consumer pins `--iso-ink` to force a fixed look — the brand-guide sets `--iso-ink: var(--surface-page)` on `.bb-ink` to keep its white-on-ink showcase.
- **Shared engine — `scripts/iso/engine.mjs`.** Exports `createIsoKit({K,Kh,V,OX,OY})` (the 2:1 dimetric projection `P` + primitives `slab` / `keycap` / `roundBox` / `surfaceBox` / `roundPoly` / `seg` / `bbox`), the `C` (wireframe) + `S` (surface) palettes, the `SW` stroke-weight ladder (`hair` 1.1 · `edge` 1.6 · `bold` 2.4), the `NSS` non-scaling-stroke rule, and `autoFrame` + `wrapSvg`. Per-scene generators: `scripts/iso/{token-spin,swap-card,macbook,mac-studio}.mjs` + `scripts/brand-iso-illustration.mjs` (the floating-desk scene). **The colour strings are baked into the emitted SVG**, so a palette change in the engine only takes effect on REGENERATION — re-run + re-embed the consumers.
- **Edge consistency is by construction.** Every stroke authors from `SW` + every colour from `C`/`S`, rendered with `vector-effect:non-scaling-stroke` (injected by `wrapSvg`), so a new scene reads at one consistent edge width regardless of its viewBox scale. Don't hardcode a stroke px or an `oklch()`.
- **Rounded iso boxes** use `roundBox` (wireframe) / `surfaceBox` (filled) — the full rounded top + full rounded bottom rect (no cut corners) joined by a SINGLE vertical on the left + right silhouette corners only (NO line at the front/near corner). `rpx` is the corner radius in SCREEN px (the `roundPoly` unit), not grid units — a grid-unit value silently yields a sharp corner. Skill contracts 11 + 13.
- **Library (official home):** `design-system/assets.html#iso-illustrations` — the scenes on `data-theme="light"` ink tiles (the white-on-ink lines read on any page theme). **Motion showcase:** the `brand-guide.html` "in motion" cards.
- **Embed rule:** generate the SVG from the scene, then embed it **inline** (so it reads `--surface-page` + animates — an `<img>` can do neither) on a light-pinned ink tile. Outline-only; the one fill is the active/moving element.

### Multi-logo arrangements — optically balance, never fixed-height-match

When two or more logos sit together in one arrangement — a **row, column, or grid**: partner-logo grids (`.ds-doc-partner-logo-grid`), marketing logo clouds (`.logo-cloud`), the proposal "who we work with" / "who you can distribute to" grids — size each logo so they read with **comparable optical weight**, NOT so they share an identical pixel height. Snapping every logo to one height (e.g. all at 28 px) is the failure mode this rule exists to prevent.

**Why fixed-height-matching fails.** Logos carry wildly different intrinsic proportions. A compact icon-plus-short-word lockup (Rabby Wallet, Ethena) at the same height as a long horizontal wordmark (Fireblocks, Binance Web3 Wallet) reads far smaller and lighter — it occupies a fraction of the ink in its cell. A tall stacked or heavy mark reads heavier. The eye registers **mass** (how much ink fills the cell), not the height number. Equal heights ≠ equal presence.

**The method.**

1. Pick a baseline height for the set (the most common silhouette — usually the medium wordmarks).
2. Then nudge each logo's height **up or down** until its optical mass matches its neighbors — compact lockups size **up**, dominant / very-wide wordmarks size **down**. Typical spread is within ±30 % of the baseline; a compact lockup landing ~1.3× the wordmark height is normal, not drift.
3. Keep each logo centered in its cell after resizing (recompute against the cell's `x`/`width` + `y`/`height`).
4. **Verify with a screenshot of the WHOLE arrangement, never per-logo dimensions** — optical balance is a visual judgment across the set; a dimensions table will tell you the heights match while the row still looks lopsided.

**Engineering.** CSS surfaces: set per-logo `height` (or `max-height`) on the `<img>` and let width auto — don't force a single shared height on the container's children. Figma (proposals): scale SVG-imported logos with `node.rescale(targetH / node.height)` (uniform; scales the frame's vector children — `resize()` won't scale children), then recenter against the host cell.

**Bug history.** May 2026 — the Anchorage proposal's token grid put Rabby Wallet at the row-standard 28 px; the compact rabbit-mark-plus-wordmark lockup read noticeably undersized beside the wide Fireblocks / Binance wordmarks. Bumping Rabby to 36 px (~1.3× the baseline) balanced the row. The lesson generalized into this rule: heights are a starting point, optical mass is the target. The proposals-specific application + the Figma `rescale` mechanics live in `.claude/skills/lifi-proposals/SKILL.md → Logo grids`.

### Extracting a module from a shared classic script

The site's app JS is **classic `<script>`s sharing one global scope** (no ES modules, no bundler for app code). When splitting a module out of a large shared script — the Theme Composer engine → `theme-composer-core.js` (June 2026) is the worked precedent (`design/theme-composer.md §3 / §10 step 5`) — the mechanics are load-bearing:

- **Keep the new file a classic script — NOT IIFE-wrapped.** Top-level `function` declarations auto-create `window.*` properties, so existing callers keep working whether they call by bare name (`deriveTokens(...)`) or via `window.fn` (`window.applyPalette(...)`). An IIFE would hide every function and break all consumers. Mirror the shared script's own non-wrapped top-level structure.
- **Load it BEFORE its callers** on **every** page that uses them. Watch the page that loads the shared script but not the editor/peers — the launchpad (`index.html`) loads `shared.js` alone, so it needs the new module too. A `DOMContentLoaded`-time caller (e.g. `initBrandPalette → applyPalette`) only needs the module defined by then, but the robust universal rule is **"immediately before the shared script, mirroring its `defer`."**
- **`const` / `let` are shared across classic scripts via the global lexical environment but are NOT `window` properties.** Cross-file reads resolve at call-time (fine), but a `const` can't be redeclared in two files. If a peer needs a `const`'s value, expose it explicitly (`window.X = value`).
- **Host-config the module reads can stay in the host file** and resolve cross-script at call-time — that's the portability seam (the engine reads `DEFAULT_PALETTE` from `shared.js`; a new project supplies its own). The reference is call-time only, so no top-level access in the module triggers a TDZ.
- **Atomic sweep, no aliases** ([[Primitive retirement is atomic]]): the new file + every consumer page's `<script>` tag + the source-of-truth doc pointers (`design/theme-composer.md`, the `shared.js →` breadcrumbs this file carries) land in one commit. **Verify in-browser**, not just `node --check`: load the chrome-only page (launchpad), a full-feature page (playground/composer), and a scoped consumer (Portal) — confirm the namespace resolves, the engine paints, and the console is clean.

### Diagram Engine — `window.LifiDiagram` (JSON specs → pure-SVG diagrams)

The diagram system behind designed flow / architecture / timeline visuals for decks, proposals, PRDs, and docs (June 2026; Phases 1–3 shipped). **The spec is the document** — declarative JSON (nodes · edges · groups · canvas), committed to the repo; everything downstream is rendering. Canonical spec + schema + phase plan: `design/diagram-engine.md`; execution log: `design/diagram-engine-punchlist.md`. The load-bearing contracts:

- **Pure SVG, zero `foreignObject`.** Text positioned by measured baselines (canvas `measureText`), one `<text>` per wrapped line, arrowheads as real paths — never `<marker>`, never `dominant-baseline` (Figma import + macOS PDFKit break on all three; same family as the gradient-text PDF rule).
- **Dual theming — the attrs/style split.** Geometry lives in SVG attributes (literals, renderer-safe); theme paint lives in inline styles (`var()`/`color-mix` expressions) → live scenes retint with the Theme Composer with no re-render, scoped canvases included (SVG inherits custom properties from HTML ancestors; audited June 2026, no engine hook needed). Export (`toSVGString`) resolves declared styles to literals, normalizing colors through a **1×1-canvas round-trip** (computed `oklch()`/`color(srgb …)` strings are unparseable by Figma/PDFKit — see `bug-history.md → Exported SVG colors`). Avatar marks inline as nested vectors with per-instance id namespacing — never `<image href>`; tooling artifacts (`data-diagram-hit`, `data-composer-sel`) are stripped from exports.
- **Node registry** (`LifiDiagram.registerNode`): card · pill · avatar · stat · iso (outline-with-core DEFAULT + `variant: "filled"` — user-ratified June 2026; duotone rejected) · browser · phone · timebar (+ `canvas.axis` time mode; the engine does no date math — `w` IS the time-scaled data) · group · label. Tones carry over the retired `.flow-*` vocabulary — **the engine replaced the HTML `.flow-*` family at parity (atomic retirement, June 2026, user-ratified):** `styles.css` §3.75, `shared.js → initDsFlow()`, and the `#flow-diagrams` catalog section are gone (migration-note comments at each site); the family's design discipline (neutral-default edges · accent-sparingly · legend rule · add-a-node-when-busy) migrated to `design/diagram-engine.md → Design discipline`. ALL diagrams reach for the engine; don't author new `.flow-*` markup.
- **Router:** obstacle-avoiding Manhattan (ported from the retired `initDsFlow`) + fan-out spreading + aligned-segment detours (a gap the legacy router had); author overrides via `offset` + `via` waypoints (which skip avoidance + spreading by design). `render()` exposes the routed geometry — `model.edges[idx]` (`pts`/`p0`/`p1`/ports) + `LifiDiagram.geom` — the contract the Composer's edge handles build on.

**Diagram Composer** (option B — dedicated workspace page, June 2026): `design-system/diagram-composer.html`, wiring in `design-system/ds-diagram-composer.js` — direct manipulation over the same spec document (palette · drag with 4-grid snap · node + edge inspectors · edge geometry handles: drag endpoints to re-port/re-target, drag insert dots to add `via` waypoints, drag/dbl-click via squares · Connect · a **spec library** — Save shelves the current spec under its `id` in localStorage `diagram-composer-library`, New/load/delete per row · two-way JSON drawer + `diagram-composer-draft` working buffer). It edits specs, never owns them; `lab.html#diagram-composer` is a pointer card to the page. **Document/PDF embedding is validated** (June 2026 pilot — `artifacts/reports/pilot-diagram-engine.html`: light-theme export inlined in a `.doc-*` figure, A4 PDF identical in Chrome + PDFKit, NO rasterize pre-pass; recipe in `design/diagram-engine.md → Pipeline embedding`). Phase 4's remaining surface is the live Figma-import test, gated on the first real proposal needing it. Catalog: `design-system/index.html#diagram-engine` (two live demos, per-demo SVG export). When the schema or contracts evolve, update `design/diagram-engine.md` + this section in the same commit.

### Three-places rule

A component is not "in the design system" until it's documented in all three:

1. `styles.css` — implementation (single source of truth for styling)
2. `design/components/` — written spec with variants, usage, and don'ts
3. `design-system.html` — rendered catalog entry with live preview (if the user asked for one)

When revising an existing component, update **all three** to prevent drift.

**Catalog ↔ CSS parity — declare it with `data-promises-class`.** When a `.ds-doc-card` documents a real CSS class (vs. a concept, chapter, modifier, or anatomy zone), annotate the opener:

```html
<div class="comp-section ds-doc-card" id="code-chip" data-promises-class="code-chip" data-scope="…">
```

The nightly audit's **B3 check** (`scripts/audit-drift-helper.py --check b3`) verifies every annotated card's declared class still exists as a `.<name>` selector across the shippable stylesheets — `styles.css` / `swap.css` / `checkout.css` / `portal.css` / `page.css` (widened June 2026 from the original `styles.css` / `swap.css` pair, after `.ui-checkout` — which lives in `checkout.css` — tripped a false positive). Drift fires when a class is renamed or retired without updating the catalog — a true three-places-rule violation.

Cards documenting concepts (`#card-anatomy`, `#card-gap`), chapters (`#side-nav-rail`, `#tables-bordered`), modifiers (`#chip-bare`, `#field-prefix-suffix`), or **pure-composition markers** (a class applied in markup but with no CSS of its own — `#ui-receive-panel` is the canonical case: it names a frame for docs + JS but inherits every visual property from `.panel.ui-card`, and its own Rules pane calls a `.ui-receive-panel{}` rule a smell) **omit the attribute by design** — they have no single `.<id>` class to verify against. (A pure marker that IS annotated will trip B3 forever, since the selector never appears in CSS — the fix is to drop the annotation, not to add a rule. Validated June 2026 when `#ui-receive-panel` surfaced as a B3 finding during `/checkup`.) The annotation is opt-in; only ~30% of cards qualify. When you author a new card that documents a single CSS class, add the annotation in the same edit. When you rename a CSS class atomically (per the [Primitive retirement is atomic] rule), update the matching `data-promises-class` value in the same commit — otherwise the nightly audit will surface it as B3 drift the next morning.

Full rationale + the misdiagnosis story that prompted this contract live in `.claude/skills/lifi-ds-docs/references/bug-history.md → Nightly audit B3 — annotation-driven, not heuristic`.

### HTML comments are structural markers, not behavioral documentation

Behavioral documentation lives where the behavior is implemented:

- **CSS comments** (`styles.css`, `swap.css`, `page.css`, `doc-shared.css`) — own the visual recipe and CSS-gated behavior. Searchable per-component (grep the selector, find the spec next to it).
- **JS code comments** (inside `<script>` blocks or `.js` files) — own runtime behavior.
- **Catalog Rules / Anatomy panes** (`design-system/index.html` → `.ds-doc-card`) — own visual contracts and per-component "when to use" guidance.
- **`design/components/*.md`** — own the written component spec.
- **`CLAUDE.md` / per-skill `SKILL.md`** — own cross-cutting rules and conventions.

HTML comments shrink to **1-line markers**. Three canonical shapes cover ~95 % of legitimate uses:

| Use case | Shape |
|---|---|
| Section divider for orientation | `<!-- ── Section name ── -->` |
| Script-tag label | `<!-- @scope — short label (window.Foo) -->` |
| Inline-script wiring | `<!-- A → B → C -->` (arrow notation for the wire's flow) |

**The one exception — preserved disabled markup.** A multi-line HTML comment is legitimate when it wraps commented-out markup that's intentionally preserved for restoration, with a brief note explaining when to reintroduce it. The featured-token-module block in `playground.html` is the canonical example (`<!-- Featured module disabled — markup preserved for easy restore. Reintroduce as the top slot only when... -->`). Documentation about absence belongs at the absence.

**The "could this be a Rules-pane bullet?" test.** If an HTML comment explains *why* the markup looks the way it does, *when* to use it, or *how* it connects to other behavior — that belongs in a Rules pane (or CSS, or `design/components/*.md`), not in HTML. Trim the HTML comment to a 1-line label and migrate the prose to its canonical home in the same edit.

**Authoring rules.**

- **Don't dump a paragraph in HTML to defer the spec decision.** If a feature genuinely needs prose explanation, the spec belongs somewhere durable — the catalog Rules pane, a `design/components/*.md` section, or a CSS comment block at the implementing selector. Reaching for an HTML paragraph because "I'll move it later" usually means it stays there forever, then accumulates.
- **When trimming comments, watch for stale phase/version markers.** `phase 3`, `v17`, `May 2026`, `deferred`, `future`, `placeholder`, `TODO follow-up` — any embedded temporal claim in an active comment. Either rewrite to reflect current state or remove. Git history is the right place for archaeology. (Migration-note comments in CSS that explicitly document a primitive retirement — `/* .form-hint retired May 2026 → .hint, see CLAUDE.md → Primitive retirement is atomic */` — are the legitimate exception. Those dates are intentional breadcrumbs, not stale claims about active behavior.)
- **The cleanup is its own commit, not a drive-by.** Bulk comment cleanup lands as a dedicated commit so the diff is a single revert target. Don't bundle it with feature work.

**Where this is enforced.** Documentation-enforced today (this section). A future validator pass on HTML files (`scripts/validate-doc.py` extension) could flag multi-line HTML comments outside the disabled-markup exception. Until then, manual review at PR/commit time + the recurring opportunity to trim during related edits.

**Bug history (May 2026).** `playground.html` accumulated ~30 prose-heavy HTML comments across iterations of the swap widget, Limit-mode rebuild, Variants Rail, and panel-height pass — each contributor adding 3–9 lines explaining their addition. Cleanup pass removed 345 lines (commit `5c3ff38`) of explanatory text that either already lived in CSS / catalog / SKILL.md, or that was archaeological (stale "phase 3" / "v17" markers). The principle landed as this rule the same session — implicit-becomes-explicit conversion to prevent the next round.

### The catalog uses `.ds-doc-card` — invoke the `lifi-ds-docs` skill

Every catalog entry follows a **progressive-disclosure template** (`.ds-doc-card`) so designers see only what they need (label + caption + preview) while engineers and AI find everything else (markup, specs, anatomy, rules, source) behind a quiet disclosure strip. The page-level **Concise / Detailed toggle** in the header flips every panel at once.

When adding or migrating a component, use the skill — don't free-hand the HTML:

- `.claude/skills/lifi-ds-docs/SKILL.md` — when to invoke, the workflow, the panel taxonomy
- `references/component-template.html` — copy-paste skeleton
- `references/writing-style.md` — caption rule (≤140 chars, no marketing voice), prose conventions
- `references/procedure.md` — six-step workflow with edge cases
- `scripts/validate-doc.py` — structural linter; run before committing any change to `design-system/index.html`

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

The validator checks for missing slots, oversized captions, panel/disc mismatches, raw hex colors, marketing words, and **scope tagging** (E012). Exit code 0 = clean.

Full rationale + the primitive's anatomy live in `design.md §13 — Contributing to the Design System` and in `styles.css → .ds-doc-card` (the comment block above the CSS).

### Catalog page authoring — see the `lifi-ds-docs` skill

When you're working on `design-system/*.html` — adding a component, moving a section, renaming a tab, adding a dashboard metric — the **catalog page authoring rules** live in `.claude/skills/lifi-ds-docs/SKILL.md`, not here. The skill is auto-invoked for catalog work; it covers:

- **Catalog organization** — sidebar contract (groups curated, items A→Z), section monotonicity (sidebar order = page order), type-led naming (`#accent-cards` not "Pricing"), group intros as inline blocks, the 8-step add-a-component workflow. Validator command: `python3 .claude/skills/lifi-ds-docs/scripts/validate-doc.py`.
- **Scope tagging** — every `.ds-doc-card` carries `data-scope="foundations | ui | marketing | docs"`. Multi-scope is the norm. Validator errors on missing/invalid (E012).
- **Design-system top nav** — rendered from `ds-nav.js`; never hand-rolled per page. Edit the `TABS` array to add a tab.
- **The catalog is multi-page (June 2026 split).** It's no longer one `index.html` — it's seven audience pages: `index.html` (Components · Cards · Tiles · Data Viz · Navigation · Diagrams), `foundations.html` (colour/type/spacing/radius/elevation/surfaces/materials/devices + composers), `playground.html` (playground shell composition + swap/bridge/limit `ui-*` primitives + market data — renamed from `widget.html` June 2026 when the Widget page was promoted to the top-level Playground; redirect stub left at `widget.html`), `assets.html` (icons/avatars/logos/portraits/brand-lockup), `marketing.html` (website page-sections), `portal.html` (LI.FI partner-console `.portal-*` primitives), and `docs.html` (the catalog-authoring kit — authoring guide · section TOC · viewport · asset cells · lightbox; "Documentation" tab in the "More" overflow, added June 2026). A new component goes on the page that matches its **scope**, not always `index.html` — see `SKILL.md → Workflow when adding a new component` (step 3). After adding/moving any card, **regenerate the cross-page ⌘K manifest** (`node scripts/build-search-index.js`; the pre-commit hook also does it). Anchors that moved off `index.html` are forwarded by `catalog-redirects.js` (single-source, page-aware). Full architecture + the per-page map: `design.md §13 → Multi-page architecture — the audience-page split`.

Full rules in `SKILL.md → Catalog organization` and `SKILL.md → DS page chrome`. The hero-eyebrow and URL-filename rules (also in the skill) are flagged below — they fire often enough to be worth a CLAUDE.md pointer.

### Launchpad — `index.html` is the site entry chooser; `home.html` is the marketing homepage

The site no longer lands on the marketing homepage. **`index.html` is the Launchpad** — a clean standalone destination chooser with three big cards (Design System / Playground / Website). Vite serves `index.html` at `/`, so it's what loads by default (the point: the team doesn't re-see the full marketing homepage every server restart). The former marketing homepage moved to **`home.html`** (content unchanged; `git mv` preserved history).

**Wiring (May 2026).**

- **Home links → `home.html`.** Every marketing-page `navbar-site__logo` and the co-marketing "Home" nav link point at the marketing homepage, NOT the lobby. `index.html` is the neutral fork point above them.
- **Playground back button → the Launchpad (`../index.html`).** The playground rail's `brand-lockup--back` (aria-label "Back to Launchpad") returns to the lobby chooser, not the marketing homepage — "up" from the tooling surface is the entry chooser, and the FAB still offers the Website destination separately. (Changed June 2026 from `../home.html` / "Back to lifi.fi".)
- **The FAB is the universal switcher.** `shared.js → initDesignFab()` renders three peer destinations — **Website** (`home.html`) · Playground · Design System — with relative pathing that shifts up one dir inside `/design-system/`. The Website entry was added when the homepage became a distinct destination.
- **`data-no-fab` — reusable per-page FAB opt-out.** Setting `data-no-fab` on `<body>` (or `<html>`) makes `initDesignFab()` early-return. The Launchpad uses it — its three cards already ARE the cross-surface choice, so the FAB would duplicate them. Any future page can suppress the FAB the same way (it's a generic attribute, not a lobby-specific hack). `shared.js` still loads on the lobby for the theme toggle.

**The three destination cards are the `.destination-card` primitive** (tier-2, `styles.css` — promoted May 31 2026 from the former page-local `.launch-card`; documented at catalog `#destination-cards` + `design/components/surfaces.md → Destination card`). What stays **page-local** in `index.html`'s `<style>` is only the lobby chrome (`.launch-topbar` / `.launch-brand` / `.launch-main` / `.launch-grid` / `.launch-intro`) and the **paused wireframe mocks** (`.launch-mock*` + the faux sub-primitives), kept as commented markup for the "better recreations" revisit.

- **Cards = `.tile` + `.destination-card`.** `.tile` supplies the surface, hover/active/focus accent recipe, and the `--tile-accent` system; `.destination-card` adds the preview frame, theme-swappable shot, bullet list, justified CTA, and the concentric-radius recipe. Each card binds its accent **inline via the tile API** (`--tile-accent-light/dark`) → **accent 1 (DS) / accent 2 (Playground) / accent 3 (Website)**. The old `.launch-card--ds/play/web` modifiers are retired.
- **Preview = `.destination-card__media` > `.destination-card__frame`** — a neutral-hairline 16:9 frame (no gradient backdrop) holding two `.destination-card__shot` images (`--light` / `--dark`) that swap with the theme. The shots are **real page screenshots** captured from the running site as a **two-tier set** under `screenshots/launchpad/`: `full/` (3200×1800 @2x archival masters, never `<img src>`) + `thumb/` (1024×576, what `index.html` + the catalog `#destination-cards` actually load). **Regen pipeline** (needs `npm run dev` on `:5173`): `node scripts/capture-screenshots.mjs --config screenshots/launchpad/capture.config.json` (writes `full/` — 4 destinations × light/dark via `prefers-color-scheme` emulation; the config lists the URLs + per-page settle) → `node scripts/build-screenshot-thumbs.js` (sharp downscale `full/` → `thumb/`). Re-run both whenever a destination's UI changes. The radial-gradient panel + window-chrome bar earlier revisions used are gone, and the wireframe mocks the screenshots replaced are paused, not deleted.
- **Title sits above the preview** — card title = **`.tile-title`**, bullet summary = **`.tile-body.destination-card__list`** — no hand-rolled font rules. The lobby hero ("Where to?") uses the standard **`.eyebrow-word` + `.section-title`** pairing.
- **Card-to-card gap** routes through the **card-gap ladder** via a local `--card-gap` (xl tier) on `.launch-grid`.
- **Footer CTA = `.btn-primary` / `.btn-accent2` / `.btn-accent3` + `.btn-block .btn-with-icon .btn-justify`** — a full-width **justified** button (label leading, arrow trailing) synced to the card's accent, with the button family's `--btn-on-accent` formula auto-contrasting the label + arrow. NOT a generic blue button on every card; NOT the older centered-label treatment.
- **Corner math runs OUTWARD with an over-round ceiling** — the `--button-radius` anchor (16px — the **lg-tier** button radius) is the SEED, driving the preview frame radius and, via `min(calc(--button-radius + --destination-card-pad), --space-32)`, the card radius (default pad 32 → seed 16 + 32 = 48, capped to **32** so a large padding doesn't over-round). The CTA **re-asserts the seed explicitly** (`border-radius: var(--button-radius)`) rather than inheriting the default text-button tier — now a notch tighter at 12px (ladder: sm 8 / default 12 / lg 16 / xl 20) — so frame + CTA stay matched at 16. The card is concentric up to the 32 cap, then flat.

This section is the durable contract; the primitive's own contract lives in `styles.css → .destination-card` + the catalog `#destination-cards`. When the architecture evolves (a fourth destination, a different default-landing decision, a second `.destination-card` consumer), update this section in the same commit.

### LI.FI Portal — partner console (`portal.html`)

A net-new partner-facing console (kicked off June 2026), recreated from a Claude Design concept using existing DS primitives (NOT the concept markup). Reached from the Launchpad (4th `.destination-card`) and the cross-surface FAB. Default theme LI.FI 1.0 — the concept's violet is a *selectable theme*, never the default (per the LI.FI-1.0 rule).

**Architecture — single-page app shell + JS view-switching (the playground's pattern).** One `portal.html` hosts the persistent chrome; nav swaps the active `.portal-view` (no per-page reload; the shell is built once). Three files: `portal.html` (shell + all views), `portal.css` (portal-specific layering on the shared `.app-*` shell + tier-3 `.portal-*` content primitives — the shell itself now lives in `styles.css`; see Shell below), `portal.js` (view-switching, JS-managed breadcrumb, detail tabs, the integrations demo-state toggle, theme toggle, deep-link boot). Loads `presets.js` + `theme-editor.js` + `shared.js` (FAB + dropdowns + theme) like the other surfaces.

**Shell — the shared app-shell (June 2026 re-platform).** The Portal moved off its original CSS-grid shell + top bar onto the **shared `.app-shell` / `.app-rail` / `.app-canvas` primitives** (`styles.css` "App shell" section + `shared.js → window.lifiInitAppRailScreens` / `lifiInitAppRailCollapse`), generalized from the Playground rail recipe so both products share ONE shell. `portal.css` now keeps only portal-specific layering on `.app-*`. (Playground convergence onto `.app-*` — retiring `.playground-rail`/`.playground-canvas` + converging the theme-rail wiring — is a tracked follow-up; the Playground still runs its own shell for now.)
- **Left rail** = `<aside class="panel app-rail portal-rail">` — a fixed glass panel with a `data-screen` drill system. Width is a **scoped override** — `body.portal-body { --rail-width: var(--w-4); }` (320px, 2 ladder steps below the shared `.app-shell` default `--w-6` 384px), so the shared default the Playground convergence will inherit stays untouched. **Collapse model (June 2026, `43547dd`) — a Portal-scoped persistent click-toggle PUSH, not the shared hover-expand `.app-rail--mini`** (portal.html no longer carries that class): `body.is-rail-mini` shrinks `--rail-width` to `--rail-mini` (72px) so rail + `.app-canvas` resize in lockstep; toggled by the head's `.portal-rail__toggle` button (arrow-left-to-line icon, rotates 180° between states), persisted in localStorage `lifi-portal-rail-mini`, wired in `portal.js → initPortalRailToggle`; CSS: `portal.css → "Collapsed (mini) rail"`. The shared `.app-rail--mini` primitive is untouched and now has zero live consumers — kept as the catalog recipe (see `design/components/navigation.md → "App rail"` Portal note). ROOT screen: `.app-rail__head` (brand `.brand-lockup` + a top-action cluster — **theme toggle · notifications**; the **Appearance trigger was removed June 2026** — theme selection lives in the Settings view's Theme panel, see below) + a full-width `.search-trigger` row + the `.dropdown` org switcher + the full `.side-nav` (11 views + Analytics `__item--parent`/`__sub`). Pinned `.app-rail__footer` = the **wallet connect mount** (`.ui-wallet-mount` — the official persona card / Connect CTA; see Wallet connect below) + a Support/Docs **`.action-bar action-bar--footer`** row (standardized June 2026 — neutral buttons, no icons, divider dropped per-consumer; see `actions.md → action-bar`).
- **No top bar.** Search + notifications + theme toggle moved into the rail head. (The former JS-managed breadcrumb was removed June 2026 — the rail's `.side-nav` active state + each view's page-head carry the wayfinding.)
- **Settings → Theme panel — the canonical Portal theming surface (June 2026).** A panel in the Settings view (`portal.html → [data-portal-theme-panel]`) that owns theme selection AND editing for the dual-theme model. Two scopes side-by-side: **Chrome** (paints `:root` → the rail + app shell; LI.FI 1.0 default) and **Brand** (paints `.portal-canvas` → partner branding). Each is a `.dropdown` (open/close wired by `shared.js → initDropdown`) whose trigger names the active theme via `window.ThemeComposer.renderCardRow`'s swatch-strip; its panel lists every theme. A row click **selects** — applies palette + radii to that scope's element(s) via `window.ThemeComposer.applyPresetScoped` / `ThemeComposer.applyRadiiToScope` (chrome → `[document.documentElement]`, brand → `[.portal-canvas]`); the row's **edit pencil opens `window.ThemeComposer`** (full OKLCH palette + corners + spacing composer) **in a `.modal-overlay`** scoped to the SAME element(s), so an edit live-paints exactly that surface. No persistence by design (the editor's `onChange` is a no-op; `scope` does the live paint; everything resets to `:root` / the org theme on reload — matching the LI.FI-1.0 brand-neutral default). The Chrome trigger seeds from `:root`'s genuine cold-load default (`DEFAULT_BRAND_ID`, no auto-apply); the **Brand trigger reads the active org's resolved theme (`LifiPartnerOrgs.themeFor`) and re-seeds on `LifiPartnerOrgs.on('change')`**, so it always names what `portal-render.js` actually painted the canvas. CSS is tokenized in `portal.css` (`.portal-theme-grid` / `.portal-theme-trigger` / `.portal-theme-list`). Wiring: `portal.js → initPortalSettingsTheme`. **The dormant `appearance` / `theme-edit` rail screens + the `initPortalThemes` IIFE were retired into this surface (atomic, no aliases) — their `openEditor` editor-mount pattern was folded into `initPortalSettingsTheme`, parameterized by scope.**
- **Right rail** — `<aside class="panel app-rightrail portal-rightrail">` scaffold; hidden until a view sets `body[data-rightrail="on"]` (reserved, empty).
- **Collapse** — manual collapse exists via the head's `.portal-rail__toggle` (the mini push model above — a head button, not a `.drawer-handle`); the responsive full-hide below 900px is unchanged: `.app-shell.is-rail-collapsed` (+ `--rail-tx` slide) via `lifiInitAppRailCollapse(rail, {autoCollapseBelow:900})`.
- **Scroll model** — `.app-canvas` scrolls internally; the fixed `.app-rail` stays put. (The retired grid shell's `.portal-shell` / `.portal-content` / fixed-top-bar bounded-height chain no longer applies.)

**Wallet connect — personas ARE the partner-org accounts (June 2026).** The footer's one-off `.portal-rail__account` was replaced by the **official persona connect card**, driven by the SAME `window.LifiWalletConnect` core + 4-step wizard as the playground (see "Playground wallet-connect wizard" → Shared module). portal.html loads `wallet-sim` + `wallet-connect.js` + the `#walletConnectModal` shell; the footer is a `.ui-wallet-mount` (connected → the `.list-item` connect / identity row + disconnect; disconnected → "Connect wallet" CTA). `portal.js → initPortalWallet` calls `LifiWalletConnect.initModal()` / `initDisconnectButtons()`, renders the mount via `statusCardHTML`, and subscribes to `lifi:wallet-change`.

- **Cold-load auto-connects as Philipp** (`ceo → lifi` — the house Partner Demo account; was Vilen/`hodler → vilendesign` until June 2026) — a deliberate **dev-convenience override**, so the Portal opens signed in and the user isn't re-connecting each load (the playground keeps its own auto-connect-Vilen; `portal.js → initPortalWallet` fires a guarded `WC.connect('ceo')` after the `lifi:wallet-change` listener, no-op if already connected). This overrides the original "cold-load is DISCONNECTED" demo intent — to demo the gated/disconnected state + connect wizard, hit disconnect manually (a `?connected=0` deep-link escape hatch was discussed but not built). The disconnected state + nav-gating machinery (below) all still work; only the cold-load default changed.
- **Nav gating (demo)** — disconnected leaves only Dashboard reachable: `body:not([data-playground-wallet-connected="true"]) .portal-rail__nav .side-nav__item:not([data-view="dashboard"])` (+ `.side-nav__sub`) → `display:none` (portal.css). On disconnect, portal.js falls the active view back to Dashboard so a gated view is never left showing.
- **Personas → orgs** — connecting as a persona switches the active partner org to where they work AND greets the connected person (overriding `account.first`, so e.g. Kim is greeted as Kim even though Rabby's Owner is Finn). Mapping (`portal.js → PERSONA_ORG`): `ceo→lifi` (Philipp — the house demo account) · `hodler→vilendesign` (Vilen) · `whale→ledger` (Dale) · `stables→kast` (Mabel) · `fish→rabby` (Finn) · `shrimp→rabby` (Kim, 2nd seat). The 3 non-Vilen org `account` + you-members in `partner-orgs-data.js` were reassigned to their personas (Dale/Finn/Mabel); Kim joins Rabby as a Developer. The shared connect signal `body[data-playground-wallet-connected]` is "playground"-named but cross-surface — a rename to `data-wallet-connected` is a tracked follow-up.

**Deep-links (shareable + screenshot-friendly).** `?view=dashboard|integrations|detail|…`, `?state=populated|empty|limit` (integrations), `?tab=overview|wallets|fees|…` (detail), `?integration=<name>`, `?org=<id>` (active partner org — see Multi-tenant below), and `?theme=light|dark`. `portal.js → bootFromQuery()` reads them on load. `?theme` is honored by the FOUC guard, which ALSO writes it to `localStorage` so `shared.js`'s theme bootstrap doesn't override it back (see bug-history → "Headless screenshot of a live JS page never exits" for the sub-bug this fixed).

**Multi-tenant — partner-org accounts (June 2026).** The Portal is multi-tenant: every view binds to an active PARTNER ORGANIZATION. Source = `partner-orgs-data.js` → `window.LifiPartnerOrgs`, the supply-side sibling of `@lifi/personas` (tenants who EMBED LI.FI, vs. personas = end-user wallets). Five archetypes spanning the spectrum: **LI.FI Services** (house · the DEFAULT Partner Demo account — LI.FI's own first-party products (Jumper, Widget, Playground) as a tenant; `themePresetId: null` = "wear the SITE default brand", resolved at apply time by `LifiPartnerOrgs.themeFor()` → `DEFAULT_BRAND_ID`, so the house account TRACKS a brand flip (June 2026 — the hardcoded `'lifi-1'` kept the canvas on 1.0 after the 2.0 flip); mark `avatars/lifi/lifi.svg`; owned by the `ceo` persona Philipp Zentner; `DEFAULT_ORG = 'lifi'`), **Ledger** (enterprise · monochrome theme), **Rabby Wallet** (growth · blue-violet, sourced from the logo SVG), **Kast** (starter · mint — brand-APPROX, flagged for verification), **VilenDesign** (solo · composed magenta, no brand assets → monogram identity). Each org holds CANONICAL facts (identity, account, KPIs, members, integrations, fee rates, plan, `themePresetId`) + DERIVED arrays (recent txns, per-chain fee splits, API keys, audit log, analytics breakdowns, seeded sparkline/chart series — deterministic per-org seed so series are stable across reloads); `getIntegrationDetail(orgId, slug)` derives the per-integration drill data. **Platform-level surfaces — Status, network health, Roles — are SHARED constants (same for every tenant), never per-org.** API: `listOrgs` · `getOrg` · `themeFor(orgOrId)` (resolves an org's theme — null `themePresetId` → `DEFAULT_BRAND_ID`; ALL consumers resolve through it, never read `.themePresetId` raw) · `getIntegrationDetail` · `getActiveOrgId` · `setActiveOrg` (persists `lifi-portal-active-org`, fires `change`) · `on/off` · `sparkPath` · `fmt`. The four partner theme presets (`ledger`/`rabby`/`kast`/`vilendesign`) live in `presets.js`, applied scoped to `.portal-canvas` on org switch; the house org carries no preset of its own — `themeFor` resolves it to the site default (`DEFAULT_BRAND_ID`), whatever that is at the time.

- **Binding engine** — `portal-render.js` (loaded AFTER `portal.js`). Declarative hooks in `portal.html`: `[data-bind]` (text) · `[data-bind-html]` · `[data-bind-value]` (inputs) · `[data-bind-width]` (style.width %) · `[data-bind-chip]` (delta chip cls+text from a vm key) · `[data-render]` (row sets via the `RENDERERS` map) · `[data-spark]`/`[data-chart]` (per-org SVG series) · `[data-detail]`/`[data-detail-render]` (the drill, filled by `window.lifiRenderPortalDetail`). `buildVM(org)` → flat key→value map; `applyOrg(id)` re-themes + rebinds + re-renders rows + re-runs the monogram pass. **Boot is DEFERRED to `DOMContentLoaded`** so shared.js has defined `ThemeComposer.applyPresetScoped` first (else the scoped-theme call silently no-ops — see bug-history). **Rich-table rows are emitted via the cell-builder helpers** (defined after `txStatusChip`): `cell(inner, end)` / `ctext(v, {bold,ink,mono})` / `ident(avatarHtml, name)` / `routeCell(t)` / `meterCell(valueHtml, pct)` / `dotText(state, label)` / `actionCell(inner)` — 12 renderers consume them. When adding a Portal table renderer, reach for these rather than hand-rolling `<td><span class="data-cell-text">` markup (keeps every table on the promoted rich-table component; established in the June 2026 sweep).
- **Org switcher** — rebuilt from `listOrgs()` (brand-toned monograms; Ledger has a real `avatars/wallets/ledger.svg`, the rest use monograms — follow-up to source square glyphs for Rabby/Kast). Click → `setActiveOrg` → identity + all view data + theme flip live. The integration-row drill uses a DELEGATED listener on the integrations view (per-row listeners die when `portal-render` re-renders the `<tbody>` — see bug-history).
- **DS doc surface** — `design-system/partners.html` (a `.tile`/`.identity-tile`-style org grid + detail panel, beside Personas; "Partners" tab in `ds-nav.js`, `data-active="partners"`). "Open in Portal" deep-links `portal.html?org=<id>`. **GOTCHA: DS catalog pages load `styles.css` / `page.css` / `doc-shared.css` but NOT `portal.css`** — so `.portal-*` classes (`.portal-you`, `.portal-dot`, …) render UNSTYLED there. When porting product markup onto a DS page, reuse universal primitives (`.chip`, etc.), never `.portal-*`.

**Screens built.**
- **Dashboard hub** — 4 KPI `.stat-card`s (delta chips + inline sparklines), the "Embedded from Superset" area chart (`.seg` timeframe), Top routes + Recent transactions (`.data-table`, real `/avatars/chains` logos). Bound per-org via `portal-render.js`.
- **Integrations** — usage-meter pill, KPI cards, search + `All/Active/Paused/Archived` `.seg` filter, a clickable integrations `.data-table` (row → Detail). Three states (Populated / Empty / At-limit) swap via a quiet "Preview state" demo toggle (a fine-tuning affordance, NOT product chrome — keeps the nav item active + breadcrumb fixed). Empty = illustration + step cards; At-limit = red meter + disabled CTA + plan-limit `.alert--info` banner. **New-integration wizard** (`integration-wizard.js` → `window.LifiIntegrationWizard`) — a 4-step modal (basics → string → wallets → API key) opened from any `[data-action="open-integration-modal"]`; Finish fires `lifi:integration-created` { name, string, wallets }, which `portal.js` persists via **`LifiPartnerOrgs.addIntegration(orgId, {name, string, wallets})`** — pushes a first-class record onto the org's canonical `integrations` (unique deduped slug, `status:'active'`, stored `wallets`, `custom:true`; bumps `usage.integrationsUsed`; fires `'change'` so portal-render rebinds the list) and returns it — then `openDetail(rec.name)` lands on the new integration's **real Detail** (`renderDetail` resolves by name, so it's the just-pushed record, not `integrations[0]`). **`getIntegrationDetail` echoes a custom integration's REAL provisioned wallets** when `it.wallets` is present — EVM/SVM defaults + EVM-L2 overrides (each L2 chain has a `/avatars/chains` mark; names via `CHAIN_NAME` + an `L2_NAMES` map for zkSync/Scroll/Mantle) — instead of the seeded addresses; collected $ stays derived. The Wallets-tab **SVM default row is now data-bound** (`data-detail-render="wallets-svm"` → `receivers.svmFull`, a new `svmLong()` for seeded / the real address for custom) so SVM renders per-integration like the EVM rows did — the static placeholder is retired. **Bitcoin/Sui/Tron receivers are now surfaced too** (June 2026): `getIntegrationDetail` adds `receivers.{bitcoin,sui,tron}Full` + `has{Bitcoin,Sui,Tron}` (read from `w.bitcoin/sui/tron .address`, each gated on presence); `portal-render.js → walletFamily()` renders each provisioned wallet (`walletItem('Fee wallet', …)`) and reveals its `[data-detail-group="<fam>"]` group iff present, else hides it via inline `display` — so seeded integrations (which never provision these) don't grow empty sections. In-memory only (resets on reload, like the rest of the org mock). **Catalog:** the wizard's reusable compositions are documented at `design-system/portal.html` — `#network-row` (the expandable wallet-family row), `#input-counter` (the in-field char counter, **de-wizarded** `.iwiz__counter-field` / `.iwiz__field-counter` → `.input-counter` / `__count` — page-local in `portal.css`, promote to `styles.css` on a 2nd consumer; distinct from the label-row `.field-counter`), and `#integration-wizard` (the 4-step flow as a composition reference).
- **Integration Detail** — entity header + `.tab-nav-list` tabs (used standalone, JS panel-swap). All six tabs built: **Overview** (3 metric+action cards, dynamic BPS fee-rule rows, fee receivers, onboarding checklist), **Wallets** (EVM/SVM/Bitcoin/Sui/Tron groups, nested per-chain overrides + connector line, real chain logos; non-EVM/SVM groups shown only when provisioned), **Fees** (dismissible contract-update notice, total/lifetime/BPS summary, per-chain collectable table with mini bars), **API Keys** (integration-scoped keys table, "shown once" note), **Webhooks** (endpoints `.list--cards` with LED status + event chips, recent-deliveries table with colour-coded response codes), **Audit log** (integration-scoped event table). All six tabs bound per-integration via `window.lifiRenderPortalDetail` (June 2026), including every wallet group — EVM defaults + L2 overrides, SVM, and Bitcoin/Sui/Tron (the last three via `walletFamily()`, shown only when provisioned).
- **Withdraw-fees flow** (June 2026 — `withdraw-fees.js` → `window.LifiWithdrawFees`, forked from the wizard's state-machine shape) — a 4-state `.modal--lg` flow (select → signing → success | error) opened via `[data-action="open-withdraw-modal"]` from the Detail → Fees per-chain Withdraw buttons (`data-withdraw-slug` + `data-withdraw-chain`) and the Fee Management per-integration rows (no chain attr → the integration's largest remaining chain balance). Chain-scoped by design (one chain = one transaction; the header `.chip-avatar` pill names it). Composes `.screen-header` / `.checkbox` (incl. the `:indeterminate` dash state added to the base primitive for the select-all) / `.list--cards` token rows / `.alert--info` with a `.spinner` seated in the `.spot-icon` disc / `.detail-list--divided` receipt / `.error-detail` (the revert echo — promoted June 2026 from the flow's page-local `.wfee__error` into the tier-2 alert-family sibling) / `.spot-icon-4xl --round` result discs; `.wfee__*` (portal.css) is layout glue only. Data: `LifiPartnerOrgs.getWithdrawable(orgId, slug, chain?)` (seeded per-chain token split; amounts derive from the wallet-sim price oracle at call time — store dollars, compute amounts) + `recordWithdrawal()` (in-memory per-token offsets ledger — drains every collectable surface live via `'change'`, writes a "Just now" audit entry, disables drained rows; EARNED figures — fees KPI, lifetime, txn counts — deliberately never shrink; resets on reload). Fires `lifi:fees-withdrawn`. The sim always succeeds; ⌥-click the CTA (or `LifiWithdrawFees.failNext()`) forces the error path, Try again re-signs and succeeds. **The "Withdraw all fees" button runs the BATCH variant (June 2026)** — the same chassis as a sequential per-chain wizard (`bselect → brun → bsummary`, `LifiWithdrawFees.openAll`): one signature per chain (EVM has no cross-chain atomic withdraw), driven through a **per-chain CHECKLIST, not a stepper** — the steps ARE the chains (avatar + amount + status per row, the select checkboxes become 20px progress marks: queued ring → spinner → done ✓ / failed ✕ / skipped –) and the list doubles as the running receipt. Legs come from `getWithdrawableAll(orgId, slug)` (per-chain ctx array, largest first, drained chains dropped); each landed leg settles via `recordWithdrawal` IMMEDIATELY (surfaces drain live per leg; Stop never rolls back). A failed leg PAUSES the batch — Retry / Skip chain / Stop batch; Stop with zero landed returns to bselect (picks intact), with ≥1 it exits through the partial summary (success / warn / danger disc grades the outcome; per-leg explorer links). Trigger: the static `.portal-fees-withdraw` button carries `data-action="open-withdraw-modal" data-withdraw-all`; `portal-render.js → renderDetail` stamps the slug + drained-disable + an honest gas-and-signature-count caption. Chain granularity in the batch; token granularity stays in the single-chain flow. ⌥-click demo-fails the SECOND leg. **The Fee Management view's "Withdraw all fees" twin runs the ORG-WIDE batch (June 2026)** — the SAME engine (legs in, scope-blind `startBatch / advanceBatch / stopBatch`), only the legs' scope + chrome/copy differ: legs = (integration × chain) pairs from `getWithdrawableAllOrg(orgId)` (flattens `getWithdrawableAll` across `org.integrations`, integrations largest-first), trigger = `data-withdraw-all-org` (no slug; `LifiWithdrawFees.openAllOrg`), checklist grouped by integration (quiet `.wfee__leg-group` xs-monogram labels emitted on each slug change), legs keyed `slug|chain` (a chain alone isn't unique org-wide), header pill = the org's mark + name (orgMarkHTML's real-logo-else-monogram fallback), copy counts "withdrawals" not "chains" (paused banner names `integ · chain`; Skip drops the "chain"). Stamped per-org by `portal-render.js → applyFeesWithdrawAll` (drained-gate + signature-count gas caption; runs inside `applyOrg`, so batch legs landing re-stamp it live). Catalog: `design-system/portal.html#withdraw-fees` + `#withdraw-fees-batch` (composition references incl. the org-wide grouped-select snapshot, the `#integration-wizard` pattern).
- **Left-rail views** (June 2026) — every nav target is now a real `.portal-view`: **Analytics** (Overview / Volume / Revenue — KPI grids, `.portal-chart` area charts, `.portal-barlist` breakdowns, top-routes + by-integration tables), **Fee Management** (org-wide collectable summary + per-integration table), **Transactions** (cross-integration explorer — KPI strip, status-seg toolbar, full status chip set, pager), **API Keys** (org keys with masked secrets + scopes), **Status** (operational banner, components with 90-day uptime tick strips, network table, incident list), **Audit Log** (org event table), **Members** (seats meter, member table with 2FA + roles, roles reference), **Settings** (org profile form, notification/security `.setting-row` toggles, danger zone). All bound per-org via `portal-render.js` (Status / network health / Roles stay platform-shared constants).

**New page-local primitives (promotion candidates once a 2nd consumer appears).** `.portal-usage` (+`--limit`) · `.portal-entity-head` · `.portal-metric` card · `.portal-wallet-group` + nested overrides · `.portal-fee-*` table cells/bars · `.portal-checklist` · `.portal-empty__art` illustration · `.portal-initial`/`--square`. KPI sparkline is inline SVG. Documented as candidates in commits; formal catalog cards pending (see below). **Duplicate-vs-genuine split (June 2026):** `.portal-bps` was retired → `.chip` (a tinted rate pill IS a chip); `.portal-usage` duplicates the universal `.progress-inline` — retire it in the deferred bar-consolidation (entangled with the onboarding bar + the `#portal-usage`/`#portal-checklist` catalog cards), NOT a promotion candidate; `.portal-metric` / `.portal-entity-head` / `.portal-wallet-group` / `.portal-checklist` are GENUINE Portal compositions (only PARTIAL-fit to DS primitives) — KEEP them, don't force-fit to `.stat-card`/`.screen-header`/`.list-item`.

**Phase status.** ✓ shell + Dashboard hub · ✓ Integrations (3 states) · ✓ Detail — all six tabs (Overview/Wallets/Fees/API-Keys/Webhooks/Audit-log) · ✓ every left-rail view (Analytics Overview/Volume/Revenue · Fee Management · Transactions · API Keys · Status · Audit Log · Members · Settings — June 2026; previously all routed to the generic placeholder) · ✓ launchpad card + FAB entry + hub real-logos. Each view is deep-linkable via `?view=<name>` (Detail tabs via `?view=detail&integration=<name>&tab=<tab>`). **Pending:** catalog docs for the new `.portal-*` primitives (June 2026 additions: `.portal-barlist`, `.portal-status-led`/`.portal-uptime`/`.portal-incident`, `.portal-key`/`.portal-key-cell`/`.portal-scopes`, `.portal-actor`/`.portal-audit-detail`, `.portal-member`/`.portal-you`/`.portal-role`, `.portal-danger-zone`/`.portal-danger-row`, `.portal-pager`, `.portal-tx-integ`, `.portal-form`, the re-tintable `--portal-chart-accent` slot) · ✓ **multi-tenant binding** — every view (incl. the 6 Detail tabs) binds to the active partner org via `window.LifiPartnerOrgs` + `portal-render.js` (June 2026). Remaining static: Integrations empty/at-limit preview states (tracked follow-up; the detail Wallets-tab groups — EVM/SVM/Bitcoin/Sui/Tron — are now all data-bound). **Pending:** auth flow + onboarding wizard (deferred per kickoff); a real backend (the partner-org layer is a rich mock, not a live API). Build-stage reference screenshots export to `~/Desktop/lifi-portal-build/` (per-screen, light + dark) via the static-server + poll-and-kill recipe (bug-history).

**Open decisions (designer's call — flagged, not resolved).**
- **Portal accent** — only 3 brand accents for 4 launchpad cards; Portal currently reuses `--accent-secondary` (violet, like Playground) + `btn-accent2`. A distinct 4th accent is a quick brand decision.
- **Faithful deviations** — the plan-limit + contract-update banners use our `.alert--info` (tinted + blue CTA) vs the concept's white card + black button; accent is LI.FI-blue vs the concept's violet. Align or keep.
- **Static → live data** — RESOLVED (June 2026): every Portal surface binds to the `window.LifiPartnerOrgs` mock per the Multi-tenant section above (not `LifiTransactions` — partner-aggregate traffic is a different domain than the persona-keyed single-wallet history). The remaining step is a real backend behind the same API surface; the mock is shaped so that swap is a data-source change, not a render change.
- **Launchpad grid** — switched to 2×2 for 4 cards; 4-across is the alternative.
- **a11y** — the custom view-switch + detail tabs use `<a role="tab">` / `<button>`; a focus-order + roles pass is worth doing before "production".

**Source of truth.** This section is canonical for Portal's architecture + status. Implementation: `portal.{html,css,js}`; launchpad card in `index.html`; FAB entry in `shared.js → initDesignFab` (`PORTAL_ICON` / `portalEntryHTML`). When a phase lands or a decision resolves, update this section in the same commit.

### Playground default config — LI.FI + Jumper share one configuration (view-rail + Advanced)

The playground widget config has three axes — **presentation** (`single` / `view-rail`), **tier** (`simple` / `advanced`, view-rail only), and **layout** (`adaptive` / `dual` / `compact`). The canonical config, and the **cold-load default**, is **`view-rail` presentation + `advanced` tier** → 3 plain tabs (Swap · Bridge · Limit), **dual** layout. Both Design-Workshop brands (LI.FI and Jumper) use this exact tuple — they differ **only by theme + site-preview nav** (`lifi-1` vs `jumper-1`; `nav: 'lifi'` vs `'jumper'` — the `nav` axis applies the matching `.host-nav` host bar by clicking the rail's `[data-nav-option]` card, so rail state + row value + storage sync through `initRailNavPreview`'s canonical path, June 2026). Jumper is the source of truth; LI.FI was synced to it (June 2026).

**Why no Tabs presentation / no adaptive.** The `tabs` presentation rendered the `.seg-menu` split-tab WITH a visible "more modes" chevron (`data-action="modes-open"`) that opened a full-screen modes-overflow screen. That affordance **duplicated the View rail's tier mechanism** for the only 4th mode (Gas) — view-rail already reaches Gas via the Simple tier — so it was **retired June 2026**: the `tabs` presentation card, the `initModesScreen` handler, and the `data-state="modes"` screen are gone, and the presentation axis collapsed to `view-rail` / `single`. The `.seg-menu` split-tab **primitive is preserved** (catalog `#seg-menu`, popover backend) so an overflow chevron can re-mount in the view-rail seg when modes genuinely exceed three (DCA / perps). `view-rail` also hides the free Layout drill, so layout is tier-driven (advanced → dual) — never `adaptive`.

**Tier tab sets + the Private mode (June 2026).** The two tiers carry DIFFERENT three-mode sets: **Advanced** → Swap · Bridge · Limit (the cold-load default); **Simple** → Bridge · Private · Gas. Bridge is the only mode in both tiers and the re-home anchor when a tier switch drops the active mode (was Swap; changed when Simple dropped Swap for the Bridge·Private·Gas set). **Private** is the Simple tier's 2nd tab (it is NOT in the Advanced seg) AND is selectable as a **Single-presentation lock** (June 2026 — added to `SINGLE_TYPES` + the `#railSingleTypeGrid` picker, placed after Bridge to mirror the Simple-tier Bridge·Private·Gas grouping) — Bridge's any-token / any-chain mechanics routed through **Houdini Swap** (privacy aggregator): the route panel shows a single forced route (`ui-quote-list.js` passes `options.forceProvider` to `quote-sim.js → getRoutes()` when `body[data-widget-mode="private"]`), and the receiving wallet is **mandatory** — the `.ui-destination-wallet-card` is always shown once connected (gated by `body[data-widget-mode="private"][data-playground-wallet-connected]`, NOT `[data-destination-set]`), with a required zero-state (a left-aligned "Enter address" `.chip-avatar` pill — `.ui-destination-wallet-card__enter` — reusing the token-picker "Select" affordance, swapped in for the set-state identity row, plus a "Required" `.chip-warn` marker — `.ui-destination-wallet-card__required` — top-right on the `__header` label line) and a **disabled Review CTA** until an address is set, both driven by `swap.js → syncPrivateGate()`. Because the fixed pair now differs per tier, `initViewRail → syncTierSeg` drives **all three** seg slots per tier (2 fixed `.seg-item` + 1 `.seg-item--menu`), not just the menu tab; markup order is display order (the old `order:-1` Swap-first CSS hack in `swap.css` was retired). The **Single-presentation lock** force-syncs the picked type by driving `window.lifiApplyMode(mode)` directly (June 2026 — `syncWidgetMode` no longer `.click()`s a seg tab, which couldn't reach a mode the active tier's seg omits, e.g. Private or Gas while Advanced; the rewrite also repaired a latent Single→Gas no-op in the Advanced tier). `syncPrivateGate` is presentation-independent, so the mandatory-receiving-wallet gate fires identically whether Private is entered via the Simple-tier seg or the Single lock. Adding a mode touches `KNOWN_MODES` + `MODE_LABELS` + `MODE_TITLES` (+ `TIER_MODES` + `TIER_TAB_LABEL` if it joins a tier) + an `applyMode` branch, plus any body-gated CSS. Full spec: catalog `#widget-modes` + `design/components/swap.md → Widget modes`.

**Where the default lives.** Cold-load fallbacks: `swap.js → initRailPresentationPanel` (`pres = 'view-rail'`) + `initViewRail` (tier `'advanced'`). Brand tuples: `playground.html → BRANDS` (`lifi` === `jumper` except theme/label). Markup defaults (the `is-active` presentation card, the `data-presentation-row-value` label, the view-tier rail `is-active`) match. `applyBrand` is **not** called on cold load — the default is the code fallback per "Default = code fallback, not localStorage echo."

**One-time config migration (June 2026) — the documented exception to "code fallback, not localStorage echo."** A returning user's *saved* presentation/tier/layout shadows a cold-load fallback (hydrate prefers storage), so the default flip alone wouldn't reach anyone who'd ever picked a brand or presentation in the rail — much of the stale `tabs` was **incidental**: `applyBrand` persists a presentation by simulating a card click, and the pre-June `lifi` brand tuple bundled `presentation:'tabs'` + `layout:'adaptive'`, so picking the LI.FI brand wrote `tabs` as a side effect. `swap.js → migratePlaygroundConfigRev()` (stamped by `lifi-playground-config-rev`, runs **once**) clears the three config keys **plus** the two retired fall-through keys (`lifi-playground-flow-mode` / `-mode-nav` — the presentation hydrate derives `single` from `flow-mode` when `presentation` is absent, which would defeat the reset) so every returning user re-hydrates to view-rail + Advanced. This intentionally clobbers a deliberate `tabs` / `single` / Simple pick — the approved trade for one canonical config across the team. Bump `CONFIG_REV` to re-run on a future default change. This is the *only* sanctioned localStorage clobber of a user's playground config; the steady-state rule (storage wins until a rail **Reset**) otherwise holds.

**Contributing.** Don't re-introduce the `tabs` presentation or a default seg-menu overflow chevron — the June 2026 retirement removed both because the affordance duplicated the view-rail tiers for the lone 4th mode (Gas). `single` stays selectable as a **manual** rail option (alongside the default view-rail); only the defaults are locked to view-rail + advanced. If a genuine 4th+ mode ships (DCA / perps), re-mount the `.seg-menu` overflow chevron in the view-rail seg — the primitive is still documented at catalog `#seg-menu` — rather than resurrecting the Tabs presentation. Keep the LI.FI and Jumper brand tuples identical (theme + site-preview nav aside) so the two stay one configuration. Theme is unaffected by this rule — LI.FI 1.0 is still the cold-load palette per "LI.FI 1.0 is the only default."

### Checkout widget — a distinct widget TYPE (the partner pay-in flow), not a 5th exchange mode

The partner-embeddable **pay-in widget** (June 2026): takes an end user from "I owe X" to a confirmed on-chain deposit, funded by one of four sources (Pay from Wallet · Transfer Crypto · Connect Exchange/Mesh · Deposit with Cash/Transak). It usually renders inside a **modal** on the partner's page, but can also render **inline** like the swap widget, and is fully themeable via the Theme Composer. Built in the playground so it's presentable + customizable screen-by-screen. **`design/components/checkout.md` is the canonical spec; this section carries the cross-session awareness + the contracts future playground work must not break.**

**The widget-type axis — `body[data-widget-type="exchange" | "checkout"]` is the sole gate.** Default **`exchange`** is the **code-level cold-load fallback** (per "Default = code fallback, not localStorage echo" — a saved pick only overrides, never seeds a non-default). `exchange` shows `.widget-stage` (the swap widget, machinery unchanged); `checkout` hides it and shows `.checkout-stage` (inline host) OR opens `#checkoutModal` (modal host). Gates are **CSS-only** (`checkout.css`) — the exchange widget keeps running behind `display:none`, so switching is instant. The rail carries a shared **Widget** section (Type → Exchange/Checkout) + a checkout-only **Checkout** section (Host → Inline/Modal); the exchange-only **Layout** section hides in checkout mode (`.playground-rail__section--exchange` / `--checkout` gates). Wallet + Theme stay shared.

**Partner config (the merchant request) — `window.LifiCheckout.config`.** The product-vs-demo boundary: a config object `{ receive, charge, merchant, theme, return }` a real integration would pass, surfaced live on the rail's Checkout section (**Merchant** drill — partner identity + theme from `window.LifiPartnerOrgs`; **Charge** drill — Fixed-token / Fixed-USD / Open mode + amount + merchant-token picker) and driven by `LifiCheckout.configure(partial)` / `getConfig()`. **`charge` present LOCKS the Receive side to the charge and SOLVES the Send** via a reverse quote (`quoteFixed()` probes forward `getQuote` for fee+gas, then `fromUsd = (toUsd + gas)/(1 − fee)`, returning a quote whose `toAmount`/`toUsd` are exactly the charge); `charge: null` is the open deposit (user types). A partner `theme` paints the CONTENT CANVAS via `ThemeComposer.applyPresetScoped([data-palette-canvas])` (the rail's scoped channel — chrome stays neutral); `merchant` renders a "Paying &lt;name&gt;" lockup + labels the terminal button "Return to &lt;name&gt;". On completion the widget fires **`lifi:checkout-complete`** `{ source, receive, charge, merchant, returnUrl, txId }` (the result-out half). Still a MOCK contract — no iframe / `postMessage` / SDK packaging. Full spec: `design/components/checkout.md → Partner config`.

**Host model is host-agnostic.** `checkout.js → renderScreen()` returns the current screen's inner markup; `render()` paints it into every `[data-checkout-flow]` host (inline `.panel.ui-card.ui-checkout` + modal `.modal.panel.ui-checkout`, both pinned to the `--w-7` 416 column). `body[data-checkout-host="inline" | "modal"]` (default `inline`) gates which shows; both render identically. The modal auto-resizes between steps via `window.LifiModalResize`.

**Per-method flow engine — `FLOWS[state.source]` selects the screen sequence** the funding-source pick starts. Shared front spine (`funding-source → select-token → amount`) diverges in the middle: Pay-from-Wallet → `progress → completed → details`; Transfer Crypto → `deposit-address → progress → completed → details`; **Deposit-with-Cash (Transak)** → a fiat front spine (`cash-currency → cash-amount`) → the embedded Transak sub-modal (`tk-amount → tk-wallet → tk-confirm → tk-card → tk-auth → tk-status`) → the LI.FI back half (`cash-watching → cash-progress → cash-completed → cash-details`). `go(step)` → `render()` → `afterEnter(step)` schedules the mock auto-advances. **Adding a method = one `FLOWS` entry + its funding-source branch + any new screen renderer** — don't fork the engine. **Error/warning/refund states are terminal branches** (`cash-cancelled`/`cash-no-route`/`cash-unreachable`/`cash-error`/`cash-refund`), reached via `showState(kind)` — NOT linear steps; the natural trigger is dismissing the Transak sub-modal mid-flow (→ `cash-cancelled`), plus the ⌥-click demo-fail nod on "Continue to payment" (→ `cash-error`) and `window.LifiCheckout.show(kind)` (catalog snapshots + console).

**The Transak sub-modal is a FIXED-BRAND surface — the SECOND exception to theme-via-canvas after the QR plate.** The six `tk-*` screens recreate the embedded Transak widget in Transak's OWN brand (fixed white surface + Transak blue `#2563eb` + their tab/card/field chrome + "Powered by Transak", the authentic `logos/partners/transak.svg`), in BOTH light AND dark mode (verified). `providerOf(step)` returns `'transak'`; `render()` flags the host `data-checkout-provider="transak"` and `screenHTML()` delegates to `transakScreenHTML()` (its own chrome, no LI.FI screen-header/footer); `checkout.css` swaps the panel surface to fixed white. The principle (a surface representing an external system wears its identity, not the LI.FI theme) is the QR-plate exception writ large — and the same pattern serves the deferred Mesh sub-modal. **`.ui-transak-*` is page-local provider chrome — never reach for it elsewhere.**

**~80% reuse — compose, don't re-author.** The token/currency picker IS `.ui-token-picker`, the amount cards ARE `.ui-amount-card`, the receipt IS `.ui-tx-summary`, the pipeline checklist IS `.status-row`, the connect step IS `window.LifiWalletConnect`, quotes come from `window.LifiQuoteSim` (cash quotes derive live from the price oracle, never the stale Figma fixtures). The genuinely-new layer is the flow panel + per-method engine + a few screen compositions. Cash-flow primitives (tier-3): `.ui-checkout-fiat` (fiat amount card), `.ui-checkout-steps` (pipeline checklist), `.ui-checkout-status--compact`/`-countdown` (in-progress ring), `.ui-checkout-received`/`-receipt`/`-transfer` (back half), `.ui-checkout-status--message`/`.ui-checkout-actions` (error/refund). **`.status-row` gained `--active` (spinner in the icon slot) + `--pending` (empty muted ring) states in `styles.css`** so the checklist shows upcoming steps, not just completed ones (reusable by Portal/withdraw-fees). Assets (asset-first): circular fiat flags `avatars/flags/{usd,eur,gbp,aud,cad}.svg` (CC0 circle-flags) + `logos/partners/transak.svg` (authentic, from Transak's CDN — a single file, not a light/dark pair, since it only ever renders on the fixed-white Transak chrome). Earlier base primitives: `.ui-checkout` (flow panel), `.ui-checkout__powered`, `.ui-checkout-status` (+`--inline`), `.ui-checkout-qr`, `.ui-checkout-address`, `.spot-icon--neutral`, `.divider--label`.

**Theming is automatic** — the LI.FI screens live inside `[data-palette-canvas]`, so the Theme Composer re-colors them like the swap widget (accent/surface/radius/all), **no checkout-specific theming**. **Two surfaces are deliberately fixed (not themed):** the QR plate (white with dark modules, scannability — `qr.js → window.LifiQR.toSVG`, `[data-checkout-qr-code]` is the swap-in hook) and the Transak provider sub-modal (Transak's brand, the external-embed representation — above).

**Don'ts.** Don't make Checkout a 5th exchange mode (it's a separate type; forcing it through `applyMode`/chain-mirror/receive-panel would distort both). Don't re-compose what a DS primitive already covers (the alignment pass exists *because* an early version re-composed the picker from base `.list-item`s and drifted — see [[canonical-primitive-first]]). Don't theme the QR plate OR the Transak sub-modal (both fixed-brand by design). Don't reach for `.ui-transak-*` outside the provider sub-modal. Don't add per-component theming.

**Status (June 2026).** Pay from Wallet · Transfer Crypto · **Deposit with Cash (Transak)** happy paths built (both hosts, themeable LI.FI screens + fixed-brand Transak sub-modal + scannable QR), plus the cash flow's error/warning/refund states. **Deferred punch-list:** Connect Exchange (Mesh) method (its sub-modal reuses the Transak provider-chrome pattern); error/refund states for the Pay-from-Wallet + Transfer methods; richer in-flow error forcing (cash errors today reach via the Transak-dismiss trigger + the ⌥-click nod + `show(kind)`); recording cash runs via `LifiTransactions`; live expiry countdown; EIP-681 QR payload; exact-416 modal width.

**Source of truth.** Spec: `design/components/checkout.md` (canonical). Implementation: `checkout.js` (`window.LifiCheckout`) · `checkout.css` (the `.ui-checkout-*` family + the gates) · `qr.js` (`window.LifiQR`). Catalog: `design-system/playground.html#ui-checkout`. The live flow runs in `playground.html` → rail → **Widget = Checkout**. When the axis / host model / engine evolves, update this section + checkout.md in the same commit.

### Single-line descriptions in menus and navigation

Every description in a menu, navigation dropdown, or top-level navigation surface **must fit on a single line at the column's natural width** — no wrapping, no two-line drops. Applies to:

- FAB menu items (`.menu-item-desc`)
- Marketing nav dropdowns (`.dd-desc` under each `.nav-dropdown-item`)
- Mobile menu dropdown items (same `.dd-desc` selector)
- Any future menu primitive that pairs an item with a one-line caption

**Why.** Wrapping descriptions break the visual rhythm of a menu — every item should occupy one row + one optional caption, scannable as a flat list. A two-line caption pushes neighbours out of alignment, makes the menu feel ragged, and signals to the reader that there might be still more text hidden offscreen. A single-line caption is a definite, honest "this is everything you need to know about this row."

**How it's enforced.** `.dd-desc` and `.menu-item-desc` in `styles.css` carry `white-space: nowrap; overflow: hidden; text-overflow: ellipsis`. Copy that exceeds the column width truncates with an ellipsis — that ellipsis is the visible signal that the rule was violated and the copy needs shortening.

**How to comply when authoring.** Keep descriptions to ~28–34 characters at most for FAB menu items (the `.ds-doc-menu-panel` is 280px wide minus icon + padding ≈ 200px text column). Marketing nav dropdowns have wider columns; aim for ~38–46 chars. Pick declarative noun phrases or short imperatives — "Live color system editor", not "A live editor for the color system that lets you tune the brand palette."

**Exception.** If a specific menu item genuinely benefits from a multi-line description (rare — usually it means the item should be a destination page, not a menu row), surface the request explicitly to the user before authoring it. The opt-out mechanism is a per-item modifier class — author it then, not as a default.

This rule applies project-wide. **Top-level navigation labels** (the chip text in the navbar itself — "Components", "Dashboard", "Playground") are inherently short and aren't affected by this rule, but if a future variant adds a description-line to top-level chips, the same single-line discipline applies.

### Priority+ overflow over hand-tuned breakpoints — the third "primitive that survives growth"

When a growing collection of inline items — top-level nav tabs, filter chips, breadcrumbs, toolbar buttons, segmented-control modes, any horizontal row whose item count expands over time — starts overflowing on narrow viewports, reach for **measure-and-collapse** before tuning a hand-picked breakpoint. The reflex move ("the row spilled, raise the breakpoint to where it stops fitting today") is the wrong instinct: it ships a magic number that's correct now and broken the next time an item is added.

**The pattern.** A `ResizeObserver` on the row's frame fires a `layout()` pass that (a) measures the available width as `frame.clientWidth − padding − fixed-siblings.offsetWidth`, NOT the row's own `clientWidth` (which is content-sized in a flex layout and never reports "too wide" — it just spills), (b) iterates the items, accumulates `offsetWidth + gap` until the running total exceeds the budget, and (c) toggles `[hidden]` on the overflowing items + reveals a trailing "More ▾" trigger whose menu lists them — in the DS top nav, that menu is the SAME `.nav-dropdown` mega panel every other tab uses (one expansion paradigm; the earlier compact popover was retired June 2026). Items are hidden in place — never moved in the DOM — so keyboard tab order matches visual order and screen readers announce only what's visible.

**Four load-bearing implementation rules:**

- **Available width is the FRAME minus the fixed siblings — not the row's own width.** A `display: flex; margin-left: auto` row's width tracks its content, never reports "too wide." Compute `frame.clientWidth - paddingLeft - paddingRight - brand.offsetWidth` (and subtract any other fixed siblings on the same flex line), then compare against the summed item widths.
- **`[hidden]` needs the canonical re-assert on flex items.** `tabLi.hidden = true` does nothing on a `display: flex` `<li>` (specificity loss — same recurring trap documented under [`[hidden]` loses to a self-set `display`]). Re-assert `.row-scope > li[hidden], .more-control[hidden] { display: none; }` in the scope-owning rule block.
- **Reusing a styled `.foo` look on a `<button>` host needs `font-family` alone, not `font:` shorthand.** When the More trigger reuses an existing link-styled primitive (`.nav-link-item`, `.tab-link`, etc.), the `<button>` UA chrome leaks and needs the scoped reset (`appearance: none; -webkit-appearance: none; border: 0; background: transparent; font-family: var(--font-sans)`). **Don't reach for `font: inherit`** — it would clobber the link primitive's deliberate `font-size` / `font-weight` / `letter-spacing` and re-derive them from the inherited body type, breaking the size ladder. Refinement of the `.menu-item`-on-`<button>` dual-host reset for primitives where the base already pins type properties.
- **Collapsing an item into the overflow menu must preserve PAGE-LEVEL reachability.** A single overflow row per item is LOSSY when the item's own dropdown/submenu is the only route to a nested page — collapsing it orphans that page entirely (competitors.html under the Brand tab: no own tab since Apr 2026, no in-page link, no ⌘K coverage; June 2026). The overflow pass must also emit one row per unique dropdown destination on a page NO top-level item owns (`ds-nav.js`: `pageOf()` filename match against the `TAB_PAGES` set, deduped across tabs; anchors into item-owned pages don't qualify — their item's own row covers them). The invariant: *every page reachable from the expanded bar stays reachable from the collapsed bar.* Belt-and-braces: nested-page siblings also cross-link in their own section navs (`.tab-nav-lg`), so the pages reach each other at any width. Full diagnostic: bug-history → "DS navbar priority+ overflow" Trap 4.

**This is the third "primitive that survives growth" pattern in the codebase.** The siblings are the **card-gap ladder** (semantic gap tokens — `--gap-card-sm/md/lg/xl` — rather than picking raw `--space-*` per consumer; a future "Compact" preset re-points every consumer via cascade) and the **sizing ladders** (sm / default / lg / xl tiers; every component graduates through the same vocabulary so adding a new control inherits the matrix). All three solve the same meta-problem: **the system stays correct when item count or instance count changes** — no per-component re-tuning, no magic numbers that decay.

**Where this is enforced.** `ds-nav.js → initPriorityNav()` is the reference implementation (the DS top nav was the trigger for this rule — see bug-history → "DS navbar priority+ overflow" for the full diagnostic). When a second consumer hits the same shape (a chip rail, an over-stuffed toolbar), extract the algorithm into a shared helper rather than re-deriving the width math.

### DS page identity contract — nav label drives eyebrow + URL filename + ⌘K page entry

When renaming a DS sub-page tab (`Components` / `Dashboard` / `Brand Guide` / `Lab` / `AI` / `Competitors`) in `ds-nav.js`, the same rename **must** propagate to three other surfaces: the hero eyebrow (`<span class="header-eyebrow-accent">[Nav Label]</span>`), the URL filename (kebab-case of the label, e.g. `Brand Guide` → `brand-guide.html`), and — for the non-catalog pages — the matching `SITE_PAGES` row (label + caption mirror the tab's `label` + `desc`) in `scripts/build-search-index.js`, then regenerate the manifest (June 2026 — the ⌘K all-pages coverage). The nav label is the single source of truth — eyebrow, URL, and ⌘K entry track it.

**The contract — when a tab label changes, propose the URL rename in the same turn.**

> "Rename `<old>.html` → `<new>.html` to match the new label? I'll leave a redirect stub at the old path so inbound links keep working."

Redirect-stub shape: an HTML file at the old path with `<meta http-equiv="refresh" content="0; url=lab.html">` plus a JS `window.location.replace('lab.html')` fallback. Pattern used previously for `art.html → motion.html`. Full rules (eyebrow shape, nested-page nuance for `competitors.html`, URL-rename exception process): `.claude/skills/lifi-ds-docs/SKILL.md → DS page chrome`.

### Dashboard live data — pipeline pointer

The DS dashboard (`design-system-dashboard.html`) reads from a committed JSON snapshot (`dashboard-data.json`) via `data-dash*` attributes. **Before committing any change that moves the numbers** (adding a component, new tokens, new DS pages), refresh the snapshot:

```sh
node scripts/collect-dashboard-data.js
```

Full pipeline + attribute vocabulary (`data-dash` / `data-dash-var` / `data-dash-style` / `data-dash-donut` / `data-dash-sparkline` / `data-dash-bars` / `data-dash-list`) and the "when adding a new live metric" workflow: `.claude/skills/lifi-ds-docs/SKILL.md → Dashboard — live data flow`. The `data-dash-bars` binder (+ `data-dash-bar-labels`; vertical bars into a `.bars` shell) drives the dashboard's cadence panels via the shared plot frame (`chartFrame`, defined in `chart-frame.js`); `data-dash-sparkline` stays the small edge-to-edge contract. (The `data-dash-area` binder was added June 2026 but shipped with zero consumers — the cadence panels went with `data-dash-bars` — and was retired as dead code in the same June 2026 line/area catalog single-source refactor; its monotone-cubic `monotonePath` was lifted into `chart-frame.js → window.LifiCharts` for the catalog line/area renderer.)

### Token prices — sync pipeline + portfolio derivation

The swap playground's token USD prices live in **two** hardcoded sources — `data/tokens.json` (`priceUSD` per row, drives the picker) and the vendored `vendor/lifi-personas/wallet-sim.umd.js` `__tokenPrices__` oracle (the runtime price source behind every portfolio fiat + quote). They're kept in lockstep by **`scripts/sync-token-prices.js`**, which fetches live USD from CoinGecko (free `simple/price`) ONCE and rewrites BOTH files from that single fetch — so they can't drift from *each other*, only (together) from market. Same canonical-source-vs-rendering discipline as the dashboard snapshot: one fetch is the source, both files are renderings of it.

```sh
node scripts/sync-token-prices.js                 # spot: fetch + rewrite both files
node scripts/sync-token-prices.js --check          # no network; report staleness
node scripts/sync-token-prices.js --dry-run        # fetch + diff, no write
node scripts/sync-token-prices.js --history        # 180d daily history → data/price-history.json
```

- **Freshness is a `/checkup` check (B8).** `nightly-ds-audit.sh` runs `--check` (reads the `lastUpdated` stamps on all present price files — both spot sources + `price-history.json`, no network) and offers the sync as the fix. Default threshold **7 days** (`MAX_AGE_DAYS` in the script; `--max-age-days N` overrides per run).
- **Never hand-edit a price** in either file — run the sync. The symbol→CoinGecko-id map (`CG_IDS`) lives at the top of the script; add an entry (with the `coingecko.com/en/coins/<id>` URL as a comment) when a new symbol needs live pricing. Symbols with no reliable id keep their existing value + warn rather than zeroing out.
- **The vendored bundle is edited in-place** (deliberate, June 2026). If `@lifi/personas` is ever rebuilt/reinstalled it overwrites `wallet-sim.umd.js` — just re-run the sync. The `tokens.json` `_meta.purpose` note points at the script so no one hand-edits a price there.

**Portfolio values derive at runtime — never cache a dollar total.** Every fiat figure (per-token USD, the "Your tokens" header total, persona-selector totals) is computed fresh as `balance × getPriceUsd(symbol)` via `sim.toUsd()` / `sim.getTotalUsd()` — no precomputed aggregate is stored anywhere, so a price sync flows through every portfolio number automatically. When building portfolio surfaces (a tracking page is planned), **store amounts, compute dollars** — baking a `totalUsd` into a data file or snapshot creates a *third* price source that drifts, the exact trap the single-fetch sync exists to kill. Reach for the wallet-sim API (`getBalances` / `toUsd` / `getTotalUsd`), not a cached total.

**Transaction-history prices derive at runtime too — same discipline, BOTH surfaces (June 2026).** The Limit-mode Orders panel (`window.LifiOrders` ← `orders-data.js`) AND the Activity feed + tx-details (`window.LifiActivity` ← `activity-data.js`) each RECOMPUTE their price-bearing display from the live oracle at render time, keeping only each tx's INTENT in the fixture. Reason: the `lifi-transactions-data.js` fixtures were authored at an old ~2.5× price universe (ETH 3,200–4,000 vs live ~1,555, UNI 8.50 vs 2.39, ARB/OP 12–21×) and never moved when the sync brought tokens.json + the oracle to market — a *frozen-fixture* variant of the cache-a-dollar-total trap, and it spanned both the limit-order fields AND every swap/bridge/gas `Exchange rate` string + per-tx USD. Orders derivation: `market` = spot price of the volatile side, `trigger` = market × (1 + `distancePct`/100), `buy` = direction-aware (sell × trigger for volatile→stable, sell ÷ trigger for stable→volatile), USD = amount × price. Activity derivation (commit `74adba6`): keep the SENT amount, derive the received amount via the stored price-impact haircut (`toUsd = fromUsd × (1 + impact)`, so `from ≈ to` stays coherent), both USD, and the rate row (volatile-as-base; same-token bridges keep their fee-ratio strings). **Price-source priority (both): `LifiPickerData.listTokens()` (tokens.json — the comprehensive 49-token registry) PRIMARY → `getPriceUsd` (the wallet-sim oracle) FALLBACK → stored fixture string (last-resort, never blanks a cell). The oracle is a persona-HELD subset of ~10 tokens (ETH/WBTC/USDC/USDT/DAI/ARB/OP/SOL/stETH/USDS) — it returns `null` for UNI/wstETH/LINK and any token no persona holds, so reach for `LifiPickerData`/tokens.json whenever you need a price for an ARBITRARY symbol, not `getPriceUsd`.** The shared lookup + format helpers live in `tx-price-derive.js` (`window.LifiTxPrice`), consumed by both translators (load before either). Full diagnostic: `.claude/skills/lifi-ds-docs/references/bug-history.md → "Orders table prices drifted from the synced token oracle"`.

**Adding a PICKABLE token requires an oracle entry too — `getQuote` reads the oracle ONLY (June 2026).** `quote-sim.js → getQuote()` prices both legs via `sim.getPriceUsd(symbol)` (the wallet-sim `__tokenPrices__` oracle) and returns **`null`** if either symbol is absent — whereas `getRoutes()` reads `fromToken.priceUSD` (tokens.json) FIRST, then falls back to the oracle. So a token added to `tokens.json` for the picker (a browse/swap target the active persona doesn't hold) ALSO needs an entry in `vendor/lifi-personas/wallet-sim.umd.js → __tokenPrices__`, or selecting it yields no quote (the Receive card stays empty). This is why the Solana enrichment (June 2026) added the 6 new tokens to BOTH `tokens.json` AND the oracle — SOL→BONK returned `null` until the 5 non-held prices (JUP/ORCA/PYTH/BONK/WIF) landed in the oracle. The `tokens.json`↔oracle lockstep isn't only for price freshness; it's load-bearing for quotes. (Add the new symbols to `scripts/sync-token-prices.js → CG_IDS` too, so a future sync maintains them.)

**Value-over-time (the curve a tracking page will chart) is `portfolio-series.js` → `window.LifiPortfolio.getSeries(personaId?, {days})`.** It composes the two raw inputs — `data/price-history.json` (per-symbol daily history, the `--history` snapshot) × today's persona balances — into a `[{ t, usd }]` daily series, with the **last point pinned to the live spot total** so the right edge equals `getTotalUsd()` exactly. This is **fidelity tier B**: holdings held constant, value moves with real market history — it does NOT yet reflect balance changes over time. **Tier C** (replay `lifi-transactions-data.js` to reconstruct balance-at-each-date, so the curve shows activity steps too) is the deferred upgrade; it reuses the same `price-history.json`, so it's an extension of the helper, not a rebuild. The helper isn't wired into a page yet — the future portfolio page includes the `<script>` and consumes the series.

### Figma colour sync — one-way from `styles.css`

Figma's colour variables are a **one-way rendering of `styles.css`**, not a second source of truth — the same canonical-source-vs-rendering discipline as `CLAUDE.md` → catalog and the live codebase → `dashboard-data.json`. Established May 2026 when the brand/spectral/semantic/surface/text colours were first exported to the **Foundation 2.0 [Library]** Figma file (`fileKey H94oR8UnAAmrtRhrLGsi5v`).

```
styles.css ──(scripts/export-color-tokens.mjs)──▶ design/tokens/colors.json ──(lifi-figma-color-sync skill + use_figma)──▶ Figma collection "LI.FI · Colours (Web)"
  CANONICAL                                          committed manifest                                                         RENDERING (never the source)
```

**The contract.**

- **`styles.css` is the only source.** Never hand-edit a colour in Figma as the source; if a Figma colour is wrong, fix `styles.css` and re-sync. The sync upserts variables *by name* (idempotent) and never reads Figma back into the codebase.
- **Values are rasterized from the live cascade, never copied from hex comments.** Most colour tokens are computed (`oklch(from …)`, `color-mix`, `clamp`, dual-mode `[data-theme]`), several hex comments are stale (the light spectral palette is re-tuned at `styles.css:1535`), and high-chroma OKLCH is gamut-mapped on display. `export-color-tokens.mjs` paints each token in headless Chrome and reads the sRGB bytes — the only faithful value for an sRGB surface like Figma.
- **The mirror collection stays standalone.** Don't merge into the Figma file's existing `Primitives` / `Theme` collections — those are the product/app token lineage (grey/alpha ramps, Jumper modes). The web DS is a separate lineage; merging force-marries two systems that should evolve independently.
- **Three layers, mirroring the catalog/dashboard infra.** Principle (this section) · mechanism (`scripts/export-color-tokens.mjs` → `design/tokens/colors.json`) · procedure (the `lifi-figma-color-sync` skill, which carries the `use_figma` builder code + the Figma gotchas — period-banned variable names, sizing-after-append, mode-pinning).
- **Source of truth: `styles.css` `:root` / `[data-theme="light"]` colour blocks.** The manifest stamps the source commit; the Figma board carries a provenance subtitle. Scope is colours today; the framework is extensible to spacing/type/radii later (don't pre-build).

When a colour changes in `styles.css`, re-run the extractor and re-sync via the skill in the same pass — same "refresh the rendering when the canonical rule changes" rule as the dashboard snapshot and the catalog renderings.

**The bigger program.** Colours are phase 1 of a full DS → Figma foundation pipeline (tokens → icons → components), where the decision is locked: **code is the source of truth, Figma is a generated artifact** (variables + components regenerated from the DS, never hand-edited in Figma). The component layer is a constrained code → Figma compiler — render the catalog component, walk its computed style/geometry, reverse-map values to the token manifests so fills bind to variables, and build Figma component sets via `use_figma`. Full architecture, phased roadmap, open decisions, and cross-cutting Figma MCP gotchas: **`design/figma-sync.md`**.

### Brand → theme preset — the `lifi-brand-theme` skill + extraction scripts

Adding a company as a selectable Theme Composer preset (a competitor, a partner wallet/app, or an own brand) goes through the **`lifi-brand-theme`** skill — never hand-author OKLCH seeds from a guessed hex. The split is the house pattern (skill carries judgment, scripts do the deterministic transform):

- **`scripts/extract-brand-colors.mjs <url>`** — headless-Chrome reader of a LIVE site's computed brand colours (for SPAs whose markup has no inline hex; zero-dep raw CDP, mirrors `export-color-tokens.mjs`). Ranks by usage, so the top hit is usually a neutral — read the chromatic candidates critically.
- **`scripts/hex-to-preset.mjs --name … --id … "<hex>" …`** — pure-JS hex→dual-mode OKLCH seed block + verbatim `palette[]` + a WCAG-AA contrast report flagging any light-mode accent that needs a deeper L.

The skill (`.claude/skills/lifi-brand-theme/SKILL.md` + `references/brand-sourcing.md`) owns the judgment: the **source ladder** (brand book / press kit / committed theme tokens / logo SVG → live scrape last; never eyeballed/averaged — see `design.md §13 → Competitor brand sourcing`), the **monochrome-base brand → mono-hue-at-three-lightnesses** convention (Kast mint, VilenDesign sky-blue, Hop purple), the **`palette[]` (verbatim display) ≠ seeds** rule, **bump `revision` to propagate** an edited preset to returning users (`shared.js → ensureDefaultPreset`), and **category honesty** (`Partner` / `Studio` / sector, not "competitor" for a partner). Presets stay selectable-only — none auto-applies, so this never touches the LI.FI-1.0-default rule. The brand grid in `design-system/competitors.html` auto-renders every preset EXCEPT those in its `HIDDEN_IDS` set (June 2026 — the page is a competitive landscape, so non-competitor partner/studio brands are hidden there: `kast`, `vilendesign`; Ledger + Rabby keep their cards deliberately as audit-worthy wallet brands despite the `Partner` category — id-based filter, NOT category-based, by decision). For a competitor preset, add a `BODY_COPY['<id>']` narrative in the same pass; for a partner/studio preset, add its id to `HIDDEN_IDS` instead (the preset stays fully selectable in the Theme Composer). Worked examples (Ledger/Rabby/Kast/VilenDesign, June 2026) live in the skill's references file.

### Maintenance skills — `/wrap-up` · `/prime` · `/checkup` (the reinforcing loop)

Three on-demand skills keep the design system clean and the collaboration self-reinforcing. Invoke any by name; **none depends on the nightly cron firing** (which often doesn't).

- **`/wrap-up` — capture.** At a session's end, distils the session's learnings (corrections, validated calls, diagnostic chases, rule refinements) into their durable homes (memory · `CLAUDE.md` · `design.md` · `design/components/*.md` · `bug-history.md` · catalog Rules panes), then surfaces forward-looking follow-ups. (`.claude/skills/wrap-up` — the thin LI.FI brand layer over the project-agnostic `.claude/skills/wrap-up-foundation`, which owns the methodology; mirrors the `lifi-doc-foundation` → typed-skill split, so Jumper/partner/other-team wrap-ups add a new layer rather than forking the foundation.)
- **`/prime` — resurface.** Before you touch an area, surfaces the relevant prior art — past bugs + their fixes, the governing rules, recent commits, related primitives, memories — so a solved problem isn't re-solved. The inverse of wrap-up. (`.claude/skills/prime` + `scripts/prime-recall.py <scope> [--memory-dir <path>]`)
- **`/checkup` — maintain.** The manual, interactive trigger for the health suite: runs `scripts/nightly-ds-audit.sh`, surfaces the findings, and **offers to fix the deterministic ones** (doc-anchor drift, exact CSS-token snaps, stale generated snapshots) while flagging the judgment calls. The local-cron nightly is the passive backstop; `/checkup` is the on-demand, actionable pass over the same checks. (`.claude/skills/checkup`)

**The loop:** `/wrap-up` writes durable knowledge → `/prime` reads it back when it pays off → `/checkup` keeps the surfaces consistent → the next `/wrap-up` captures what changed. Each pass sharpens the next; that's the reinforcing-learning system.

**The checkers + generators these compose** (pure-stdlib / no-dep, deterministic; wired into the pre-commit hook and/or the nightly audit — full check list + E/W/B/C codes live in `scripts/nightly-ds-audit.sh`):

- `scripts/check-doc-anchors.py` — every doc/memory `design-system/<page>.html#<id>` ref resolves to the page that actually hosts it; `--fix` rewrites page-moves, `--memory-dir` extends the scan to memories. Nightly **B7**.
- `scripts/check-css-tokens.py` — bare `font-size` / `letter-spacing` → nearest `--text-*` / `--tracking-*` token; `--fix` snaps EXACT (value-identical) matches, leaves off-ladder values for a designer call. Nightly **B4**.
- `scripts/build-redirect-map.js` → `catalog-redirects.js` (complete id→page anchor forwarder) · `scripts/build-search-index.js` → `search-index.json` (cross-page ⌘K) · `scripts/collect-dashboard-data.js` → `dashboard-data.json` · `scripts/build-widget-page.js` → `widget.html` (the standalone widget page) — all regenerated + staged by the pre-commit hook.

**`widget.html` is a generated PAGE, not a data artifact (July 2026)** — and it derives from exactly ONE source, `playground.html`, not from every catalog page. Practical differences from the other three: (a) the cross-page foreign-WIP contamination rule above does NOT apply, because nothing but `playground.html` feeds it — but uncommitted foreign WIP *on the playground* does contaminate it, so classify that one file before committing a regen; (b) it's byte-stable, so `git diff --quiet -- widget.html` is a valid staleness check (unlike `dashboard-data.json`); (c) `node scripts/build-widget-page.js --check` exits 1 when stale, for CI or a manual audit. **Never hand-edit `widget.html`** — it carries a DO-NOT-EDIT banner and the next commit overwrites it. Edit `playground.html` (the widget markup) or `widget.css` (the page shell, which is hand-authored and never rewritten).

**The generator pattern is the answer to "single source vs. duplication" when there's no build step.** This site is served raw by Cloudflare Pages (no bundler, no includes, no partials — see `DEPLOYMENT.md`), so a second page needing the same markup has only bad options: an iframe (ships the source page's whole DOM, chrome included — hidden chrome still paints) or a hand copy (drifts). Generating the page from its source at commit time gives one source of truth and a real standalone page. `widget.html` is the precedent; reach for it before accepting duplication or a frame.
- `scripts/audit-drift-helper.py` (nightly B1/B2/B3/C3/C5 drift checks) · `.claude/skills/lifi-ds-docs/scripts/validate-doc.py` (catalog structural lint; manual/CI).

Same canonical-source-vs-rendering discipline as the rest of the system: the scripts derive truth from the live source, the skills compose them with human-in-the-loop triage.
