l-design-system
Look up zudo-text design system rules when writing CSS, creating components, using Tailwind classes, or adding design tokens. Covers: display-scale system, spacing/font-size/icon/color tokens, three-t...
Design System Reference
Source of truth: packages/
Display Scale System
This app replaces browser zoom with a --display-scale CSS custom property.
Why: Tauri WebView causes cursor position errors with native browser zoom. The custom scale system avoids this by scaling all visual dimensions via CSS calc().
How it works:
JS calls
applyDisplayScale(scale)which sets--display-scaleondocument.documentElementAll spacing and font-size tokens multiply their base value by
var(--display-scale)Valid scales:
[0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0]
Implementation: tauri-
The Golden Rule
ALL visual px values MUST scale with --display-scale. Three valid approaches:
Tailwind utilities (preferred) —
p-lg,text-sm,gap-mdalready scale via token overrides in:rootcalc()in hand-written CSS —calc(Npx * var(--display-scale, 1))CSS variable reference —
var(--spacing-lg),var(--text-base)
NEVER use raw px values for visual dimensions in CSS or arbitrary Tailwind values like w-[24px]. They won't scale when the user changes display scale.
Exceptions (values that should NOT scale)
border-width(1px, 2px) — intentionally thin, scaling makes them blurryborder-radius— defined in@theme, not runtime-scaled (visual preference, not layout)outline,box-shadow spread— decorative, not layout1pxas a divider/separator sizeScrollbar dimensions (browser-controlled)
Spacing Tokens
Defined in @theme block, runtime-overridden in :root with calc(base * var(--display-scale)).
| Token | Base | Tailwind | Description |
|---|---|---|---|
--spacing-0 | 0 | p-0, gap-0 | Zero |
--spacing-1px | 1px | p-1px | Hairline (not scaled) |
--spacing-2xs | 2px | p-2xs, gap-2xs | Micro |
--spacing-xs | 4px | p-xs, gap-xs | Extra small |
--spacing-sm | 6px | p-sm, gap-sm | Small |
--spacing-md | 8px | p-md, gap-md | Medium |
--spacing-lg | 12px | p-lg, gap-lg | Large |
--spacing-xl | 16px | p-xl, gap-xl | Extra large |
--spacing-2xl | 20px | p-2xl, gap-2xl | 2x large |
--spacing-3xl | 24px | p-3xl, gap-3xl | 3x large |
--spacing-4xl | 32px | p-4xl, gap-4xl | 4x large |
--spacing-5xl | 48px | p-5xl, gap-5xl | 5x large |
Icon Size Tokens
Scaled with --display-scale like all spacing tokens.
| Token | Base | Tailwind | Usage |
|---|---|---|---|
--spacing-icon-xs | 12px | w-icon-xs h-icon-xs | Extra-small inline icons |
--spacing-icon-sm | 14px | w-icon-sm h-icon-sm | Small inline icons |
--spacing-icon-md | 16px | w-icon-md h-icon-md | Standard icons (default) |
--spacing-icon-lg | 20px | w-icon-lg h-icon-lg | Large toolbar icons |
--spacing-icon-xl | 24px | w-icon-xl h-icon-xl | Prominent icons |
Icon component API: Icons accept a size prop: "xs" | "sm" | "md" | "lg" | "xl" (default: "md"). This renders the icon inside a <span> with the corresponding Tailwind size classes, which scale with --display-scale. Backward-compatible width/height number props are still supported but bypass scaling.
Migration rule: Use token sizes by default. Explicit numeric width/height props on icon components are only allowed for component-owned exceptions not shared across the app (e.g., a 32px navigation arrow specific to one dialog). All such exceptions must use calc(Npx * var(--display-scale)) to respect display scaling.
// Preferred — scales with display-scale
<SearchIcon size="md" />
// Backward compatible — does NOT scale
<SearchIcon width={16} height={16} />Component Size Tokens
Scaled with --display-scale. Used for specific UI components.
| Token | Base | Tailwind | Usage |
|---|---|---|---|
--spacing-draft-btn | 24px | w-draft-btn h-draft-btn | Draft number buttons |
--spacing-scroll-btn | 20px | w-scroll-btn h-scroll-btn | Scroll arrow buttons |
--spacing-icon-btn-square | 32px | w-icon-btn-square h-icon-btn-square | Square icon buttons |
--spacing-draft-preview-w | 440px | w-draft-preview-w | Draft hover preview popover width |
--spacing-draft-preview-h | 360px | max-h-draft-preview-h | Draft hover preview popover height |
Spacing Role Tokens (Spacing Rearchitecture epic #3957)
Component-scoped semantic tokens layered on the base scale above — one token per design decision that isn't itself a base-scale step (28, 22, 44, 3, a 16-gap) or that needs independent scaling behavior (a floor, a rounded bar). Full doctrine + per-token rationale: doc/.
| Token | Base | :root scaled value | Tailwind | Replaces |
|---|---|---|---|---|
--spacing-close-btn | 28px | calc(28px * var(--display-scale)) | w-close-btn h-close-btn | ~19–35 copy-pasted w-[28px] h-[28px] close-X sites — use <DialogCloseButton> instead of the raw utility |
--spacing-hint-indent | 28px | calc(28px * var(--display-scale)) | ml-hint-indent (hintIndentCls) | ~25 ml-[28px] settings hint indents |
--spacing-accent-bar | 3px | max(2px, round(3px * var(--display-scale), 1px)) — floored + rounded | w-accent-bar/h-accent-bar, border-l-[length:var(--spacing-accent-bar)] | 3px priority stripe / drag pill / insertion indicator |
--spacing-icon-btn-mini | 22px | calc(22px * var(--display-scale)) | w-icon-btn-mini h-icon-btn-mini | table-helper 22px mini buttons; shortcut remove-button 24→22. Distinct from --spacing-frame-ctrl-btn (also 22px, frame chrome) — do not rename or merge |
--spacing-card-gap | 16px | calc(16px * var(--display-scale)) | gap-card-gap | gap between bordered board cards (timeline/pile figure-ground fix) |
--spacing-section-gap | 28px | calc(28px * var(--display-scale)) | mb-section-gap (etc.) | names the validated settings 10↔28 (2.8×) between-group rhythm — adopt where already ~28, don't migrate to 24/32 |
--spacing-tree-indent-base | 12px | calc(12px * var(--display-scale)) | consumed via treeIndentStyle(depth, scale) | tree depth-0 content indent (5 divergent implementations converge here) |
--spacing-tree-indent-step | 16px | calc(16px * var(--display-scale)) | consumed via treeIndentStyle(depth, scale) | tree per-depth increment |
--spacing-tree-connector | 1px | (FIXED — declared once, no :root override) | n/a (JS-positioned) | tree guide-line width, never scaled |
--spacing-touch-min | 44px | max(44px, calc(44px * var(--display-scale))) — 44px floor, only grows | min-h-touch-min/min-w-touch-min, max-mobile:min-h-touch-min | raw 44/56 hit areas (iOS HIG touch minimum) |
--overlay-offset-top | (not a spacing utility — :root only) | 10vh — FIXED, viewport-relative | top-[var(--overlay-offset-top)] | modal overlay vertical anchor (command-palette/spotlight/item-detail unify to 10vh) |
Shared class constants (@takazudo/ui-components)
Pairings of existing base-scale tokens exported as named class strings — a single CSS var can't express a padding shape, but a named class can:
| Constant | Classes | Role |
|---|---|---|
dialogHeaderCls | flex items-center justify-between px-3xl py-xl border-b border-edge | Dialog header chrome |
dialogBodyCls | px-3xl py-3xl | Dialog body chrome |
dialogFooterCls | flex items-center justify-end gap-md px-3xl py-xl border-t border-edge | Dialog footer chrome |
menuItemCls | px-md py-xs max-mobile:min-h-touch-min | Tier A floating-menu row (context-menu, dropdown-listbox, kebab, font-picker) |
pickerRowCls | px-lg py-sm min-h-touch-min | Tier B search-overlay row (spotlight, command-palette) |
toolbarBarCls | flex items-center gap-sm px-lg py-sm | Secondary board/control bar (not the top app toolbar) |
chipPadCls | px-sm py-2xs | Chip/pill/badge padding |
bannerRowCls | px-lg py-sm mb-md bg-bg-alt | Sub-header banner row |
hintIndentCls | ml-hint-indent | Settings hint indentation (same value as the token above) |
Row-tier rule: only 2 tiers. Every floating menu/listbox row is either menuItemCls (compact, mouse-driven) or pickerRowCls (roomier type-ahead overlay, 44px floor). Don't invent a third.
Shared primitives
<DialogCloseButton>(@takazudo/ui-components) — the close (X) button for a dialog header. Backed by--spacing-close-btn; rendersw-close-btn h-close-btnwith the glyph inside. Always render it per the Close-X top-right rule (rootCLAUDE.md§ Adding a New Dialog).treeIndentStyle(depth, scale)(@takazudo/ui-components) — the one tree-indent/connector formula every tree surface uses. Returns{ paddingLeft, connectorLeft }in px, already rounded to an integer device pixel:paddingLeft = round((12 + 16*depth) * scale),connectorLeft = round((12 + 16*depth − 8) * scale). Rounding happens in JS (not CSScalc()) because fractional scales (0.9, 1.1, 1.25, 1.75) leave sub-pixel values that drift rows out of alignment otherwise.
Font Size Tokens
Runtime-overridden in :root with calc(base * var(--display-scale)).
| Token | Base | Tailwind |
|---|---|---|
--text-xs | 13px | text-xs |
--text-sm | 14px | text-sm |
--text-md | 15px | text-md |
--text-base | 16px | text-base |
--text-lg | 18px | text-lg |
--text-xl | 22px | text-xl |
--text-2xl | 24px | text-2xl |
Color System (Three-Tier)
Colors flow through three tiers. Never use raw color values in components.
Tier 1 — Palette (raw values, set by color theme): --palette-bg, --palette-fg, --palette-cursor, --palette-selection, --palette-0 through --palette-15
Tier 2 — Semantic (resolved by JS from color theme config): --theme-bg-primary, --theme-bg-secondary, --theme-bg-surface, --theme-text-primary, --theme-text-secondary, --theme-accent, --theme-accent-subtle, --theme-border, --theme-danger, --theme-danger-strong, --theme-on-accent, --theme-hover-overlay, --theme-hover-bg, --theme-hover-fg, --theme-selection, --theme-cursor
Tier 3 — Tailwind aliases (defined in @theme, bridging semantic vars to utilities):
| Alias | Maps to | Usage |
|---|---|---|
--color-bg | --theme-bg-primary | bg-bg — primary background |
--color-bg-alt | --theme-bg-secondary | bg-bg-alt — secondary background |
--color-surface | --theme-bg-surface | bg-surface — elevated surface |
--color-fg | --theme-text-primary | text-fg — primary text |
--color-fg-muted | --theme-text-secondary | text-fg-muted — secondary text |
--color-accent | --theme-accent | bg-accent, text-accent — accent color |
--color-accent-subtle | --theme-accent-subtle | bg-accent-subtle — subtle accent |
--color-edge | --theme-border | border-edge — borders |
--color-danger | --theme-danger | text-danger — error/danger |
--color-danger-strong | --theme-danger-strong | text-danger-strong — strong danger |
--color-info | --theme-info | text-info — informational |
--color-info-strong | --theme-info-strong | text-info-strong — strong informational |
--color-on-accent | --theme-on-accent | text-on-accent — text on accent bg |
--color-overlay | --theme-hover-overlay | bg-overlay — overlay background |
--color-hover | --theme-hover-bg | bg-hover — hover background |
--color-hover-fg | --theme-hover-fg | text-hover-fg — hover text |
--color-selection | --theme-selection | bg-selection — selection highlight |
--color-cursor | --theme-cursor | bg-cursor — cursor color |
Layout Tokens
| Token | Value | Intended surface |
|---|---|---|
--toolbar-height | calc(52px * var(--display-scale)) | — |
--status-bar-height | 0px (set by JS when visible) | — |
--overlay-bg | rgba(0, 0, 0, 0.6) | Canonical modal scrim — use via bg-[var(--overlay-bg)] |
--overlay-bg-weak | rgba(0, 0, 0, 0.3) | Non-modal drawer scrims |
--overlay-bg-strong | rgba(0, 0, 0, 0.9) | Image-dense overlays / lightbox |
Important: --color-overlay (Tier 3 Tailwind alias bg-overlay) is the hover-tint color (maps to --theme-hover-overlay), NOT a scrim — the similar name is a one-hyphen collision. Always use --overlay-bg for modal scrims and dialog backdrops.
Z-Index Layer System
Source of truth: packages/. Regenerate the CSS with pnpm gen:z-index; validate parity with pnpm check:z-index. The generated block in packages/ between GENERATED:Z_INDEX_BEGIN and GENERATED:Z_INDEX_END is never hand-edited.
| Token | Value | Purpose |
|---|---|---|
--z-content | 0 | Default in-flow content |
--z-toolbar | 10 | Sticky toolbars / sidebar resize |
--z-dropdown | 20 | In-flow dropdown menus |
--z-popover | 30 | Inline popovers (not portaled) |
--z-popover-portaled | 40 | Popovers via portal |
--z-mobile-strip | 40 | Mobile bottom frame-strip pill bar (shares value with popover-portaled; semantically separate) |
--z-modal-backdrop | 50 | Modal/drawer backdrop |
--z-modal | 60 | Modal/drawer foreground |
--z-modal-stacked | 65 | Modal rendered above another modal (e.g. lightbox over assets dialog) |
--z-context-menu | 68 | Context menus (above stacked modals so they open from inside dialogs; below toast) |
--z-toast | 70 | Transient notifications |
--z-tooltip | 80 | Tooltips (highest UI layer) |
--z-ios-offline | 90 | iOS offline empty-state screen |
--z-dev-badge | 100 | Dev-only diagnostic overlays |
--z-local-1 | 1 | Child promotion within isolated parent |
--z-local-2 | 2 | Child promotion within isolated parent |
--z-local-3 | 3 | Child promotion within isolated parent |
Global tiers stack by value: --z-modal-stacked (65) is above --z-modal (60), while --z-context-menu (68) stays above stacked modals and below --z-toast (70). The two value-40 tiers are intentionally shared but semantically separate. Use --z-local-1 through --z-local-3 only inside a parent that creates its own stacking context (for example, isolation: isolate or positioned plus z-index) to promote children above siblings; never use local tokens against global tiers.
Portal vs inline: inline popovers use the inline tier; a body-portaled float uses the portaled tier, and a body-portaled float whose anchor lives inside a modal must be elevated above --z-modal with --z-modal-stacked (65). DropdownListbox exposes the finalized S1 API panelLayer?: "dropdown" | "modal-stacked"; use it as panelLayer="modal-stacked" for that case (#5662). Precedents: pile-view-dialog.tsx (~L665), pile-filter-autocomplete.tsx (~L312), and theme-creator-help-dialog.tsx (~L453).
Pointer-events trap: an overlay rendered as a child of a pointer-events-none modal wrapper is unclickable regardless of z-index because hit-testing skips it (#5635). Either portal it to document.body with its own pointer-events-auto chain, or render it inside the wrapper's pointer-events-auto frame. ModalFrame does not re-enable pointer events itself; the call site must provide the remedy.
design-token-lint forbids raw z integers. Use var(--z-*) or calc(var(--z-*) …) references; do not write raw values such as z-index: 65 or z-[65].
Border Radius (NOT scaled)
These are intentionally not scaled with --display-scale.
| Token | Value | Tailwind |
|---|---|---|
--radius-sm | 3px | rounded-sm |
--radius-md | 4px | rounded-md |
--radius-lg | 6px | rounded-lg |
--radius-xl | 12px | rounded-xl |
--radius-dialog | 0 | rounded-dialog |
--radius-full | 9999px | rounded-full |
Dialog and panel surfaces are SQUARE
Every dialog, modal, floating panel, drawer, and popover surface has square corners. Use --radius-dialog (rounded-dialog) on the surface root — never rounded-xl, and never a hand-written radius.
This is a shape-language decision, not a per-dialog choice: rounded chrome reads as a phone-app card, while this app's surfaces are meant to read as panes of a desktop tool. Mixing the two makes the app look assembled from parts.
Applies to: the outer surface of
FloatingPanel,ResizableDialog,MobileFullscreenDialog, settings/preferences dialogs, side panels, bottom drawers, context menus, and anybg-surface/bg-bgbox that floats above the page with its own border and shadow.Does NOT apply to: controls inside those surfaces. Buttons, inputs, chips, thumbnails, checkboxes and menu rows keep
-— the radius scale still exists, it just stops at the container.- radius- sm/ md/ lg --radius-xlremains defined for in-content cards, but a dialog root must not use it.
Movable panels need a visible affordance
If a surface can be dragged, the UI must say so. A panel that moves only when you happen to grab the right pixel is a hidden feature.
Required for any drag-movable surface:
| Rule | Detail |
|---|---|
| Grip affordance | A grip glyph at the leading edge of the header, before the title |
| Grip is an icon | Use the shared GripIcon component — sized with --spacing-icon-* |
| Header is the handle | The whole header bar drags; interactive children opt out |
| Cursor | cursor: grab on the header, grabbing while dragging |
Never render a grip as a bare text character. ⠿ (and friends like ⋮⋮, ≡) inherit whatever font-size they land in, so they do not follow --display-scale — at 150% every neighbouring control grows and the grip stays tiny. This is the single most common way the display-scale golden rule gets broken, and neither design-token-lint nor a jsdom class-string test can see it, because there is no class and no px value to inspect. An icon component with a size prop is the only correct form.
Resize handles: opt-in for browsable-library surfaces
Do not put a resize grip on a dialog or panel by default. A corner grip reads as an OS window chrome affordance and is out of place on an in-app surface — most panels size themselves from their layout and their content.
The one exception: an outer surface may be user-resizable when its content is a browsable library — a filer whose useful size scales directly with how much the user is browsing, rather than a fixed-purpose dialog. The Assets filer is the motivating case. This is opt-in per surface, not a default flip — other FloatingPanel consumers keep their current non-resizable behaviour.
When a surface opts in, the grip must be an icon component with a size prop, placed at the bottom-right corner — never a bare text glyph (⌟ and friends), which does not follow --display-scale for the same reason as the drag grip above.
Panes inside a surface may still have splitters (the folder-tree/details splitters are fine) — that part is unchanged.
Collapsing a pane: edge tab, not a toolbar button
To let the user hide a side pane (a folder tree, a details/inspector pane), mount a small chevron tab on the seam between the pane and the content, the way zudo-doc collapses its sidebar and TOC. When the pane hides, the tab slides to the surface edge and the content reflows into the space.
This is preferred over a toolbar toggle because it costs no header room, it sits where the thing it controls is, and the same control both hides and restores. Pair it with a data-*-driven CSS transition on the pane rather than unmounting, so the motion is continuous. Reference implementation: .zd-desktop-sidebar-toggle / .zd-desktop-toc-toggle in packages/ of the zudo-doc repo.
Other Tokens
Font family: --font-sans (Noto Sans JP stack), --font-mono (JetBrains Mono stack)
Font weight: --font-weight-normal (400), --font-weight-medium (500), --font-weight-semibold (600), --font-weight-bold (700)
Line height: --line-height-tight (1), --line-height-normal (1.5), --line-height-relaxed (1.75)
Transition durations: --duration-fast (0.1s), --duration-normal (0.15s), --duration-slow (0.25s)
Shadow: --shadow-dialog — 0 20px 60px rgba(0, 0, 0, 0.5)
Tailwind v4 — Tight Token Strategy
All Tailwind defaults are reset in the @theme block:
@theme {
--spacing-*: initial;
--color-*: initial;
--text-*: initial;
/* ... all categories reset */
}Only explicitly defined tokens are available. This means:
p-4does NOT work (Tailwind's default1remis reset)p-mdworks (our8pxscaled token)text-red-500does NOT work (no default color palette)text-dangerworks (our semantic color alias)
Active State (list / nav / tab selection)
When a list item, nav item, or tab is "active" (the currently selected entry), use the canonical active-state tokens. This is the "tan highlight" pattern shared by SidebarItem, TabBar, Button, archives table-view, similar-docs/doc-list-item, ios-onboarding, and authoring-help nav.
Canonical tokens:
| Token | Role |
|---|---|
--theme-active-bg | Background of the active/selected item |
--theme-active-fg | Foreground (text/icon) of the active/selected item |
--theme-accent | Optional 2px left border — the "tan highlight" cue for list-style nav items |
Tailwind aliases: bg-active, text-active-fg, border-accent.
Do
/* List / nav item — with the 2px accent left bar */
.my-nav-item {
border-left: 2px solid transparent; /* reserve space so active doesn't shift text */
}
.my-nav-item.is-active {
background: var(--theme-active-bg);
color: var(--theme-active-fg);
border-left-color: var(--theme-accent);
}// Tab / chip-style selector — no left bar, just bg/fg swap
<div className={active ? "bg-active text-active-fg" : "bg-transparent text-fg-muted hover:bg-hover"}>Don't
Don't use
--theme-accent-subtle(orbg-accent-subtle) as the active background. It is a decorative tint, not the selection token. Exception:bg-accent-subtleis fine as a transient highlight inside an animation keyframe (e.g. "restored" flash), not as the steady-state active class.Don't invent ad-hoc active pairs like
bg-accent-subtle + text-accentorbg-hover + text-fgfor the selected state. Per-theme contrast is tuned on--theme-active-bg/--theme-active-fg; ad-hoc pairs break that tuning.Don't use
bg-accent + text-on-accentfor list-item active. That is the "filled accent" pattern used by draft-bar pills and the swimlane toggle button — a distinct, louder UX intended for isolated controls, not list/nav selection.
Where the tokens come from
Defined in
packages/(ui- components/ src/ tokens. css --theme-active-bg,--theme-active-fg) and exposed as Tailwind aliases--color-active,--color-active-fg.Per-theme values flow through
packages/(thecolor- themes/ src/ color- settings. ts activeBg/activeFgkeys). Add new overrides there, not in component CSS.
Active / Focused Card Border (solid vs dashed accent)
For card-style surfaces that have a distinct active item (the currently-selected entry) and a separately-tracked keyboard-focused item — e.g. Timeline View cards and Pile View cards — use the canonical accent-border rule:
| State | Class | Look |
|---|---|---|
| active item | border border-accent | solid accent border |
| focused item | border border-dashed border-accent | dashed accent border |
Rules:
Single border owner. The card's own
borderis the only thing that paints the active/focused cue — never a separateoutline. Change border style + color only at a constant 1px width, so toggling active↔focused↔neutral causes zero layout shift.Active wins over focus. When one card is both active and focused, show the solid accent border (no dashed). A focused-but-not-active card shows dashed; a focused empty slot still shows dashed (focus outranks the neutral/transparent resting border).
Never an offset
outlinefor the focus cue.outline-offset-*paints outside the card box, and a scroll/overflow container (overflow-x-auto⇒overflow-y: auto) clips its top/bottom edges — producing a "cut out" border. Folding focus into the card's own border keeps all four sides continuous. (This generalizes the no-outlineguidance inl-lessons-active-frame-border— there it was the FrameChrome inset-shadow collision; here it is overflow clipping. Same conclusion: don't use offset outlines for the cue.)
Canonical implementation: packages/ (isActive ? editing ? "border border-accent" : "border border-dashed border-accent" : edge). Pile View follows the same matrix in tauri-.
This is distinct from the bg/fg "Active State" tan-highlight above — that is for list/nav/tab selection; this is for card surfaces whose selection cue is an accent border.
Common Mistakes
w-[24px]orh-[16px]— Arbitrary px values bypass display-scale. Use a spacing token orcalc(Npx * var(--display-scale)).width={16} height={16}on icons — Use thesizeprop instead (e.g.,<SearchIcon size="md" />). Thesizeprop renders with scaled CSS classes.gap: 12pxin CSS — Usevar(--spacing-lg)or Tailwindgap-lg.font-size: 16px— Usevar(--text-base)or Tailwindtext-base.New CSS custom properties without scaling — Any visual dimension must use
calc(... * var(--display-scale)).Using Tailwind's numeric spacing (
p-4,m-2,gap-8) — These are reset. Use named tokens (p-xl,m-xs,gap-md).Using default Tailwind colors (
bg-gray-100,text-blue-500) — These are reset. Use semantic aliases (bg-bg,text-accent).bg-black/40(or anybg-black/*) /text-white— the default palette is reset by--color-*: initial, so these utilities compile to NOTHING. For a background utility this leaves the backdrop fully transparent (#4093); for a text-color utility liketext-whiteit leaves the foreground at its inherited/parent color instead of white — neither failure raises a build or runtime error. The retiredbase/base-altstems (pre-rename names forbg/bg-alt) are the same class of error —from-base-altandbg-baseshipped as dead CSS for the same reason. This failure is invisible to jsdom class-string tests — the class name is present in the DOM either way, so a test asserting onclassNamepasses while the browser paints nothing. Usebg-[var(--overlay-bg)](or the weak/strong variant above) for scrims, and a semantic foreground alias (text-fg,text-on-accent, etc.) for text. The whole shadelesswhite/blackfamily (text-white,bg-white,text-black,border-black, … across every color-taking prefix — #4282) and the retiredbase/base-altutilities are now blocked bypnpm design-token-lint(repo-scopedprohibitedExtrain.design-token-lint.json). Two traps in how that ban is written: it enumerates concrete utilities likefrom-base-alt, because a bare stem entry is an exact-match rule and would silently match nothing; and every color-taking prefix needs its OWN entry, because exact-match rules do not match by prefix either —ring-basedoes not coverring-offset-base(both enumerated as of #4275). Do not treat a greendesign-token-lintas proof a file is clean: the extractor only readsclassName=/class=attributes,class:list, andcn()/clsx()-style calls, so a component that assembles its classes into a plain variable first (const cardClassName = `…`— the shapekanban-uses) is never scanned at all.board/ src/ kanban- card. tsx ring-offset-basesurvived there for exactly that reason, alongside classes as long-banned asbg-black. For those files a context-freegrepfor the retired stems is the only real check.A glyph character standing in for an icon (
⠿,⋮,×,▾as text) — it inherits font-size, so it silently ignores--display-scaleand desyncs from every real icon around it as the user zooms. Use an icon component with asizeprop. See "Movable panels need a visible affordance" above.text-basesitting next to abg-*fill (e.g.bg-accent text-base) — almost always the retired--color-basetoken (pre-rename name, from the base→bg rename) misused as a color, not the legitimatetext-basefont-size utility (16px).--color-baseno longer exists, so the class silently sets no color at all, leaving inherited/wrong foreground text (#4161, #4279).design-token-lintcannot flag this:text-baseis Tailwind's real font-size utility elsewhere in the codebase, and banning it outright would break those legitimate uses. A hand-audit is required — check whether the class is paired with abg-*fill (misuse → replace withtext-on-accentor another semantic foreground alias) versus standing alone as body text sizing (legitimate).
Adding a New Token
Add the base value in the
@themeblock intokens.cssAdd the scaled override in the
:rootblock withcalc(base * var(--display-scale))— unless the value is FIXED (never scaled, e.g. a hairline or avhoverlay anchor), in which case declare it once, in@themeonly, and skip step 2Use the
--spacing-prefix for spacing tokens (enables Tailwind utility generation)Use the token in components via Tailwind classes or CSS variables
If adding a color: add the Tier 3 alias in
@thememapping to the Tier 2 semantic variableNew token vs. shared class constant — if the value isn't itself a base-scale step and needs independent scaling (a floor, a rounded bar, a fixed px), add a
--spacing-*token (§ Spacing Role Tokens above). If it's just a pairing of existing base-scale tokens (a header is px+py+border), add a shared class constant instead — a single CSS var can't express a padding shape, but a named class can, and it keeps the--spacing-*key set from ballooning with one-off shapes.A new
--spacing-*token must land in both@themeand:root(unless FIXED) —packages/asserts the two key sets match, minus the fixed allowlist.ui- components/ src/ _ _ tests_ _ / tokens- spacing- role- parity. test. ts