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 CSS — calc(Npx * var(--display-scale, 1))

  3. 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 blurry

  • border-radius — defined in the canonical --radius-* namespace, 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-keycap-edge3pxmax(2px, round(3px * var(--display-scale), 1px)) — floored + roundedborder-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-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-min44px44px — fixed at every display scalemin-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-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):

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-keycap--theme-keycap-bgbg-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-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 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/mindmap-board/src/mindmap-node.tsx. 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.

TokenValueTailwind
--radius0.25remrounded
--radius-xs0.125remrounded-xs
--radius-sm3pxrounded-sm
--radius-md4pxrounded-md
--radius-lg6pxrounded-lg
--radius-xl12pxrounded-xl
--radius-2xl1remrounded-2xl
--radius-3xl1.5remrounded-3xl
--radius-4xl2remrounded-4xl
--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", -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-4 still compiles to 16px from the restored --spacing base

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

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

  • text-danger works (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/ui-components/src/tailwind.css and packages/ui-components/src/tokens.css 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/migration/token-emission-baseline.json 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:

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.

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/ui-components/src/selection-frame.tsx:

LayerComponentLookMeaning
Selected / currentSelectionFramecontinuous 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 focusSelectionInnerRing 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 cursorSelectionInnerRing 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-edge and their normal surface (bg-surface, never bg-active) in every state. The layers are absolute, pointer-events-none, aria-hidden, add no tab stop, and the host is relative (plus isolate when 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 a SelectionInnerRing with visible or group-focus-visible:block, so keyboard focus is never invisible. Never use an offset outline for 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/timeline-board/src/timeline-card.tsx, tauri-app/renderer/components/shared/draft-grid.tsx (single-action button), tauri-app/renderer/components/pile-view-dialog.tsx (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/ui-components/src/draft-bar.tsx, 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:

ComponentFileUse for
Buttonpackages/ui-components/src/button.tsxDialog/form actions. Variants primary | secondary | ghost | danger | danger-ghost, sizes sm | md, an active prop.
IconButtonpackages/ui-components/src/icon-button.tsxA 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.
DialogCloseButtonpackages/ui-components/src/dialog-close-button.tsxThe 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-app/renderer/components/edit-key-bar/palette-key-button.tsxTouch 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:

StateTokens
Hoverbg-hover text-hover-fg
Pressedbg-active text-active-fg
Disabledopacity-40
FocusfocusRingInsetCls (@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 (--radius-sm/md/lg) — 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/app-defaults/src/toolbar-surfaces.ts) 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/ui-components/src/context-menu.tsx, use-context-menu.ts) for any menu that needs groups, checked rows, or a submenu. Never reach for ToolbarKebabMenu (packages/ui-components/src/toolbar-kebab-menu.tsx) 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 same contextmenu event).

  • openAtPoint(x, y, context?, trigger?) — a … button. Read the trigger's getBoundingClientRect() and pass its left/bottom; pass the trigger element itself as trigger so focus returns to it on close. See tauri-app/renderer/components/settings/device-management/device-context-menu.tsx for the full pattern, including the click-to-toggle handling via menuProps.open / close() and ignoreRef.

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-app/renderer/components/dashboard/dashboard-context-menu.tsx. 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/app-defaults/src/toolbar-surfaces.ts / tauri-app/renderer/components/toolbar-customize-menu.tsx.

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.nativeContextMenu is 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-app/renderer/lib/suppress-native-context-menu.ts (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

  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 compile from the restored --spacing: 0.25rem base (p-4 is 16px), but do not follow --display-scale and are flagged by pnpm design-token-lint as numeric spacing. 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: .design-token-lint.json sets scanClassVariables: true, so the extractor also scans literal class strings and supported literal ternaries in const/let/var declarations whose names contain class (case-insensitive). It still misses class variables whose names do not contain class, computed, concatenated or runtime-built class strings, and content excluded by design-token-lint-ignore comments. For those gaps a context-free grep for the retired stems remains 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).

  11. 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 from tokens.css's :root block). 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

  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.