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 the canonical--radius-*namespace, 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-keycap-edge | 3px | max(2px, round(3px * var(--display-scale), 1px)) — floored + rounded | border-b-[length:var(--spacing-keycap-edge)] | keycap bottom edge; collapses to a hairline while the cap sinks by calc(var(--spacing-keycap-edge) - 1px) |
--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 | 44px — fixed at every display scale | 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-base-0 through --palette-base-4, --palette-accent-0 through --palette-accent-2, and --palette-state-{danger,success,warning,info}
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-keycap-bg, --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-keycap | --theme-keycap-bg | bg-keycap — raised keycap fill (one step lighter than the page in both modes, so a pressed cap can fall back to bg-active) |
--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 numeric z-index values and raw numeric z-N utility classes. z-auto remains a static Tailwind utility. For token tiers, use the custom property in an arbitrary-value utility such as z-[var(--z-dropdown)], or in CSS as z-index: var(--z-dropdown). Do not write values such as z-index: 65 or z-[65].
The only numeric class exception is z-[1] on the mindmap touch-target <span> in packages/. The lint exception is limited to that element with its touch-target data markers, tabIndex={-1}, pointer-events-none, and absolute classes; it exists because the parent has no stacking context for a --z-local-* token. Do not copy this exception to other elements.
Border Radius (NOT scaled)
These are intentionally not scaled with --display-scale.
| Token | Value | Tailwind |
|---|---|---|
--radius | 0.25rem | rounded |
--radius-xs | 0.125rem | rounded-xs |
--radius-sm | 3px | rounded-sm |
--radius-md | 4px | rounded-md |
--radius-lg | 6px | rounded-lg |
--radius-xl | 12px | rounded-xl |
--radius-2xl | 1rem | rounded-2xl |
--radius-3xl | 1.5rem | rounded-3xl |
--radius-4xl | 2rem | rounded-4xl |
--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", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif), --font-mono ("JetBrains Mono", "Fira Code", "Cascadia Code", monospace), and --font-serif (ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif). These are Tailwind v4 --font-* tokens and are not display-scaled.
Font weight: --font-weight-light (300), normal (400), medium (500), semibold (600), and bold (700).
Line-height utilities: --leading-tight (1.25), --leading-normal (1.5), --leading-relaxed (1.625), and --leading-snug (1.375) preserve the values used by Tailwind before the namespace reset. leading-none remains Tailwind's static value of 1.
Direct content ratios: CSS that needs the content ratios uses var(--leading-content-tight) (1), var(--leading-content-normal) (1.5), and var(--leading-content-relaxed) (1.75). The --leading-content-* names are separate from the utility ratios even though they use the canonical --leading-* namespace. The old --line-height-* names are gone.
Letter spacing: --tracking-tighter (-0.05em), --tracking-tight (-0.025em), --tracking-normal (0em), --tracking-wide (0.025em), --tracking-wider (0.05em), and --tracking-widest (0.1em). These tracking-* utilities retain their font-relative values and are not display-scaled.
Transition durations (CSS variables only): --duration-fast (0.1s), --duration-normal (0.15s), and --duration-slow (0.25s). These root-scoped names do not create duration-fast utility classes.
Named shadows: --shadow-dialog (0 20px 60px rgba(0, 0, 0, 0.5), utility shadow-dialog), --shadow-floating-panel (shadow-floating-panel, also used through var() in panel CSS), and --shadow-popover (shadow-popover, also used through var() in popover CSS). The generic shadow, shadow-sm, shadow-md, shadow-lg, and shadow-xl candidates are not available in this token set.
Do not use text-2xs or the removed text-fg-error, text-fg-faint, text-fg-on-accent, and text-fg-warning candidates. They have no emitted utility or project token; in particular, there is no --text-2xs value. The design-token lint and Tailwind utility guard catch source classes that emit no CSS.
Tailwind v4 — Tight Token Strategy
The project resets defaults only in the listed Tailwind namespaces, then adds its named values back in @theme:
@theme {
--spacing-*: initial;
--color-*: initial;
--text-*: initial;
--font-*: initial;
--font-weight-*: initial;
--leading-*: initial;
--tracking-*: initial;
--radius-*: initial;
--shadow-*: initial;
--inset-shadow-*: initial;
--drop-shadow-*: initial;
--breakpoint-*: initial;
}Static utilities and namespaces not reset here remain available. The --spacing base is also restored to 0.25rem, so numeric spacing utilities still compile; prefer the named spacing scale when display-scale behavior is required. For example:
p-4still compiles to 16px from the restored--spacingbasep-mdworks (our8pxscaled token)text-red-500does NOT work (no default color palette)text-dangerworks (our semantic color alias)container-size utilities remain available from Tailwind's imported
--container-*values
Tailwind v4 recognizes --font-*, --leading-*, --tracking-*, and --radius-* for font-family, line-height, letter-spacing, and border-radius utilities. The older-looking --font-family-*, --line-height-*, --letter-spacing-*, and --border-radius-* names do not supply those utilities. See the namespace table in Spacing Tokens & Shared Primitives for the utility-generating prefixes, runtime-only variables, and the max-w-* compatibility aliases.
The namespace normalization preserves the before computed styles; its deliberate visible-change list is empty.
Tailwind Utility Emission Guard (#7046)
Run pnpm check:tailwind-utilities from the repository root. pnpm design-token-lint runs this guard after the design-token lint.
The guard compiles candidates extracted from production .js, .jsx, .ts, and .tsx under tauri-app/renderer/** and packages/*/src/**. It covers bare rounded and shadow plus the rounded-*, leading-*, tracking-*, shadow-*, text-*, max-w-*, font-*, and z-* families, including supported negative forms, found in class props, class strings, helper calls, and class-bearing object properties. It skips node_modules, dist, tests, stories, fixtures, generated output, and declaration files. It also checks every original @theme declaration in packages/ and packages/ for the legacy --line-height-*, --letter-spacing-*, --border-radius-*, and --font-family-* prefixes, including unused declarations.
Each current production candidate must emit CSS. Previously audited candidates are compared against the immutable styleguide/ semantics, and the guard checks the SHA-256 digests of both that file and token-computed-baseline.json against token-namespace-decisions.json. Those before-evidence files are provenance: do not overwrite them. The guard also checks that production leading-* utilities do not fall through to --spacing-* and that max-w-md resolves through --container-md rather than the same-suffix spacing value. Its leading-md candidate is a diagnostic probe only; it intentionally demonstrates the 8px spacing fallback and must not appear in production source.
The comparison allows a new candidate when Tailwind emits it and it passes the collision checks, as well as a historical candidate whose last source use has been removed. It does not freeze candidate or occurrence counts. The migration's 43 dead-class removals and 65 z-index replacements are one-time migration evidence, not permanent count targets.
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.
Selected / Current Card: Solid Frame + Inner State Rings
Timeline, Grid and Pile mark their selected/current card with the shared Solid Frame and paint every other card state as a separate inner ring. All of them are layers from packages/:
| Layer | Component | Look | Meaning |
|---|---|---|---|
| Selected / current | SelectionFrame | continuous 3px border-fg frame, inset 1px (--spacing-selection-frame) | Timeline: selected card. Grid: selected note. Pile: the invoking tray's current note (pinned for the session). |
| Actual focus | SelectionInnerRing variant="solid" | 2px accent ring, inset 7px (--spacing-focus-ring-inset) | Timeline: actual CodeMirror focus inside an open editor (several editors may stay open). Grid/Timeline: keyboard focus on the card itself (group-focus-visible). |
| Browsing cursor | SelectionInnerRing variant="dashed" | 2px dashed accent ring, inset 9px (--spacing-cursor-ring-inset) | Pile only: the arrow-key/hover cursor, which starts on the current note and then moves on its own. |
Rules:
The card box never changes. Cards keep
border border-edgeand their normal surface (bg-surface, neverbg-active) in every state. The layers areabsolute,pointer-events-none,aria-hidden, add no tab stop, and the host isrelative(plusisolatewhen its content has its own z-index, e.g. CodeMirror gutters) — so selection causes zero layout shift.States stack; nothing "wins". When one card is current, focused and the cursor, draw the frame and the ring together. Do not fold one state into another.
Foreground frame, accent rings. The frame is
border-fg, not accent: it marks identity and stays a real border in forced colors and grayscale. Rings are accent.Own the focus paint. A card that suppresses its native outline (
outline-none) must render aSelectionInnerRingwithvisibleorgroup-focus-visible:block, so keyboard focus is never invisible. Never use an offsetoutlinefor card state: a scroll container (overflow-x-auto⇒overflow-y: auto) clips its top/bottom edges. Inner layers keep all four edges visible.Assert the layer contract, not class strings. Tests check
[data-selection-frame]/[data-selection-ring][data-visible]children and the host's data/ARIA state (aria-current,data-current,data-cursor,data-timeline-selected,data-editor-focused).
Canonical implementations: packages/, tauri- (single-action button), tauri- (PileCardItem).
This is distinct from the bg/fg "Active State" highlight above — that is for list/nav rows and expanded title rows; this is for card surfaces.
Note Tabs (text-editor Note Tray)
Collapsed notes in the text editor's Note Tray are connected tabs (packages/, tabCls / capCls / TAB_CAP_PLACEMENT), not filled pills:
Each tab is a fixed row across the bar (
--spacing-note-tab-row, 56px) that is its whole hit area. A decorative cap keeps an 8px (md) clearance on both depth sides (40px idle), and the 24px identity box (h-draft-btn) sits on the row's centerline.Horizontal top/bottom tabs keep a scale-aware 8px visible cap gap: 2px cap inset per side + 4px flex gap. Buttons use 12px horizontal padding and a 48px minimum width; vertical rails keep their existing geometry.
The selected cap drops the document-side clearance and edge, fills with the document background (
bg-bg), and paints over the 2px baseline layer (data-testid="note-tab-baseline") so it joins the document. Idle caps use the tray fill (bg-bg-alt) with a 1px edge (2px on hover). The label never moves.Placement mirrors (bottom) or rotates (left/right) the depth axis. The baseline is a layer, not a border, because a scroll container cannot paint over its parent's border.
Keyboard focus paints on the identity box (
group-focus-visible/tab:outline-*), inside the row's clearance, so the strip never clips it.Expanded title rows on a rail are not tabs; they keep the list Active State.
Note Stickers
The Note Tray offers an independently hideable, default-visible icon-only Sticker action in its tab-bar actions at every width and placement. It opens the existing inline form. The closed editor has no duplicate identity band; only an outside-filter notice or the open form reserves space above the text.
NoteSticker paper is always bg-accent text-on-accent with its fold and small shadow. Selection, focus, editing, hover and the cursor never invert, fade or recolor it, and no surface it sits on is accent-filled any more. In NoteIdentity the real number is shrink-0 and never truncates; the sticker truncates first.
Buttons
Four button components, picked by what the surface around them already reads as:
| Component | File | Use for |
|---|---|---|
Button | packages/ | Dialog/form actions. Variants primary | secondary | ghost | danger | danger-ghost, sizes sm | md, an active prop. |
IconButton | packages/ | A single icon action. toolbar variant is transparent — for a row that is itself already visibly a toolbar. square variant is bordered (border border-edge bg-surface) — for a standalone icon action that is NOT inside an obvious toolbar row. |
DialogCloseButton | packages/ | The dialog-header close (X) only — see the Close-X top-right rule (root CLAUDE.md § Adding a New Dialog). |
Keycap (appearance="keycap" on PaletteKeyButton) | tauri- | Touch key pads / grids of same-weight actions — the mobile More-tools panel (Keys tab, favorite slots, App/Frame tiles). Backed by bg-keycap (--color-keycap → --theme-keycap-bg) and the --spacing-keycap-edge bottom edge (#6676). The desktop edit-key bar keeps the quiet toolbar look (appearance="quiet"), not keycaps. |
The edge rule: a button must have a visible edge or fill at rest unless it sits inside a container that already reads as a toolbar row. A grid of bare glyphs is not a set of buttons — that gap (the mobile More tools panel before #6676) is exactly the defect this rule exists to prevent. Concretely: a lone IconButton sitting on a plain background needs variant="square" (or the keycap treatment on touch); it never sits there with variant="toolbar" and no surrounding chrome that already signals "this row is a toolbar".
Filled accent (bg-accent text-on-accent — Button's primary variant) is reserved for isolated primary controls (the launcher, draft-bar pills) — never for list selection. See "Active State" above for the selection tokens; do not reach for filled accent there.
Every button state comes from the same tokens regardless of which component renders it:
| State | Tokens |
|---|---|
| Hover | bg-hover text-hover-fg |
| Pressed | bg-active text-active-fg |
| Disabled | opacity-40 |
| Focus | focusRingInsetCls (@takazudo/ui-components) |
Sizes come from the button size tokens documented above: --spacing-icon-btn-square (32px square IconButton), --spacing-icon-btn-mini (22px table-helper buttons), --spacing-close-btn (28px DialogCloseButton), and --spacing-touch-min (fixed 44px at every scale). Buttons keep the radius scale (-) — only dialog/panel surfaces are square (see "Dialog and panel surfaces are SQUARE" above).
New toolbar buttons must be hideable. Register them in TOOLBAR_SURFACES (packages/) and guard them with useToolbarContextMenu — see root CLAUDE.md § Feature Implementation Checklist, "If the feature adds a toolbar button".
Context menus
Every new interactive surface ships with a designed context menu. Right-click must never fall through to the native WebKit menu (Reload / Inspect Element) on app chrome. The desktop fallback described below only hides the native menu as a last resort — it is a safety net, not a substitute for designing one.
Use the shared ContextMenu + useContextMenu (packages/, use-context-menu.ts) for any menu that needs groups, checked rows, or a submenu. Never reach for ToolbarKebabMenu (packages/) for that case — it is a flat single-select list with no separators/labels/checkbox rows.
useContextMenu exposes two openers that share one item-builder function:
openFromEvent(event, context?)— right-click (and the ContextMenu key / Shift+F10, which browsers dispatch as the samecontextmenuevent).openAtPoint(x, y, context?, trigger?)— a…button. Read the trigger'sgetBoundingClientRect()and pass itsleft/bottom; pass the trigger element itself astriggerso focus returns to it on close. Seetauri-for the full pattern, including the click-to-toggle handling viaapp/ renderer/ components/ settings/ device- management/ device- context- menu. tsx menuProps.open/close()andignoreRef.
Grouping: a { type: "label" } heading plus { type: "separator" } between groups. Actions of a different kind never share a group without a separator — e.g. "Configure palette…" (settings) is its own group, separate from placement actions. A current choice among alternatives (e.g. which placement is active) is a { type: "checkbox" } checked row via onToggle/checked — never a set of aria-pressed buttons standing in for a menu.
One ContextMenu per surface. The component is a single-instance registry (module-level — opening a second root menu evicts the first), so a list of rows (dashboard widgets, kanban cards, …) shares ONE ContextMenu instance rather than mounting one per row — see tauri-. A surface that already owns a right-click menu and also wants the toolbar customize entries merges visibility.menuItems from useToolbarContextMenu into its own item list instead of rendering that hook's menu (a second ContextMenu would evict the first) — see packages/ / tauri-.
Menu rows use menuItemCls (Tier A, see the shared class constants table above); the panel z-index is --z-context-menu.
Keep-native list — deliberate, must stay native, do not add a custom menu here:
Every
<input>/<textarea>/<select>/[contenteditable].Any non-collapsed text selection, anywhere.
The editor when
AppSettings.editor.nativeContextMenuis on.Touch devices on the editor and preview panes — the native long-press callout is their only copy affordance.
The slide-deck preview.
The terminal idle area.
This is the epic #6674 keep-native inventory, enforced app-wide by the desktop fallback in tauri- (shouldSuppressNativeContextMenu / installNativeContextMenuSuppressor): one document-level bubble contextmenu listener that defers to event.defaultPrevented (so it never races a surface's own menu) and exempts the list above plus dev builds (so Inspect Element keeps working). A [data-native-context-menu] wrapper is the generic per-element opt-out for a surface that wants the native menu without owning a contextmenu handler.
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 compile from the restored--spacing: 0.25rembase (p-4is 16px), but do not follow--display-scaleand are flagged bypnpm design-token-lintas numeric spacing. 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:.design-token-lint.jsonsetsscanClassVariables: true, so the extractor also scans literal class strings and supported literal ternaries inconst/let/vardeclarations whose names containclass(case-insensitive). It still misses class variables whose names do not containclass, computed, concatenated or runtime-built class strings, and content excluded bydesign-token-lint-ignorecomments. For those gaps a context-freegrepfor the retired stems remains 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).Never write a comment-closing
*/inside CSS comment prose — e.g. spelling out two token globs back-to-back as--text-*/--spacing-icon-*in a comment closes the comment at that*/, silently stranding every following declaration up to the next real*/as invalid CSS text (#6563: this dropped--overlay-bg,--overlay-offset-top,--display-scale, and every scaled--spacing-*override fromtokens.css's:rootblock). Write "--text-*and--spacing-icon-*" instead of--text-*/--spacing-icon-*, or otherwise put a space or word between any*and/in comment text.
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