zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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/ui-components/src/tokens.css

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-scale on document.documentElement

  • All 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-app/renderer/lib/display-scale.ts

The Golden Rule

ALL visual px values MUST scale with --display-scale. Three valid approaches:

  1. Tailwind utilities (preferred) — p-lg, text-sm, gap-md already scale via token overrides in :root

  2. calc() in hand-written CSScalc(Npx * var(--display-scale, 1))

  3. CSS variable referencevar(--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 blurry

  • border-radius — defined in @theme, not runtime-scaled (visual preference, not layout)

  • outline, box-shadow spread — decorative, not layout

  • 1px as a divider/separator size

  • Scrollbar dimensions (browser-controlled)

Spacing Tokens

Defined in @theme block, runtime-overridden in :root with calc(base * var(--display-scale)).

TokenBaseTailwindDescription
--spacing-00p-0, gap-0Zero
--spacing-1px1pxp-1pxHairline (not scaled)
--spacing-2xs2pxp-2xs, gap-2xsMicro
--spacing-xs4pxp-xs, gap-xsExtra small
--spacing-sm6pxp-sm, gap-smSmall
--spacing-md8pxp-md, gap-mdMedium
--spacing-lg12pxp-lg, gap-lgLarge
--spacing-xl16pxp-xl, gap-xlExtra large
--spacing-2xl20pxp-2xl, gap-2xl2x large
--spacing-3xl24pxp-3xl, gap-3xl3x large
--spacing-4xl32pxp-4xl, gap-4xl4x large
--spacing-5xl48pxp-5xl, gap-5xl5x large

Icon Size Tokens

Scaled with --display-scale like all spacing tokens.

TokenBaseTailwindUsage
--spacing-icon-xs12pxw-icon-xs h-icon-xsExtra-small inline icons
--spacing-icon-sm14pxw-icon-sm h-icon-smSmall inline icons
--spacing-icon-md16pxw-icon-md h-icon-mdStandard icons (default)
--spacing-icon-lg20pxw-icon-lg h-icon-lgLarge toolbar icons
--spacing-icon-xl24pxw-icon-xl h-icon-xlProminent 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.

TokenBaseTailwindUsage
--spacing-draft-btn24pxw-draft-btn h-draft-btnDraft number buttons
--spacing-scroll-btn20pxw-scroll-btn h-scroll-btnScroll arrow buttons
--spacing-icon-btn-square32pxw-icon-btn-square h-icon-btn-squareSquare icon buttons
--spacing-draft-preview-w440pxw-draft-preview-wDraft hover preview popover width
--spacing-draft-preview-h360pxmax-h-draft-preview-hDraft 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/src/content/docs/architecture/spacing-tokens.mdx.

TokenBase:root scaled valueTailwindReplaces
--spacing-close-btn28pxcalc(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-indent28pxcalc(28px * var(--display-scale))ml-hint-indent (hintIndentCls)~25 ml-[28px] settings hint indents
--spacing-accent-bar3pxmax(2px, round(3px * var(--display-scale), 1px)) — floored + roundedw-accent-bar/h-accent-bar, border-l-[length:var(--spacing-accent-bar)]3px priority stripe / drag pill / insertion indicator
--spacing-icon-btn-mini22pxcalc(22px * var(--display-scale))w-icon-btn-mini h-icon-btn-minitable-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-gap16pxcalc(16px * var(--display-scale))gap-card-gapgap between bordered board cards (timeline/pile figure-ground fix)
--spacing-section-gap28pxcalc(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-base12pxcalc(12px * var(--display-scale))consumed via treeIndentStyle(depth, scale)tree depth-0 content indent (5 divergent implementations converge here)
--spacing-tree-indent-step16pxcalc(16px * var(--display-scale))consumed via treeIndentStyle(depth, scale)tree per-depth increment
--spacing-tree-connector1px(FIXED — declared once, no :root override)n/a (JS-positioned)tree guide-line width, never scaled
--spacing-touch-min44pxmax(44px, calc(44px * var(--display-scale))) — 44px floor, only growsmin-h-touch-min/min-w-touch-min, max-mobile:min-h-touch-minraw 44/56 hit areas (iOS HIG touch minimum)
--overlay-offset-top(not a spacing utility — :root only)10vh — FIXED, viewport-relativetop-[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:

ConstantClassesRole
dialogHeaderClsflex items-center justify-between px-3xl py-xl border-b border-edgeDialog header chrome
dialogBodyClspx-3xl py-3xlDialog body chrome
dialogFooterClsflex items-center justify-end gap-md px-3xl py-xl border-t border-edgeDialog footer chrome
menuItemClspx-md py-xs max-mobile:min-h-touch-minTier A floating-menu row (context-menu, dropdown-listbox, kebab, font-picker)
pickerRowClspx-lg py-sm min-h-touch-minTier B search-overlay row (spotlight, command-palette)
toolbarBarClsflex items-center gap-sm px-lg py-smSecondary board/control bar (not the top app toolbar)
chipPadClspx-sm py-2xsChip/pill/badge padding
bannerRowClspx-lg py-sm mb-md bg-bg-altSub-header banner row
hintIndentClsml-hint-indentSettings 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; renders w-close-btn h-close-btn with the glyph inside. Always render it per the Close-X top-right rule (root CLAUDE.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 CSS calc()) 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)).

TokenBaseTailwind
--text-xs13pxtext-xs
--text-sm14pxtext-sm
--text-md15pxtext-md
--text-base16pxtext-base
--text-lg18pxtext-lg
--text-xl22pxtext-xl
--text-2xl24pxtext-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):

AliasMaps toUsage
--color-bg--theme-bg-primarybg-bg — primary background
--color-bg-alt--theme-bg-secondarybg-bg-alt — secondary background
--color-surface--theme-bg-surfacebg-surface — elevated surface
--color-fg--theme-text-primarytext-fg — primary text
--color-fg-muted--theme-text-secondarytext-fg-muted — secondary text
--color-accent--theme-accentbg-accent, text-accent — accent color
--color-accent-subtle--theme-accent-subtlebg-accent-subtle — subtle accent
--color-edge--theme-borderborder-edge — borders
--color-danger--theme-dangertext-danger — error/danger
--color-danger-strong--theme-danger-strongtext-danger-strong — strong danger
--color-info--theme-infotext-info — informational
--color-info-strong--theme-info-strongtext-info-strong — strong informational
--color-on-accent--theme-on-accenttext-on-accent — text on accent bg
--color-overlay--theme-hover-overlaybg-overlay — overlay background
--color-hover--theme-hover-bgbg-hover — hover background
--color-hover-fg--theme-hover-fgtext-hover-fg — hover text
--color-selection--theme-selectionbg-selection — selection highlight
--color-cursor--theme-cursorbg-cursor — cursor color

Layout Tokens

TokenValueIntended surface
--toolbar-heightcalc(52px * var(--display-scale))
--status-bar-height0px (set by JS when visible)
--overlay-bgrgba(0, 0, 0, 0.6)Canonical modal scrim — use via bg-[var(--overlay-bg)]
--overlay-bg-weakrgba(0, 0, 0, 0.3)Non-modal drawer scrims
--overlay-bg-strongrgba(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/ui-components/src/z-index-tokens.ts. Regenerate the CSS with pnpm gen:z-index; validate parity with pnpm check:z-index. The generated block in packages/ui-components/src/tokens.css between GENERATED:Z_INDEX_BEGIN and GENERATED:Z_INDEX_END is never hand-edited.

TokenValuePurpose
--z-content0Default in-flow content
--z-toolbar10Sticky toolbars / sidebar resize
--z-dropdown20In-flow dropdown menus
--z-popover30Inline popovers (not portaled)
--z-popover-portaled40Popovers via portal
--z-mobile-strip40Mobile bottom frame-strip pill bar (shares value with popover-portaled; semantically separate)
--z-modal-backdrop50Modal/drawer backdrop
--z-modal60Modal/drawer foreground
--z-modal-stacked65Modal rendered above another modal (e.g. lightbox over assets dialog)
--z-context-menu68Context menus (above stacked modals so they open from inside dialogs; below toast)
--z-toast70Transient notifications
--z-tooltip80Tooltips (highest UI layer)
--z-ios-offline90iOS offline empty-state screen
--z-dev-badge100Dev-only diagnostic overlays
--z-local-11Child promotion within isolated parent
--z-local-22Child promotion within isolated parent
--z-local-33Child 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.

TokenValueTailwind
--radius-sm3pxrounded-sm
--radius-md4pxrounded-md
--radius-lg6pxrounded-lg
--radius-xl12pxrounded-xl
--radius-dialog0rounded-dialog
--radius-full9999pxrounded-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 any bg-surface/bg-bg box 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 --radius-sm/md/lg — the radius scale still exists, it just stops at the container.

  • --radius-xl remains 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:

RuleDetail
Grip affordanceA grip glyph at the leading edge of the header, before the title
Grip is an iconUse the shared GripIcon component — sized with --spacing-icon-*
Header is the handleThe whole header bar drags; interactive children opt out
Cursorcursor: 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/zudo-doc/src/features.css 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-dialog0 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-4 does NOT work (Tailwind's default 1rem is reset)

  • p-md works (our 8px scaled token)

  • text-red-500 does NOT work (no default color palette)

  • text-danger works (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:

TokenRole
--theme-active-bgBackground of the active/selected item
--theme-active-fgForeground (text/icon) of the active/selected item
--theme-accentOptional 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 (or bg-accent-subtle) as the active background. It is a decorative tint, not the selection token. Exception: bg-accent-subtle is 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-accent or bg-hover + text-fg for 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-accent for 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/color-themes/src/color-settings.ts (the activeBg / activeFg keys). 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:

StateClassLook
active itemborder border-accentsolid accent border
focused itemborder border-dashed border-accentdashed accent border

Rules:

  • Single border owner. The card's own border is the only thing that paints the active/focused cue — never a separate outline. 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 outline for the focus cue. outline-offset-* paints outside the card box, and a scroll/overflow container (overflow-x-autooverflow-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-outline guidance in l-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/timeline-board/src/timeline-card.tsx (isActive ? editing ? "border border-accent" : "border border-dashed border-accent" : edge). Pile View follows the same matrix in tauri-app/renderer/components/pile-view-dialog.tsx.

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

  1. w-[24px] or h-[16px] — Arbitrary px values bypass display-scale. Use a spacing token or calc(Npx * var(--display-scale)).

  2. width={16} height={16} on icons — Use the size prop instead (e.g., <SearchIcon size="md" />). The size prop renders with scaled CSS classes.

  3. gap: 12px in CSS — Use var(--spacing-lg) or Tailwind gap-lg.

  4. font-size: 16px — Use var(--text-base) or Tailwind text-base.

  5. New CSS custom properties without scaling — Any visual dimension must use calc(... * var(--display-scale)).

  6. Using Tailwind's numeric spacing (p-4, m-2, gap-8) — These are reset. Use named tokens (p-xl, m-xs, gap-md).

  7. Using default Tailwind colors (bg-gray-100, text-blue-500) — These are reset. Use semantic aliases (bg-bg, text-accent).

  8. bg-black/40 (or any bg-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 like text-white it leaves the foreground at its inherited/parent color instead of white — neither failure raises a build or runtime error. The retired base/base-alt stems (pre-rename names for bg/bg-alt) are the same class of error — from-base-alt and bg-base shipped 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 on className passes while the browser paints nothing. Use bg-[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 shadeless white/black family (text-white, bg-white, text-black, border-black, … across every color-taking prefix — #4282) and the retired base/base-alt utilities are now blocked by pnpm design-token-lint (repo-scoped prohibitedExtra in .design-token-lint.json). Two traps in how that ban is written: it enumerates concrete utilities like from-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-base does not cover ring-offset-base (both enumerated as of #4275). Do not treat a green design-token-lint as proof a file is clean: the extractor only reads className= / class= attributes, class:list, and cn()/clsx()-style calls, so a component that assembles its classes into a plain variable first (const cardClassName = `…` — the shape kanban-board/src/kanban-card.tsx uses) is never scanned at all. ring-offset-base survived there for exactly that reason, alongside classes as long-banned as bg-black. For those files a context-free grep for the retired stems is the only real check.

  9. A glyph character standing in for an icon (, , ×, as text) — it inherits font-size, so it silently ignores --display-scale and desyncs from every real icon around it as the user zooms. Use an icon component with a size prop. See "Movable panels need a visible affordance" above.

  10. text-base sitting next to a bg-* fill (e.g. bg-accent text-base) — almost always the retired --color-base token (pre-rename name, from the base→bg rename) misused as a color, not the legitimate text-base font-size utility (16px). --color-base no longer exists, so the class silently sets no color at all, leaving inherited/wrong foreground text (#4161, #4279). design-token-lint cannot flag this: text-base is 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 a bg-* fill (misuse → replace with text-on-accent or another semantic foreground alias) versus standing alone as body text sizing (legitimate).

Adding a New Token

  1. Add the base value in the @theme block in tokens.css

  2. Add the scaled override in the :root block with calc(base * var(--display-scale)) — unless the value is FIXED (never scaled, e.g. a hairline or a vh overlay anchor), in which case declare it once, in @theme only, and skip step 2

  3. Use the --spacing- prefix for spacing tokens (enables Tailwind utility generation)

  4. Use the token in components via Tailwind classes or CSS variables

  5. If adding a color: add the Tier 3 alias in @theme mapping to the Tier 2 semantic variable

  6. New 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.

  7. A new --spacing-* token must land in both @theme and :root (unless FIXED) — packages/ui-components/src/__tests__/tokens-spacing-role-parity.test.ts asserts the two key sets match, minus the fixed allowlist.