lifi-ds-docs skill — typography, label shapes, caption length, pane-aware primitives, syntax tokens, auto-spacing — rendered here at real size so a designer can see what shapes the page and tune it deliberately. Each card below uses the rule it documents. When adding or migrating a component, mirror these shapes — don't invent new ones.Skill & Workflow
Authoring guide
How to add or migrate a component in the catalog. Hands you off to the lifi-ds-docs skill — the canonical procedure.
python3 .claude/skills/lifi-ds-docs/scripts/validate-doc.py
Three-places rule. A component is not "in the design system" until it's documented in all three:
styles.css— implementation (single source of truth for styling)design/components/— written spec with variants, usage, don'tsdesign-system/index.html— rendered catalog entry with live preview (this page)
Six-step workflow (see references/procedure.md in the skill for full detail):
- Read the writing-style guide. Captions are one sentence, ≤140 chars, no marketing voice.
- Copy the template from
references/component-template.htmlinto the right place. - Fill the slots in order: label → caption → preview → markup → specs → rules → source.
- Verify the three-places rule.
- Run
python3 .claude/skills/lifi-ds-docs/scripts/validate-doc.py. Exit code 0 = clean. - Reload the catalog, click each disclosure pill, click the page-level Concise / Detailed toggle. Confirm everything works.
Skill: .claude/skills/lifi-ds-docs/ — SKILL.md, references/component-template.html, writing-style.md, procedure.md, scripts/validate-doc.py.
Primitive CSS: styles.css → .ds-doc-card (search for the anatomy comment block at the top of the rules).
JS: shared.js → initDsCompDoc() — handles disclosure clicks, page-level Concise/Detailed toggle, deep-link support (#section:panel).
Audience & Why
Audience priority
Designers read the catalog as an atlas; engineers as a code reference; marketing as a brand reference. Designer scanning speed wins.
- Designers — scan the catalog as a visual atlas. Recognise components by label + a glance at the preview.
- Engineering — arrive with the component name in mind. Click into Markup / Specs / Source for copy-paste code.
- Marketing — treat the catalog as a brand reference. Real previews, token-only colours, accent treatments from the live palette.
- The label is dominant — h3 scale, sans 600, primary colour. Designers read it first.
- The caption is supporting — sm scale, 400 weight, muted colour. Engineers and marketing skim it.
- The preview is the default tab. Without it, designers don't read past the head.
- Engineering content is one click away. Markup / Specs / Source live behind tabs, never on a separate page.
- AI scrapers find every byte in the DOM — inactive panes carry the
hiddenattribute, neverdisplay: nonein markup. - Don't flip the hierarchy. An earlier version had label as a tiny eyebrow and caption as full body — designers couldn't pre-attentively tell cards apart. Don't go back.
Skill: .claude/skills/lifi-ds-docs/SKILL.md → Audience priority (top of the file, sets the editorial frame).
Repo rule: CLAUDE.md → Audience priority — the same ordering applies to AI agents that author or migrate sections.
Typography
Label .ds-doc-card__label
The h3-scale heading at the top of every card. Designers read it first — sized for pre-attentive recognition, not legibility.
Brand button .btn-primary
Variant matrix
Token chips & chain badges .token-chip · .chain-badge
<!-- Shape A: name + 1 class (most common) -->
<h3 class="ds-doc-card__label">Brand button <code>.btn-primary</code></h3>
<!-- Shape B: name only (reference / aggregate) -->
<h3 class="ds-doc-card__label">Variant matrix</h3>
<!-- Shape C: name + 2-3 classes (family) -->
<h3 class="ds-doc-card__label">Token chips & chain badges <code>.token-chip</code> · <code>.chain-badge</code></h3>
| Property | Value | Token / Notes |
|---|---|---|
| font-family | Sans | var(--font-sans) |
| font-size | 26 px | Slightly above h3 scale (Apr 2026 bump). Designers scan by name; the label deserves visual weight. |
| font-weight | 600 | — |
| color | Primary text | var(--text-primary) |
| line-height | 32 px (~1.23) | var(--lh-h3) — tight enough for h3, loose enough to avoid descender clipping |
| letter-spacing | -0.02em | Slightly tighter than Geist default — reads as authored heading, not web-default. |
Inline <code> | 0.72em · weight 500 · mono | Quiet override on .ds-doc-card__label code — drops the chip outline + bg + heavy weight from the shared rule so the noun phrase leads. Subtle color shift only. |
- Three canonical shapes — A (name + 1 class), B (name only), C (name + 2–3 classes joined by ` · `). Anything else is drift.
- Always lead with a human noun phrase. Class-only labels fail W005 — designers don't think in class names.
- Sentence case only. All-caps fails W007 — reserve uppercase for tiny eyebrows.
- Separator is ` · ` (middle dot), never `/`. Slash fails W006 — it reads as "or", we mean "siblings used together".
- Cap at 3 classes. If the card needs more, split it into siblings.
- Don't flip the hierarchy. Caption is sized smaller than label by design (Audience #1).
CSS: styles.css → .ds-doc-card__label. Search for the anatomy comment block.
Skill: .claude/skills/lifi-ds-docs/references/writing-style.md → The label rule — the bad/good table extends what's shown above.
Validator: scripts/validate-doc.py — codes W005 (class-only), W006 (slash separator), W007 (all-caps).
Caption .ds-doc-card__caption
One sentence under 140 chars in plain language — lead with the component's job, follow with when to reach for it.
Brand button .btn-primary
Brand action button. Three sizes; .btn-lg in heroes, .btn-sm in dense rails.
Brand button
Powerful and beautiful action buttons that elevate your CTAs and seamlessly delight users.
<!-- One sentence. Lead with the noun phrase. ≤140 chars. -->
<p class="ds-doc-card__caption">
Brand action button. Three sizes; <code>.btn-lg</code> in heroes, <code>.btn-sm</code> in dense rails.
</p>
| Property | Value | Token / Validator |
|---|---|---|
| font-size | 16 px | var(--text-body) — bumped from 14 px (Apr 2026) so the caption reads as body prose, not a small note. |
| font-weight | 400 | — |
| color | Secondary text | var(--text-secondary) |
| line-height | 1.55 | Unitless literal — comfortable reading rhythm at 16 px (~25 px line). |
| max-width | none | Removed Apr 2026 — the parent container dictates line length so captions wrap at the natural boundary, not at an arbitrary 720 px cap. |
| Max length | 140 chars | Validator E004 · trim or split the section |
| Sentences | 1 (2 max) | Validator W002 warns at 3+ |
| Forbidden words | 12 listed | Validator E010 · marketing voice |
- One sentence. Two if you genuinely need both. Three or more triggers W002.
- Lead with the noun phrase, not the imperative. "Brand button" — not "Use this button when…".
- Examples in captions are mono code, not adjectives. Show
.btn-lgrather than "large variant". - No marketing voice. Describe the component; don't sell it. The validator owns the forbidden-words list.
- No trailing period if the caption fits cleanly without one.
- 140 chars is a hard cap. If you need more, the card itself probably needs splitting — long captions usually mean two cards merged into one.
CSS: styles.css → .ds-doc-card__caption.
Skill: .claude/skills/lifi-ds-docs/references/writing-style.md → The caption rule — the full bad/good table.
Validator: E004 (length), E010 (forbidden words), W002 (sentence count).
Pane Primitives
Pane primitives .ds-doc-* family · 16 approved blocks
The complete vocabulary for documentation panes — containers, prose, labels, inline, layout, demo. Use these instead of inline style.
Section heading
Standard .ds-doc-text paragraph. Inline <code> like .btn-primary auto-renders as a chip via the .code-chip universal rule. Multiple paragraphs auto-space.
Use <strong> for emphasis — it darkens to --text-secondary automatically.
.ds-doc-list— body-size text, tight item spacing.- Bold leads — start an item with
<strong>for "X — does Y" pairs. - Inline
.classeschip the same way as in paragraphs.
.ds-doc-aside — default brand-emphasis aside. Use when the note matters to the surrounding decision.
--note — informational. Background context, links to deeper docs, "by the way" footnotes that aren't decisions.
--success — confirmed-good practice. Use when calling out the recommended path or a working pattern.
--warn — caution. Authoring choice that breaks a downstream contract or has a known foot-gun.
--danger — don't. Anti-patterns and forbidden moves; the variant signals that the rule is non-negotiable.
--muted — quiet footnote. Background-less; reads as a side-note inside busier panes.
// .ds-doc-codeblock — drops chrome inside a pane, keeps it standalone.
const chip = "foundations";
| Class | Use |
|---|---|
| .btn-primary | Primary action — the dominant CTA |
| .btn-secondary | Paired secondary on dual-action surfaces |
Default
.ds-doc-eyebrow
Active
--accent
Lower
--ghost
Family · caption
.ds-doc-caption — sans, left-aligned, muted; the quieter sibling of .ds-doc-text. Three modifiers carry the placement rhythms used across the catalog (May 2026 promotion: collapsed 82 inline style overrides).
Default · 8 px top
Caption follows the demo with the default 8 px top gap.
--lead · 12 px top
Bigger top gap when the default 8 px feels cramped against the demo above.
--label · acts as label above
Default — caption labels the demo below it.
--centered · figcaption
.brand-tile · 240 pxFamily · demo
Four primitives for the kinds of inline annotation that surround a visual demo — a bigger label below an eyebrow, a quiet framed sample box, a right-pinned mono detail, and a smaller-text body modifier.
Priority 1
Designers
Body-lg / weight 600 — the canonical demo headline. Quieter than a card label so it doesn't compete with the card's own heading.
A
Brand button .btn-primary
The --sm modifier drops to body size for tighter rows where the headline sits on one line beside an inline class chip.
Sunk
default
Raised
--raised
Elevated
--elevated
Alpha
--alpha
- Card primitive surfacestyles.css:14092
- Brand buttonv4.2 · 12 specs
- Spot iconjust shipped
Default .ds-doc-text — body size (16px), text-secondary, generous line height. The standard for documentation paragraphs.
The --sm modifier drops to 14px while keeping the same colour and line-height. Use under a headline or caption for context that's quieter than body prose — typical at "Why" / "When to use" notes.
<!-- ═══ CONTAINERS — chrome owners ═══ -->
<!-- Codeblock · standalone has chrome, in a pane it drops -->
<pre class="ds-doc-codeblock"><code>const x = 1;</code></pre>
<!-- Table · always wrapped in .ds-doc-table-wrap -->
<div class="ds-doc-table-wrap">
<table class="data-table">…</table>
</div>
<!-- Aside · semantic note · default / note / success / warn / danger / muted -->
<p class="ds-doc-aside">Brand-emphasis note.</p>
<p class="ds-doc-aside ds-doc-aside--note">Informational.</p>
<p class="ds-doc-aside ds-doc-aside--success">Recommended path.</p>
<p class="ds-doc-aside ds-doc-aside--warn">Caution — downstream contract.</p>
<p class="ds-doc-aside ds-doc-aside--danger">Don’t — anti-pattern.</p>
<p class="ds-doc-aside ds-doc-aside--muted">Quiet footnote.</p>
<!-- ═══ PROSE — running text ═══ -->
<p class="ds-doc-text">Body prose with auto-styled <code>.classes</code>.</p>
<ul class="ds-doc-list"><li>Item</li></ul>
<!-- ═══ LABELS — eyebrows + captions ═══ -->
<p class="ds-doc-eyebrow">Default kicker</p>
<p class="ds-doc-eyebrow ds-doc-eyebrow--accent">Active</p>
<p class="ds-doc-eyebrow ds-doc-eyebrow--ghost">Lower</p>
<p class="ds-doc-caption">Default — sans, left, 8 px top</p>
<p class="ds-doc-caption ds-doc-caption--lead">--lead — 12 px top gap</p>
<p class="ds-doc-caption ds-doc-caption--label">--label — labels the demo below</p>
<figcaption class="ds-doc-caption ds-doc-caption--centered">--centered — under a centered demo</figcaption>
<p class="ds-doc-swatch-label">--accent-primary</p>
<!-- ═══ INLINE — code chip is auto-applied on every <code> ═══ -->
<code>.btn-primary</code> <!-- bare <code> auto-chips site-wide -->
<span class="code-chip">.btn-primary</span> <!-- explicit for non-<code> hosts -->
<!-- ═══ LAYOUT — grids + dividers ═══ -->
<div class="ds-doc-grid ds-doc-grid--cols-2">
<div>Left</div>
<div>Right</div>
</div>
<div class="ds-doc-grid ds-doc-grid--cols-3">…</div>
<div class="ds-doc-grid ds-doc-grid--cols-4">…</div>
<hr class="ds-doc-divider">
<hr class="ds-doc-divider ds-doc-divider--tight">
<hr class="ds-doc-divider ds-doc-divider--loose">
<!-- ═══ DEMO — headline / well / detail / text--sm ═══ -->
<!-- Headline · pairs below an eyebrow · default body-lg, --sm body -->
<h4 class="ds-doc-headline">Designers</h4>
<h4 class="ds-doc-headline ds-doc-headline--sm">Brand button</h4>
<!-- Well · headerless framed surface · sunk default -->
<div class="ds-doc-well">…</div>
<div class="ds-doc-well ds-doc-well--raised">…</div>
<div class="ds-doc-well ds-doc-well--elevated">…</div>
<div class="ds-doc-well ds-doc-well--alpha">…</div>
<!-- Detail · right-pinned mono annotation in a row -->
<li><strong>Brand button</strong><span class="ds-doc-detail">v4.2</span></li>
<!-- Smaller body modifier · 14px supporting prose -->
<p class="ds-doc-text ds-doc-text--sm">Quieter context.</p>
Containers
| Primitive | Recipe | Pane-aware behaviour |
|---|---|---|
.ds-doc-codeblock | border + bg + radius · mono · 8 token spans | Drops border, bg, radius — pane owns them |
.ds-doc-table-wrap | border + radius · horizontal scroll on narrow viewports | Drops both — pane is the container |
.data-table | uppercase headers · hover rows · code-style numeric cells | Same in/out — chrome is intrinsic to the table |
.ds-doc-aside | 3px accent left-border · italic · text-secondary · line-height 1.55 | Identical in/out · variants: --muted, --warn |
Prose
| Primitive | Recipe | Notes |
|---|---|---|
.ds-doc-text | body · text-secondary · line-height 1.6 · 640px max-width | Auto-spaces siblings · drops max-width inside pane |
.ds-doc-list | body · text-secondary · 8px item spacing · works on <ul> / <ol> | Identical in/out |
Labels
| Primitive | Recipe | When to use |
|---|---|---|
.ds-doc-eyebrow | 11px · mono · 700 · text-faint · uppercase · tracking-wider · mb-6 | Mono uppercase kicker — section sub-headings inside a pane, specimen labels above a demo, state markers ("Default" / "Active" / "Sunk"). Variants: --accent (active), --ghost (de-emphasis). |
.ds-doc-caption· --lead· --label· --centered | caption · sans · text-muted · left · mt-8 | Quieter sibling of .ds-doc-text — annotation under a demo. Modifiers: --lead (12 px top), --label (labels demo below, mt:0 mb:8), --centered (figcaption under a centered demo). |
.ds-doc-swatch-label | caption · mono · text-muted | Mono label on visual demos (swatches, dimensions, samples) · variant: --on-alpha |
Inline
| Primitive | Recipe | Notes |
|---|---|---|
.code-chip | 0.85em · mono · 600 · primary · 7% currentColor bg · no border · 5px radius | Auto-applied to every <code> on the site via the bare element selector; explicit class for non-<code> hosts. Suppressed inside .ds-doc-codeblock. Tier-2 universal — used in marketing, product, dashboards, and catalog (promoted from .ds-doc-class May 2026 v10). |
Layout
| Primitive | Recipe | Notes |
|---|---|---|
.ds-doc-grid | display:grid · 16px gap · align-items:start · mt-12 | Modifiers: --cols-2, --cols-3, --cols-4, --tight (8px), --loose (24px). Auto-stacks at ≤720px |
.ds-doc-divider | 1px hairline · 6% currentColor · my-20 | Modifiers: --tight (12px), --loose (32px). Use <hr> |
Demo
| Primitive | Recipe | When to use |
|---|---|---|
.ds-doc-headline | body-lg · 600 · text-primary · margin:0 | Bigger label below an eyebrow, inside a pane. Variant: --sm (body size for tighter rows). Pairs with .ds-doc-eyebrow. |
.ds-doc-well | padding:24 · radius:lg · surface-sunk + 6%-ink hairline | THE canonical framed-preview surface (frame component / product / material demos; leave atoms + text specimens bare). Variants: --sunk (default) / --raised / --elevated / --alpha. Spacing: --tight (12) / --loose (32). |
.ds-doc-detail | margin-left:auto · mono · caption · text-muted · nowrap | Right-pinned mono annotation in a row (path, version, dimension). Variant: --accent. |
.ds-doc-text--sm | modifier on .ds-doc-text: font-size sm (14px) | Smaller-text supporting prose under a headline or caption. Same colour, same line-height as base. |
The cardinal rule. If a pane needs a label, divider, grid, aside, headline, well, or pinned detail, reach for one of the 16 primitives below. Never inline-style your own. The primitives evolve once across the system; inline styles drift forever.
Picking the right label
.ds-doc-eyebrowfor every uppercase mono kicker — both section sub-headings inside a longer pane (where it pairs above a group of demos) and specimen labels above a single demo (where it pairs above one swatch / variant / state). 11px mono uppercase,--accentfor active state,--ghostfor de-emphasis..ds-doc-captionfor sans-serif annotation around a demo (12 px, muted, left). Reach for--lead(bigger top gap),--label(acts as a label ABOVE the demo), or--centered(figcaption under a centered cell) — never inlinestyle="margin-..."overrides; those got swept in the May 2026 promotion..ds-doc-swatch-labelfor a left-aligned mono label NEXT TO a swatch / sample (mono, muted, no transform).
Layout & containers
- Never author your own container. No
<div style="background:…">around a code snippet, no border-radius wrapper around a table. The primitive owns the chrome. - Pane-aware = automatic. Containers (
.ds-doc-codeblock,.ds-doc-table-wrap) drop chrome inside.ds-doc-viewport-panevia context selectors. You don't author the difference. - Code blocks always use
.ds-doc-codeblock, never raw<pre>. Tables always use.ds-doc-table-wrap > .data-table— the wrap provides horizontal scroll on narrow viewports. - Grids and dividers replace inline-styled
display:gridblocks. If you find yourself writingstyle="display:grid;grid-template-columns:1fr 1fr;gap:...", swap to.ds-doc-grid.ds-doc-grid--cols-2. - Asides over coloured prose. When a paragraph deserves emphasis, use
.ds-doc-aside; never override.ds-doc-textcolor or borders inline.
Demo annotations
.ds-doc-headlinefor the bigger label that pairs BELOW a.ds-doc-eyebrow. body-lg / weight 600. Use--smwhen the row needs to fit on one line beside an inline class chip..ds-doc-wellfor a small framed sample box without a header bar. Pick the surface modifier by what the demo illustrates:--sunkdefault,--raised/--elevatedfor elevation demos,--alphafor state-layer demos..ds-doc-detailfor a right-pinned mono annotation in a row — file paths, dimensions, version numbers. Place inside any flex row;margin-left:autohandles the pin..ds-doc-text--smwhen supporting prose under a headline or caption should sit visually quieter than body. Keep a body paragraph above it for the primary read.- Don't reach for
.ds-doc-wellwhen you actually want a viewport. A well is headerless and meant for small visual samples; a.ds-doc-viewport.ds-doc-viewport--stackwith a header bar is what you want for labelled demo rows.
Extending the system
- When a new pattern emerges in practice, register it here before it spreads. Drift starts with one off-pattern paragraph style.
- Promote a recurring inline pattern to a primitive once it appears 5+ times. Add the CSS next to the existing
.ds-doc-*block instyles.cssand a live demo to this card. - Don't fork by hand-tuning tokens. If a primitive feels wrong in a context, it's usually the wrong primitive — pick another, or extend the existing one with a modifier (
--tight,--accent, etc.).
CSS: styles.css — search .ds-doc-codeblock for the unified container rules and .ds-doc-eyebrow for the new label / layout primitives. Both blocks live next to each other so the cascade order is obvious.
Spec: design.md §13 → Inside-pane vocabulary — the prose spec with the full taxonomy and rationale for each primitive.
Skill: .claude/skills/lifi-ds-docs/SKILL.md → Pane content primitives.
Validator: E008 (inline style="background:#…") catches the most common authoring mistake — wrapping a primitive in a custom container. A future W010 rule (proposed) will flag inline-styled patterns that match a primitive heuristic (uppercase eyebrows, grid-template-columns blocks, line-bordered paragraphs).
Syntax-highlight tokens · 8 spans
Colour-code source snippets with these eight canonical spans. Don't invent new ones — they cover HTML, CSS, JS, JSON, and shell.
// Eight spans cover every language we ship — HTML, CSS, JS, JSON, shell.
const chip = "foundations"; // keyword + string
if (chip === true) {
fetch("/api/scopes"); // path
}
<button class="btn-primary" disabled>
Click, when ready
</button>
<!-- Wrap each token explicitly. The class drops the chip styling
when nested inside .ds-doc-codeblock so spans render as tokens. -->
<pre class="ds-doc-codeblock"><code>
<span class="comment">// comment</span>
<span class="keyword">const</span> x = <span class="string">"value"</span>;
</code></pre>
| Span | Use for | Sample | Token |
|---|---|---|---|
<span class="comment"> | Comments | // muted, italic | --text-faint |
<span class="string"> | String literals | "value" | --accent-secondary |
<span class="tag"> | HTML tags | button | --accent-primary |
<span class="attr"> | HTML attributes | class | --accent-tertiary |
<span class="value"> | Numeric / boolean | true | --accent-secondary |
<span class="keyword"> | Keywords (JS / CSS / shell) | const | --accent-primary bold |
<span class="path"> | Paths / URLs | /api/x | --accent-tertiary |
<span class="punct"> | Punctuation, brackets | { } | --text-faint |
Three accent tokens carry most of the visual load — primary for tags + keywords, secondary for strings + values, tertiary for attributes + paths. Comments and punctuation share --text-faint.
- Eight spans cover every language we ship. Don't invent new ones — if a snippet doesn't fit, reach for
commentor accept it monochrome. - Token-only colours. No raw hex, no rgb. Each span maps to a
var(--*)token so the palette switcher recolours every snippet. - Mobile flips code blocks to
pre-wrap+word-break: break-wordso phones don't get a permanent scrollbar. CSS handles it; you don't author breakpoints. - HTML escapes. Inside a code block, write
<and>for angle brackets,&for ampersand. The validator doesn't lint this — eyeball it.
CSS: styles.css → .ds-doc-codeblock .comment (and the seven sibling rules immediately following). All eight rules sit in a single block so the colour relationships are obvious in source.
Eyebrow-labelled row .ds-doc-eyebrow-row
Pairs a .ds-doc-eyebrow caption with a demo. Stack 3-7 inside a Preview pane to show variants without nested sub-viewports.
Three rows below, each pairing an eyebrow caption with a small demo. Adjacent rows auto-space (32 px); the eyebrow → demo gap is fixed (12 px). No per-instance margins required.
Variant A · default
Variant B · ghost
Variant C · destructive
<div class="ds-doc-eyebrow-row">
<p class="ds-doc-eyebrow">Variant A · default</p>
<!-- demo -->
</div>
<div class="ds-doc-eyebrow-row">
<p class="ds-doc-eyebrow">Variant B · ghost</p>
<!-- demo -->
</div>
| Selector | Effect |
|---|---|
.ds-doc-eyebrow-row | Container for one label + demo pair. No own padding or border — the parent pane owns chrome. |
.ds-doc-eyebrow-row + .ds-doc-eyebrow-row | margin-top: var(--space-32) — sibling-spacing recipe. Adjacent rows separate automatically; don't author per-instance margins. |
.ds-doc-eyebrow-row > .ds-doc-eyebrow:first-child | margin: 0 0 var(--space-12) — locks the eyebrow → demo gap so every variant aligns. |
- Use it inside a Preview pane to stack labelled variants. One
.ds-doc-cardper documented component, ONE Preview pane inside it, multiple.ds-doc-eyebrow-rows composed inside that pane. Don't fragment a Preview into several nested.ds-doc-viewportsub-frames — that's the legacy pattern this primitive replaces. - Don't author
style="margin: ..."on the eyebrow inside the row. The row owns the rhythm. Hand-rolled per-row margins are the drift this exists to eliminate. - Eyebrow caption is one line, ideally under 60 chars. "Variant A · short purpose" reads cleanly as a label; longer prose belongs in Rules / Anatomy.
- 3-7 rows per pane is the sweet spot. 1-2 rows = no need for the primitive (just show the demo). 8+ rows = reconsider whether the card is actually two separate components.
- Don't put complex grid layouts inside the row. If a row's "demo" is itself a 4-tile grid, that's fine; if every row needs a different layout, the variants are not peers and shouldn't share a Preview pane.
Colors tab pattern data-tab="colors"
Optional designer-facing pane for state colours, overrides, and critical highlights. Slot 2 — after Preview; validator E016 enforces.
Three flag tones — the taxonomy at a glance
Each swatch in a Colors tab carries a source flag classifying where the colour comes from. The three flags answer the question "is this from the system, is it a deliberate divergence, or is it locked to this widget?"
var(--accent-primary)
.btn-primary · .chip-accent1 · .status-dot
color-mix(...) · --slot remap
.checkbox input:checked · .btn-destructive · .tile.is-active
color-mix(…) blend, a remapped slot, a per-component shadow) that diverges from the system default. Read: "this component made a deliberate choice."oklch(100% 0 0 / 0.05) · #FFFFFF · brand pair
.fab:hover halo · .btn-neutral · .brand-tile
Canonical tab strip — Colors at slot 2
When present, the Colors tab sits in slot 2 — immediately after Preview and before Markup. The validator (E016) errors on any other position. The full canonical order is Preview / Colors / Markup / Specs / Anatomy / Rules / Source — Colors is the only optional one.
Designer scan path: Preview → Colors (the small palette that gives the component its identity). Engineer reference begins at Markup. Audience priority (CLAUDE.md) keeps Colors in front of the engineer-facing tabs.
Canonical Colors pane shape — one .ds-doc-text lead, a .swatch-grid of .swatch.swatch--state figures, one per critical colour.
<!-- Slot 2 — immediately after Preview, before Markup -->
<button class="ds-doc-viewport-tab" data-tab="colors">Colors</button>
<!-- Matching pane — DOM order matches tab order -->
<div class="ds-doc-viewport-pane" data-pane="colors" hidden>
<p class="ds-doc-text" style="margin-bottom: var(--space-20);">
<strong>[Card-specific lead title].</strong> [One sentence on how
the tone system works for this component.]
</p>
<div class="swatch-grid">
<figure class="swatch swatch--state"
style="--swatch-color: var(--TOKEN);">
<div class="swatch-tile"></div>
<figcaption class="swatch-meta">
<span class="swatch-flag swatch-flag--inherited">Inherited</span>
<div class="swatch-name">[State or tone name]</div>
<code class="swatch-token">var(--TOKEN)</code>
<code class="swatch-selector">.scoped-selector</code>
<div class="swatch-rationale">[Short purpose sentence]</div>
</figcaption>
</figure>
<!-- one .swatch--state per critical colour -->
</div>
</div>
Flag modifier classes: .swatch-flag--inherited (neutral) · .swatch-flag--override (pink) · .swatch-flag--convention (blue). Apply exactly one per swatch.
When to add a Colors tab
- Interactive state colours that compose the component's behaviour. Hover / active / selected / focus / disabled / pinned. Example:
.list-item's 5-state palette. - Overrides of a system default. A scoped override deserves a visual reference plus the rationale for why this component breaks from the inherited default. Example:
.alert's 4 semantic tones (override the alert base's default tone). - Critical highlight colours that make the composition recognisable. Brand accents that ARE the component (an accent card's tone, a gradient hero KPI's ramp). Example:
.accent-card's primary/secondary/tertiary tones.
When to skip
- Only system defaults apply. Component uses
--overlay-hover,--accent-primary,--ink-*with no scoped overrides and no critical highlights. The Rules pane prose ("uses the standard hover overlay") is enough. - The component has no states, no overrides, no critical highlight colours. A divider, a static label, a hairline rule.
- The component IS a colour primitive. A swatch, a palette card, a colour token. The whole component is the colour reference; a nested Colors tab is redundant.
- A canonical sibling card already documents the recipe. When a variant inherits its colour palette from a foundation card (e.g., all
.stepsvariants inherit from the Simple card), point readers to the canonical card rather than duplicating the tab.
Content contract
- Focused, not exhaustive. Interactive states + overrides + critical highlights only. NOT a "all tokens this component uses" dump.
- Usually 2–6 swatches, occasionally more, never a complete inventory. The biggest Colors tab in the catalog is Content tile — Accents at 8 swatches (3 brand + 5 spectral) — that's the ceiling.
- Surface tokens, typography sizes, and spacing values belong in Specs / Anatomy, not here.
Enforcement
- Validator E016 —
data-tab="colors"must sit at zero-indexed position 1 (slot 2). Errors on any other position. Runpython3 .claude/skills/lifi-ds-docs/scripts/validate-doc.py. - Tab DOM order must match pane DOM order. Move both when reordering — the JS keys panes off
data-pane, but readability + AI scrape order require they match. - The convention is opt-in. 26 of 213 cards (12%) have Colors tabs as of the May 2026 system-wide pass — the rest correctly skip it because they consume only system defaults.
Structure & Spacing
Section + card spacing
Three nested layers, each with its own auto-recipe. Don't author dividers, <hr>, or extra spacers — the CSS handles every layer.
My section
Section description. Sets context for the cards below.
PRIMARY VARIANTS
Card 1 — first variant
Caption.
Card 2 — second variant
Caption.
- ↑ 240px section padding
- ← .ds-doc-subsection · hairline + 80px above
- ↓ 80px card-to-card gap + 1px hairline
<!-- Section shell — eyebrow + title + desc + cards -->
<section class="section" id="my-section">
<div class="container">
<div class="eyebrow-code">02 — My Section</div>
<div class="section-title">My section</div>
<div class="section-desc">Section description.</div>
<!-- Optional subsection eyebrow — groups cards into named clusters -->
<h2 class="ds-doc-subsection">Primary variants</h2>
<!-- Adjacent .ds-doc-card cards auto-gap (80px) + auto-hairline (1px) -->
<div class="comp-section ds-doc-card" data-scope="ui marketing">…</div>
<div class="comp-section ds-doc-card" data-scope="ui marketing">…</div>
</div>
</section>
| Layer | Spacing | Selector |
|---|---|---|
| Section padding | 240 px (mobile 96 px) | .section |
| Section title gap | 64 px below desc | .section-desc + * |
| Subsection eyebrow | 80 px above + 1 px hairline | .ds-doc-subsection |
| Card-to-card gap | 80 px (mobile 48 px · print 32 px) | .ds-doc-card + .ds-doc-card |
| Card-to-card hairline | 1 px @ text-primary 6% alpha | same selector — border-top |
- Don't author
<hr>between cards. The CSS hairline is automatic via the adjacent-sibling combinator. - Don't insert empty
<div>spacers. Adjacent siblings trigger the gap automatically. - Use
.ds-doc-subsectionto break a section into named groups (e.g. "Primary variants" vs "Secondary"). Most sections don't need it. - If two cards feel "merged", check that they're direct siblings — the
+combinator is direct-sibling-only — and that no rogue wrapper is breaking the relationship. - Mobile collapses the gap to 48 px and print collapses it to 32 px. CSS handles both breakpoints; don't author them.
CSS: styles.css → .ds-doc-card + .ds-doc-card (search for the visual-separation comment block).
Skill: .claude/skills/lifi-ds-docs/SKILL.md → Visual separation between cards — explains why it's automatic, not author-able.
Variant grouping in Preview panes
When a card carries 3+ variants spanning different axes, group them by axis and stack vertically with .ds-doc-divider--loose between zones. Never mix unrelated variants side-by-side in a grid to save vertical space.
Side-by-side comparison of the two layouts. The left mixes variants from three different concerns into one grid; the right groups them by axis with hairline dividers between zones. Same variants, different scanability — the right one tells the reader at a glance that the component varies on three axes.
Don't · mixed wrap-grid
Content A
Shape A
Content B
Position A
Shape B
Content C
Reader has to parse every eyebrow to figure out what axes the component varies on. The grid optimizes for vertical density, not for cognitive grouping.
Do · zones + dividers
Content A
Content B
Content C
Shape A
Shape B
Position A
Three zones visible at a glance: the component varies on content, shape, and position. Hairlines tell the reader "this is a different axis."
<!-- Inside any .ds-doc-card > .ds-doc-viewport-pane[data-pane="preview"]
when the card has 3+ variants on 2+ axes (concerns).
Each .ds-doc-eyebrow-row carries one variant cell.
.ds-doc-divider--loose separates zones with 32 + 1 + 32 px. -->
<!-- Zone 1 — first concern (e.g., "Panel content") -->
<div class="ds-doc-eyebrow-row">
<p class="ds-doc-eyebrow">Variant A</p>
<!-- demo -->
</div>
<div class="ds-doc-eyebrow-row">
<p class="ds-doc-eyebrow">Variant B</p>
<!-- 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">
<p class="ds-doc-eyebrow">Variant C</p>
<!-- demo -->
</div>
- Ask "what axes does this component vary on?" before authoring. Write the axes down. One axis → flat stack of
.ds-doc-eyebrow-rowcells, no dividers. Two or more axes → zones grouped by axis with.ds-doc-divider--loosebetween. The grouping should reflect the COMPONENT'S structure, not the desire for visual density. - Stack vertically; don't reach for a wrap-grid.
display: grid; grid-template-columns: repeat(auto-fit, minmax(N, 1fr))for variant cells is wrong when the cells are unrelated concerns. Save the wrap-grid for SAME-axis demos where the cells are siblings — color swatch grids, icon-library cell grids, font-stack swatches. Variant cells on different concerns need vertical zones. - Use the existing primitives — never invent new spacing.
.ds-doc-eyebrow-rowhandles the Tier-1 eyebrow→demo (12 px) and the Tier-3 cell-to-cell (32 px) automatically..ds-doc-divider--loosehandles the Tier-4 zone break (32 + 1 + 32 px). The math is the Pane Breathing Room cascade — already canonical. - Each zone needs 2+ variants. A zone with one cell is unmotivated — drop the divider and let the cell sit with its neighbours. Zones imply "here's a family of related variants" — one-item zones violate the implication. (Exception: when one axis genuinely has one variant today but is documented because it'll grow — note the future shape in a Specs/Anatomy row rather than reserving a single-cell zone.)
- Don't mix grid + linear rows. Some-variants-in-a-grid-and-others-in-rows confuses the reader about what level each cell is at. Pick one shape per pane.
- The intro sentence is optional but encouraged for 3+ zones. A single
.ds-doc-textat the top of the pane saying "click any X to see Y" or "the variants below are grouped by Z" gives the reader a frame before they scan. Don't pad it; one short sentence is enough.
Canonical source: CLAUDE.md → "Preview-pane variant grouping — group by axis, stack vertically, divide between zones". This card is the rendering; the canonical rule lives in the file loaded into every session.
Reference implementation: #dropdown-default — Dropdown card, 6 variants grouped into 3 zones (Panel content / Trigger shape / Panel positioning).
Companion rule: Pane Breathing Room (Tier 1–4 spacing) — this card carries the ORGANIZATION principle; that one carries the SPACING math.
Primitives: .ds-doc-eyebrow-row (cells) + .ds-doc-divider--loose (zone breaks).
Chapter strip .ds-doc-section-toc
Auto-generated horizontal chapter nav at the top of any section with 2+ chapter markers. Author the markers; the JS renders the strip.
A simulated rendering — what gets prepended to a section's .container when the JS detects 2+ chapter markers. Live examples are at the top of #inputs, #hero, #badges, and at the top of this Authoring Guide. (The Cards group used to live in one chaptered #cards section; it was promoted to a top-level sidebar group of sibling sections Apr 2026 — the chapters became sections, each able to chapter on its own as it grows.)
Active link tracks the chapter the reader is currently scrolled into via IntersectionObserver with rootMargin: -80px 0px -55% 0px.
You don't author the strip — you author the chapter markers. The JS walks every <section class="section">, finds top-level h2.ds-doc-subsection[id] markers, and prepends a .ds-doc-section-toc when there are 2+.
<!-- inside <section id="inputs"> → <div class="container"> -->
<h2 class="ds-doc-subsection" id="inputs-text">Text & numeric</h2>
<div class="comp-section ds-doc-card">…</div> <!-- card -->
<div class="comp-section ds-doc-card">…</div> <!-- card -->
<h2 class="ds-doc-subsection" id="inputs-sizes">Sizes & label positions</h2>
<div class="comp-section ds-doc-card">…</div>
…
The strip the JS prepends looks like this (you don't write it):
<nav class="ds-doc-section-toc ds-doc-tab-nav" aria-label="Inputs contents">
<a href="#inputs-text" class="ds-doc-tab-nav-item is-active">Text & numeric</a>
<a href="#inputs-sizes" class="ds-doc-tab-nav-item">Sizes & label positions</a>
…
</nav>
- 2+ chapter markers required. Sections with 0 or 1 chapter render no strip — it only appears where there's somewhere to go.
- Direct children only. Chapter markers must be direct children of the section's
.container. The JS uses:scope > h2.ds-doc-subsection[id]so chapter markers nested inside a card's pane don't accidentally register. - Chapter id shape —
<section>-<chapter>, kebab-case.inputs-text,cards-feature,hero-anatomy. The validator warns (W008) on drift. Stable ids matter — they're URL anchors, and they become route segments if a section ever splits to multi-page. - Chapter title — Title Case, 1–3 words. "Text & numeric" good. "Text and numeric inputs grouped together" bad.
- Aim for 3–6 chapters per section. 8+ is a sign the section should split. The validator warns (W009) when a section has 7+ cards and 0 chapters.
- Don't add a one-off chapter for a single card. Refine the card to fit an existing chapter, or wait until at least two cards share its theme.
- CSS —
styles.css· search.ds-doc-section-toc — auto-generated chapter nav. Composes.ds-doc-tab-navfor visual treatment; only adds spacing + scroll-margin offset. - JS —
shared.js·initDsSectionTocs()at the bottom. Walkssection.section, prepends a strip when 2+ chapter markers exist, scroll-spies viaIntersectionObserver(rootMargin: -80px 0px -55% 0px). - Spec —
design.md §13· "Section chaptering & auto-TOC" subsection. Includes the chapter inventory and the future multi-page migration path. - Skill —
lifi-ds-docs· the chapter-pick step in the workflow + the "Adding chapters to a growing section" edge case inreferences/procedure.md. - Validator —
scripts/validate-doc.py· W008 (chapter id shape) and W009 (heavy section without chapters).
Page Chrome
Tab nav .tab-nav
Sticky horizontal rail of section anchors. Three sizes; the active link is highlighted by an IntersectionObserver on the page's own JS.
.tab-nav-sm · 40 px tall · 13 px text — dense surfaces, sub-section navs
Default · 56 px tall · 14 px text — most pages
.tab-nav-lg · 72 px tall · 16 px text — primary page nav (every DS sub-page uses this)
Live on the dashboard, AI field manual, brand guide, and competitor analysis pages — all four use .tab-nav-lg.
State colours for .tab-nav. The active item is the focal signal — accent label + accent underline read as one. Hover previews the underline at 50% alpha so the rest state still leads the eye to the active tab. Rest text uses --text-secondary so unselected tabs sit quiet; only the active one wears the brand.
var(--text-secondary)
.tab-nav-list a
color-mix(in oklch, var(--accent-primary) 50%, transparent)
.tab-nav-list a:hover
--text-primary.var(--accent-primary)
.tab-nav-list a.is-active
--text-primary and only the underline carried the accent. Reversible if it reads loud.color-mix(in oklch, var(--accent-primary) 50%, transparent)
.tab-nav-list a:focus-visible
-4px) so the ring sits inside the tab cell rather than overlapping the sticky bar's hairline below.<!-- Sticky page-level tab nav. Add `.tab-nav-sm` or `.tab-nav-lg`
to size up or down. The `.is-active` class on a link is what
the IntersectionObserver toggles. -->
<nav class="tab-nav tab-nav-lg" aria-label="Page sections">
<div class="container">
<div class="tab-nav-list">
<a href="#overview">Overview</a>
<a href="#tokens" class="is-active">Tokens</a>
<a href="#components">Components</a>
</div>
</div>
</nav>
| Size modifier | Height | Font size | Padding (h) | Underline | Use |
|---|---|---|---|---|---|
.tab-nav-sm | 40 px | 13 px | 12 px | 2 px | Dense surfaces, sub-section navs inside a panel |
| (default) | 56 px | 14 px | 16 px | 2 px | Most pages |
.tab-nav-lg | 72 px | 16 px | 20 px | 3 px | Primary page nav on long surfaces (DS sub-pages) |
Tokens. Backdrop is color-mix(in oklch, var(--surface-page) 92%, transparent) with a blur(12px) backdrop-filter — matches the topnav vocabulary. Border is 1 px hairline at --text-primary 8% alpha. Link colour is --text-secondary at rest, --text-primary on hover/active. Underline animates from transparent → --accent-primary via 0.18 s ease.
Mobile. At ≤720 px the -lg variant drops to default size and link padding tightens. Overflow scrolls horizontally without a visible scrollbar.
| Class | Role | Notes |
|---|---|---|
.tab-nav | Outer rail | Sticky · frosted · hairline bottom border. Sets the size custom properties. |
.tab-nav > .container | Width-clamped row | Reuses the system .container primitive for max-width consistency with the page below. |
.tab-nav-list | Flex row of links | overflow-x: auto; scrollbar hidden. Each direct child <a> renders as a tab. |
.tab-nav-list a | Tab link | Underline-on-hover, accent on active. Top border matches bottom width so vertical alignment doesn't shift. |
.tab-nav-list a.is-active | Current section | Page JS toggles this class via IntersectionObserver. The component itself only owns the styling. |
.tab-nav-logo | Optional left slot | Logo + vertical hairline before the links. Used on brand guide today; optional everywhere else. |
.tab-nav-sm / .tab-nav-lg | Size modifiers | Override --tab-nav-h, --tab-nav-fs, --tab-nav-pad-x, and --tab-nav-underline. |
- One
.tab-navper page. Two sticky rails compete for the same top slot and confuse the active-section signal. If you genuinely need a sub-nav, drop it inside a panel and use.tab-nav-sm. - Always include
aria-label. The nav is sticky chrome — screen readers need a name to announce it ("Page sections", "Brand guide sections"). - Anchors only. The component is for in-page section navigation. Cross-page navigation belongs in the topnav (
.ds-doc-topnav/ds-nav.js). - Active state is JS-driven. Pages set
.is-activeon the matching link via an IntersectionObserver — not via:targetor hash matching (those don't update during scroll). - Don't customise the underline colour per page. The accent-primary underline is the system signal; overriding it forks the visual language.
- Use
.tab-nav-lgfor primary page nav, default for most surfaces,.tab-nav-smonly when nested inside a denser context (panel header, settings sub-page). - No glow on the active underline. Apr 2026 v4: dropped the blurred pseudo-element bloom that used to sit beneath
.is-active. The 2 px accent underline sitting on the 1 px hairline track is a strong enough active signal — halos on horizontal navs read as decorative chrome and pull attention away from the page content beneath. Mirrored on the marketing navbar's.nav-link-item.active.
CSS: styles.css → .tab-nav (search for the anatomy comment block). Custom properties: --tab-nav-h, --tab-nav-fs, --tab-nav-pad-x, --tab-nav-underline.
Live on: design-system/dashboard.html · design-system/ai.html · design-system/brand-guide.html · design-system/competitors.html — all use .tab-nav-lg.
Active-link JS: see the IntersectionObserver block at the bottom of design-system/ai.html — same pattern can be lifted to any consuming page.
.ds-doc-card-demo / .ds-doc-tile-demo / per-page ad-hoc <div style="background:…"> wrappers — migrate new demos to .ds-doc-viewport; existing instances stay until touched.Tabbed viewport — .ds-doc-viewport--tabbed
Multi-pane variant — a tab strip swaps the body between named panes. Powers every catalog card and any frame needing several views.
Live demo — click the tabs to swap panes. The frame, padding, and border are inherited from .ds-doc-viewport; the variant adds the tab strip and the show/hide behaviour for panes.
A tabbed viewport keeps related views inside a single shared frame — same border, same padding, same controls slot — and swaps the body content from a tab strip across the top.
Switching is purely client-side; the inactive panes stay in the DOM (with hidden) so search, screen readers, and the Concise / Detailed toggle still find them.
| Metric | Value | Trend |
|---|---|---|
| Active tabs | 3 | stable |
| Panes in DOM | 3 | stable |
| Switch latency | <1 ms | — |
The pairing rule: every data-tab on a button must match a data-pane on a div inside the same frame. The validator (validate-doc.py) checks both directions and flags orphans.
Sub-components
| Class | Role | Notes |
|---|---|---|
| .ds-doc-viewport--tabbed | Modifier on the outer viewport | Removes default padding so the tab strip sits flush; opt-in only — base .ds-doc-viewport is unchanged. |
| .ds-doc-viewport-tabs | Tab strip container | Flex row · scrolls horizontally on overflow · sticky bottom border seam. |
| .ds-doc-viewport-tab | Individual tab button | Quiet pill style · .is-active for the open pane · data-tab="key" matches a pane. |
| .ds-doc-viewport-pane | Pane content wrapper | One per tab · data-pane="key" · hidden on inactive panes (DOM stays, AI/screen-readers find the content). |
ARIA — JS sets role="tab" + aria-controls on every .ds-doc-viewport-tab, and role="tabpanel" + aria-labelledby on every .ds-doc-viewport-pane. The aria-label on .ds-doc-viewport-tabs is the only authored attribute — write a short, contextual label.
Wiring — shared.js → initDsCompDoc() finds every .ds-doc-viewport--tabbed on the page and wires it. Deep links work too: #section-id:pane-key opens the named pane on load.
- Use the canonical pane names in the catalog —
preview · markup · specs · anatomy · rules · source. Drift starts the moment one card invents a new key. The validator warns on non-canonical names. - Every card gets a Preview pane — even reference / matrix cards. The Preview is what designers scan; without it, the card reads like a spec sheet and disappears in the catalog.
- Keep panes in the DOM with
hidden, notdisplay: nonefrom a custom class. The HTMLhiddenattribute is what lets the page-level Concise / Detailed toggle override visibility, and what lets AI scrapers find every pane's content. - Nesting one tabbed viewport inside a pane is OK — only one pane is visible at a time, so there's no compositing seam. The base "no nested viewports" rule still holds for the visible side.
- Don't put production navigation in a tabbed viewport. It's a documentation/inspection surface — use
.tab-navor your product's tab system for real product UI. - Don't skip the
aria-labelon.ds-doc-viewport-tabs. Screen readers announce the tablist by that label; "Component documentation" is fine for catalog cards, but be specific where context matters.
- CSS —
styles.css· search.ds-doc-viewport--tabbed(sits right after.ds-doc-viewport-body). - JS wiring —
shared.js·initDsCompDoc(). Wires every tabbed viewport on the page; works whether or not the viewport is inside a.ds-doc-cardcard. - Catalog use — every
.ds-doc-cardindesign-system/index.htmlcomposes a tabbed viewport as its content frame. - Validator —
.claude/skills/lifi-ds-docs/scripts/validate-doc.py· checks tab ↔ pane pairing, missing canonical panes, non-canonical pane names. - Skill —
.claude/skills/lifi-ds-docs/· template + procedure for adding a card.
Viewport reference
Anatomy, modifier matrix, and Do/Don't for the .ds-doc-viewport primitive — the canonical preview surface in the catalog.
Default surface — transparent. Sits directly on the page; padding only, no chrome. Add --tabbed for the tab-strip-needs-a-panel case, or --showcase for the brand-gradient backdrop. Non-tabbed previews (--stack, --row, --center, bare) inherit the transparent base so demos read against the page surface without a competing well.
Showcase surface — ambient brand-gradient backdrop (mimics decks, proposal pages, hero surfaces). Reach for it when demoing how a material reads against a real-world brand-tinted background. The canonical case is glass: the gradient shows through the translucency, revealing the material’s actual character.
Layout, size, and height modifiers stack orthogonally — see the Anatomy tab for the full matrix.
Sub-components
| Class | Role | Notes |
|---|---|---|
| .ds-doc-viewport | Outer surface + padding | Exposes --vp-pad, --vp-radius, --vp-border for per-instance overrides |
| .ds-doc-viewport-controls | Extensible controls slot (top-right of viewport) | Flex row · 2px gap. Accepts .ds-doc-viewport-control, .btn-*, .ds-doc-seg-control, anything |
| .ds-doc-viewport-control | Canonical 28×28 icon button | Transparent bg · hover tint · .is-active state · 14×14 SVG slot |
| .ds-doc-viewport-body | Inner content wrapper | No box model. Layout modifiers (--center, --row, --stack) target this when present, else the viewport itself. |
Modifier axes (orthogonal — stack freely)
| Axis | Values | Use |
|---|---|---|
| Surface | default · --showcase | Default = lifted card. --showcase swaps to an ambient brand-gradient backdrop for material demos (mimics decks / proposals). |
| Size | --sm · default · --lg | Density — tighter for control racks, larger for hero showcases |
| Layout | --center · --stack · --stack-lg · --row | Cascades to .ds-doc-viewport-body when present, else viewport itself |
| Height | --tall | min-height: 400px for demos with dynamic content |
- Use
.ds-doc-viewportfor every new component preview — catalog, dashboard, brand-guide, skill-rendered artifacts. - In documentation, prefer stacked +
--lg. Vertical column of full-width--lgviewports gives each variant whitespace; side-by-side grids only when the comparison itself is the subject. - Default the demo content to the base palette — a
.chip.chip-neutral, neutral text label, or single.btn-tertiary. Reserve accent palettes for components whose purpose is the accent. - Don't use raw hex / rgb in demo content. Every colour comes from a token (
var(--text-primary),var(--accent-primary),var(--series-N)). - Don't override
.ds-doc-viewportwith custombackgroundinline — use a surface modifier or the--vp-bordercustom property. - Don't use the viewport for production UI. It's a documentation/inspection surface — use
.card/.accent-cardfor real product surfaces. - Don't skip
.ds-doc-viewport-bodywhen you have header or footer. The negative-margin bleed depends on it. - Don't nest viewports. For a grid of previews,
display: gridon a parent with N flat viewports as children.
Asset cells
The figure-and-caption cells that render every library grid on this page, and the hover-revealed download button each asset carries. Catalog-meta (tier 1) — these classes build the asset libraries; never reach for them on product or marketing surfaces.
Asset cell .ds-doc-avatar-cell · .ds-doc-icon-cell
Figure-and-caption grid cell for the asset libraries; avatar, portrait, icon, and partner-logo cells reveal a live download button on hover.
Every cell below is live — hover (or tab) to reveal the download button; clicking saves the canonical file. Avatar and portrait cells resolve the file from the rendered <img> (largest srcset candidate); icon cells resolve it from data-icon. Partner-logo cells additionally reveal a mode-flip toggle (top-left) that previews the cell inline in the opposite mode — the download always saves whichever variant of the dark/light pair the cell shows.
Three cell shapes, one button: a 40px avatar (SVG), a 24px Lucide icon (SVG), and a 96px portrait whose download resolves the 1024px master (PNG).
<!-- Author the figure only — the download button (.ds-doc-cell-dl) is
injected by the page's "Per-asset download" script. -->
<div class="ds-doc-avatar-grid">
<figure class="ds-doc-avatar-cell">
<img class="avatar avatar--rounded" src="../avatars/chains/ethereum.svg" alt="ethereum" loading="lazy">
<figcaption>ethereum</figcaption>
</figure>
</div>
<!-- Icon cells carry data-icon — the lazy loader injects the glyph,
the download decorator resolves /icons/<name>.svg from it. -->
<figure class="ds-doc-icon-cell" data-icon="activity">
<span class="ds-doc-icon-ph" aria-hidden="true"></span>
<figcaption>activity</figcaption>
</figure>
<!-- What the script injects (reference only — never hand-author): -->
<a class="btn-primary btn-icon btn-xs btn-circle ds-doc-cell-dl"
href="../avatars/chains/ethereum.svg" download="ethereum.svg"
aria-label="Download ethereum.svg">
<svg>…</svg>
</a>
| Property | Value | Token / Notes |
|---|---|---|
| Grid | repeat(auto-fill, minmax(112px, 1fr)) | gap var(--space-8) |
| Cell padding | 16 / 8 | var(--space-16) / var(--space-8) |
| Icon glyph | 24px · stroke 1.5 | Marketing / Catalog stroke tier |
| Avatar | 40px · 96px portraits | .avatar default · .avatar--2xl |
| Download button | 24 × 24 · 12px glyph | .btn-primary .btn-icon .btn-xs .btn-circle — all chrome native |
| Reveal | opacity 0 → 1 · 0.15s ease | :hover / :focus-within on the cell |
| Mode flip | 24 × 24 · 12px glyph | .btn-neutral .btn-icon .btn-xs .btn-circle — top-left, partner cells only |
| Class | Role | Notes |
|---|---|---|
| .ds-doc-avatar-grid / .ds-doc-icon-grid | Auto-fill grid wrapper | 112px min cells; the two grids differ only in what the cells hold. |
| .ds-doc-avatar-cell | Figure — image asset | Direct-child img.avatar + figcaption. Hosts avatars AND portraits. |
| .ds-doc-icon-cell | Figure — Lucide icon | data-icon names the library file; glyph lazy-injects in the main grid. |
| figcaption | Filename caption | Mono, muted — the grep-and-paste name. |
| .ds-doc-cell-dl | Injected download anchor | Composes the stock 24px circular primary icon button; the class adds positioning + reveal ONLY. |
| .ds-doc-cell-flip | Injected mode-flip toggle | Partner-logo cells only — neutral xs circle, top-left. Toggles .is-flipped on the cell (opposite-mode plate + variant swap). |
- The download button covers avatars, portraits, icons, and partner logos. Partner-logo cells hold a dark/light pair; the button downloads whichever variant the cell shows — theme-matching by default, the other file while mode-flipped (June 2026 decision — WYSIWYG either way). Brand lockups stay excluded — they carry their own export toolbar (
initBrandLockupExport). - The mode-flip toggle is a preview, not a setting. Partner cells only: it renders the cell inline in the opposite mode (plate + variant) and re-scopes the download. The state is relative to the page theme — a page-theme flip clears every flipped cell, so the download contract never goes stale. The toggle is
.btn-neutral(the download keeps.btn-primary— one primary action per cell); its glyph shows the mode a click switches to, matching the header toggle. - Never hand-author
.ds-doc-cell-dlin markup — the page script injects it from the cell's ownsrc/data-icon. A hand-authored copy doubles up and drifts. .ds-doc-cell-dlis positioning + reveal only — the icon-button composition owns all chrome. Don't add fill, typography, or padding overrides.- It's an icon button, not a chip. A lone actionable glyph is the button family's job (
.btn-icon.btn-circleat thexstier); a "flush" icon-only chip variant was considered and rejected (June 2026) as a duplicate of this composition. Chips stay labeled tone pills. - Downloads hand over the canonical file — icons from
/icons/<name>.svg; image cells take the largestsrcsetcandidate, so portraits export the 1024px master, not the 96px thumb. - Catalog-meta, tier 1. These cells build the asset libraries — never reach for them on product or marketing surfaces.
CSS: styles.css — search .ds-doc-cell-dl (download overlay) and .ds-doc-icon-cell (cell primitives).
JS: design-system/assets.html — the Per-asset download IIFE (decorator) and the lazy icon loader beside it.
Icon: icons/download.svg (Lucide v1.8.0).
Lightbox
Asset libraries render at tiny grid size; the lightbox lets you click an asset to view it at full resolution and step through the set. It composes the existing .modal-overlay scrim and .btn-icon chrome — no new overlay machinery. Any grid opts in with one attribute; tier-2 universal, so a marketing or product gallery can reuse it.
Lightbox .lightbox
Click an asset to enlarge it and step through the gallery — arrows, ←/→ keys, scroll, or swipe. Opt in per grid with data-lightbox.
The Isometric Illustrations grid above is a live lightbox (ink frame) — click any scene to open it large and navigate the set. The demo below shows the generic opt-in for an <img> gallery on the default surface frame: click a chain mark, then use the arrows, ←/→ keys, scroll wheel, or a swipe to move through the three.
One overlay singleton serves every gallery on the page. The frame surface flips per gallery — ink for white-on-ink assets (iso scenes), surface (default) for everything else.
<!-- Opt in per gallery — one attribute. Items are the grid's direct
<figure> children (or any [data-lightbox-item]); the enlarged node is
each item's first <svg>/<img>, cloned. No JS call needed —
shared.js → initLightbox() wires every [data-lightbox] on load. -->
<div class="ds-iso-grid" data-lightbox="iso" data-lightbox-frame="ink">
<figure class="ds-iso-tile">
<svg class="bb-iso" viewBox="…">…</svg>
<figcaption class="ds-iso-cap">
<span class="ds-iso-name">Mac Studio</span>
<span class="ds-iso-meta">front</span>
</figcaption>
</figure>
</div>
<!-- Generic gallery: plain figures, surface frame, explicit caption via
data attributes (figcaption .ds-iso-name/.ds-iso-meta, the first two
figcaption children, or an <img alt> all work as fallbacks). -->
<div data-lightbox="chains">
<figure data-lightbox-name="Ethereum" data-lightbox-meta="chainId 1">
<img src="../avatars/chains/ethereum.svg" alt="Ethereum">
</figure>
</div>
// Programmatic open (optional):
window.LifiLightbox.open(items, index, { trigger, frame: 'ink' });
| Part | Class / hook | Role |
|---|---|---|
| Scrim | .modal-overlay.lightbox | Reuses the modal overlay — fixed inset, dark + blur, fade-in, [hidden] handling. Clicking the bare scrim closes. |
| Viewer | .lightbox__viewer | Centered column — the framed asset over the caption. |
| Frame | .lightbox__frame · [data-frame="ink"] | Holds the cloned asset; surface ground by default, ink (--text-primary) for white-on-ink assets. |
| Caption | .lightbox__name · __meta · __counter | Name + mono meta + N / M position (hidden for a single item). |
| Chrome | .lightbox__close · .lightbox__nav--prev/next | Reuse .btn-icon.btn-borderless.btn-circle.btn-lg; pinned white on the dark scrim regardless of page theme. |
| Item | .is-lightbox-item | Added by the controller to each gallery item — cursor: zoom-in, role="button", tabindex, Enter/Space + click open. |
| Controller | initLightbox() · window.LifiLightbox | Scans [data-lightbox] on load; one overlay singleton serves every gallery; exposes open / close. |
- Reuse first. The scrim is
.modal-overlay; close + arrows are.btn-iconwith the existingx/chevron-left/chevron-righticons. Only the thin stage layout + the controller are new. - Opt in with
data-lightboxon the gallery container; adddata-lightbox-frame="ink"for white-on-ink assets. No JS call — the controller wires it on load. - Items are figures with an
<svg>or<img>. The enlarged node is cloned, so self-contained CSS-keyframe animations (the iso scenes) keep looping in the lightbox. - Navigate with the prev/next buttons, ←/→ keys, the scroll wheel, or a swipe. Close with ✕, Esc, or a click on the scrim. The set wraps around.
- Caption resolves from
data-lightbox-name/-meta, else the figcaption (.ds-iso-name/.ds-iso-meta, a plain-text figcaption, or its first two element children), else<img alt>. - Coexists with per-cell chrome. A grid whose cells carry a hover-revealed download (
.ds-doc-cell-dl) or flip (.ds-doc-cell-flip) button stays a clean lightbox: a click/key landing on those buttons is ignored, so download/flip and enlarge are distinct affordances. The enlarged node is the first visible<svg>/<img>that isn't cell chrome — so a partner logo's theme-correct (dark/light) variant is the one shown. - Accessible by default. Items become keyboard-operable buttons with an enlarge label; the overlay is
role="dialog" aria-modal="true"; body scroll locks while open; focus returns to the triggering tile on close. - Don't hand-roll a viewer. A second lightbox would fork the scrim + chrome conventions — opt a grid into this one instead.
- CSS:
styles.css→ search.lightbox — enlarge + navigate(composes.modal-overlay; the[data-frame="ink"]+ chrome rules). - JS:
shared.js→initLightbox()(wired in theDOMContentLoadedinit list); API onwindow.LifiLightbox. - Consumers:
design-system/assets.html→ Isometric Illustrations ([data-lightbox="iso"], ink frame), Partner Logos ([data-lightbox="partner-logos"]), Portraits — Personas + Team ([data-lightbox="portraits-personas"]/="portraits-team"). - Spec:
design/components/feedback.md→ Lightbox.