| name | zudo-doc-design-system |
| description | Project-specific CSS and component rules for zudo-doc, including the Composer Polish epic's non-negotiables — accent budget (≤2-3 accent elements per viewport, hover always neutral), border ladder, 14px functional-text floor. Must be consulted before writing or editing CSS, Tailwind classes, color tokens, or component markup in this project. Covers: accent budget, component-first strategy, design token system, three-tier color architecture, and palette index convention. Triggered by 'design system', 'zudo-doc-design-system', 'zudo-doc-css-wisdom' (old name), 'accent budget'. |
| user-invocable | true |
| argument-hint | [topic: tokens, colors, component-first, palette, accent-budget] |
zudo-doc CSS & Component Rules
IMPORTANT: These rules are mandatory for all code changes in this project that touch CSS, Tailwind classes, color tokens, or component markup. Read the relevant section before making changes.
Non-Negotiables (read first)
- Accent budget: ≤2–3 accent elements per viewport at rest; most viewports 0–1. Max ONE filled-accent element per viewport.
- Hover is always neutral (fg color-mix / brightness) — never accent, even on editing-affordance chrome (resizers, handles, insert markers).
- Editing-affordance chrome (insert markers, handles, resizers, guides) is muted/neutral at rest; accent appears only at the point of interaction (hover/focus/drag).
- Borders come from the border ladder (
--color-border / --color-border-strong) — NEVER --color-muted, which is a text token, not a border token.
- Functional text ≥
--text-caption (14px). --text-micro (12px) is for true meta only (counts, timestamps) — never uppercase + letter-tracked at 12px.
- Accent role whitelist (closed): focus ring, selection outline, pressed/selected small controls (
aria-pressed/aria-current), one primary CTA. Everything not listed is neutral by default.
Named failure mode: the vivid-amber accent funnel — selection, focus, hover, chips, insert markers, and resizers all painted the same orange. When in doubt: neutral, bordered, 14px.
How to Use
This project has two parallel token worlds — read the one relevant to the file you're
touching (see "Two Worlds" below for which is which):
| Topic | File |
|---|
Doc-chrome tokens (root host: --zd-* palette, text-fg/bg-surface/etc., border ladder), Tailwind @theme | src/styles/global.css |
Shared spacing (hsp-*/vsp-*) + typography (Tier 1/2 --text-*) tokens | packages/ui/styles/tokens.css |
@zudo-sg/ui semantic colors (ink/paper/surface/line/brand/state), three-tier color system, consumption model | packages/ui/styles/colors.css, packages/ui/STORIES.md (§1 "How the package is consumed", §"Three-tier color system") |
| Component-first / server-rendered-by-default methodology | root CLAUDE.md ("Components" section) |
Read ONLY the file(s) relevant to your task. Apply their rules strictly. (There is no
src/content/docs/reference/ directory in this repo — those files don't exist; the rules
below plus the source files above are the source of truth.)
Quick Rules (always apply)
Component First (no custom CSS classes)
- NEVER create CSS module files, custom class names, or separate stylesheets
- ALWAYS use Tailwind utility classes directly in component markup
- The component itself is the abstraction —
.card, .btn-primary are forbidden
- Use props for variants, not CSS modifiers
Design Tokens (no arbitrary values)
- NEVER use Tailwind default colors (
bg-gray-500, text-blue-600) — they are reset to initial
- NEVER use arbitrary values (
text-[0.875rem], p-[1.2rem]) when a token exists
- NEVER use hardcoded hex values in components
- Spacing (shared everywhere — root,
packages/ui, apps/demo): hsp-* (horizontal),
vsp-* (vertical), 7-8 steps 2xs/3xs→2xl. Defined once in packages/ui/styles/tokens.css.
- Typography (shared, same file): Tier 1 abstract sizes
text-xs…text-2xl (each with a paired
line-height), plus Tier 2 semantic aliases text-micro/caption/small/body/heading/display
(each a var() onto a Tier-1 rung). Which tier to use depends on which world you're in — see below.
Two Worlds: doc-chrome vs. @zudo-sg/ui components
This monorepo has two independent semantic color layers sharing the one spacing/typography
token file above. Know which world the file you're editing belongs to:
- Doc-chrome world (root-only:
src/**, pages/** — the styleguide host's own header,
sidebar, search, doc prose, panels): colors come from src/styles/global.css's --zd-* →
--color-* mapping. Utilities: text-fg, bg-surface, border-muted, text-accent, plus the
raw p0–p15 palette. Doc prose (.zd-content) consumes the Tier-2 typography aliases
(text-body, text-caption, ...) via @takazudo/zudo-doc's content.css.
- UI-component world (
packages/ui/src/**, also consumed by apps/demo and by the root's
/components/* catalog): colors come from packages/ui/styles/colors.css's semantic tokens —
bg, surface/surface-2, border, fg/muted, accent/accent-hover, on-accent,
focus, success/danger/warning/info, loading-scrim, and the persistent-dark-nav
rail-* family (rail-bg, rail-bg-strong, rail-fg, rail-muted, rail-border,
rail-hover-bg — intentionally NOT a light-dark() pair; it stays dark in both schemes).
All ~70 components use the Tier-2 semantic typography aliases directly (text-body,
text-title, text-caption, ...) — this is the inverse of the doc-chrome convention, and the
inverse of what this section used to say before the port: there is no longer any component
in this package using the Tier-1 abstract sizes (text-sm, text-lg, ...) directly. See
packages/ui/styles/tokens.css's header comment for the two-tier rationale.
- Ten token exist in both worlds (, , , , ,
, , , , — widened from an original four when
the UI palette adopted these names). On root-host pages the doc-chrome block in
re-asserts all ten to the values (source order wins), so a root-rendered
component still matches the docs palette. In (no re-assertion), the
same ten names resolve to 's own values. The remaining UI-only names
(, , , , the family) are NOT re-asserted —
doc-chrome consumes none of them. (Composer Polish S1, #263):
doc-chrome now defines its own / as LOCAL
tokens in — not a re-assertion of the UI package's value (that was an accidental
leak via import order, now closed off), but a deliberate first-class border-ladder addition
consumed by the composer chrome and the styleguide catalog (regular docs pages
still don't use it — see the border ladder table below). Never assume a color utility means the
same thing in both worlds — check which file you're in.
Color Tokens (three-tier system)
Both worlds follow the same three-tier shape (palette → semantic → component), just with
different concrete tokens:
- Doc-chrome (
src/styles/global.css):
- Tier 1 (palette):
p0–p15 — raw colors, use only when no semantic token fits
- Tier 2 (semantic):
text-fg, bg-surface, border-muted, text-accent — prefer these
- Palette index convention (consistent across all schemes, see
src/config/color-schemes.ts):
- p1=danger, p2=success, p3=warning, p4=info, p5=accent
- p8=muted, p9=background, p10=surface, p11=text primary
- UI-component (
packages/ui/styles/colors.css):
- Tier 1 (
--palette-{group}-{n}): raw oklch values, GROUPED by role family —
base (warm-neutral grayscale ramp), accent (amber action ramp), state
(danger/success/warning/info), and a line-* ramp per business line. Plain
:root vars (not @theme, so no bg-palette-* utility is ever generated) —
never referenced by components directly
- Tier 2 (
--color-*): semantic roles, each a light-dark() pair of Tier-1 refs — this is
what components bind to (bg-accent, text-fg, ...)
- Full contract + rationale:
packages/ui/STORIES.md §"Three-tier color system"
Border ladder + composer panel tokens (Composer Polish S1, #263)
Structural chrome tokens, co-located with the color tables above — defined in src/styles/global.css, and mirrored in src/styles/preview.css for the preview iframe (which has no --zd-* injected):
| Token | Role |
|---|
--color-border | Sitewide hairline border — the border-ladder default. Never substitute --color-muted (a text-color token) for a border. |
--color-border-strong | Major divisions (e.g. a bordered modal/panel edge) — one step up from --color-border. |
--sg-composer-panel-bg | Composer-scoped panel surface — tonally distinct from the canvas backdrop (~ΔL 0.045 lighter, both modes); the "page sheet" the composition floats on. |
--sg-composer-canvas-backdrop | Composer-scoped canvas background the panel sheet sits over. |
--sg-composer-guide | Composer-scoped quiet-chrome tone — the resting (non-accent) color for insert markers, handles, resizers, drag guides. |
--color-border / --color-border-strong are consumed by the composer chrome and the /components styleguide catalog. The panel/backdrop/guide trio is composer-scoped by consumption — only .sg-composer-* / .zc-* chrome binds them; regular docs pages keep the pre-epic "minimized palette" look (surface==bg) untouched.
Accent budget: contrastive examples (real diffs, Composer Polish epic #262)
The pre-epic composer painted selection, focus, hover, chips, insert markers, and resizers all the same orange — 20 orange elements at rest inside the preview iframe alone. These are real before/after diffs from the epic; match the RIGHT column's idiom, not the WRONG one.
Resizer hover/drag (src/features/composer/styles.css) — hover is not a whitelisted accent role:
.sg-composer-resizer:hover { background: color-mix(in oklch, var(--color-accent) 30%, transparent); }
.sg-composer-resizer[data-sg-dragging] { background: color-mix(in oklch, var(--color-accent) 30%, transparent); }
.sg-composer-resizer:hover { background: color-mix(in oklch, var(--color-fg) 8%, transparent); }
.sg-composer-resizer[data-sg-dragging] { background: color-mix(in oklch, var(--color-fg) 14%, transparent); }
Insert markers at rest (src/features/composer/preview/preview-styles.ts) — the single biggest at-rest orange source:
.zc-insert { border: 1px dashed transparent; color: var(--color-accent); }
.zc-insert { border: 1px dashed var(--sg-composer-guide); color: var(--sg-composer-guide); opacity: 0.5; }
.zc-insert:hover, .zc-insert:focus-visible { color: var(--color-accent); opacity: 1; }
Tree slot-group label (src/features/composer/styles.css) — 12px meta text does not need shouting to read as a label:
.sg-composer-tree-slot-header { font-size: var(--text-micro); text-transform: uppercase; letter-spacing: 0.02em; }
.sg-composer-tree-slot-header { font-size: var(--text-micro); }
Measured ground truth (accent census, #270 confirm pass)
These numbers are measured on the shipped composer (#270 confirm pass), not estimates — cite them as ground truth for the accent budget:
- At rest (nothing selected): iframe orange census = 2, chrome accent census = 1.
- Realistic default (app auto-selects the root node on load): iframe = 4, chrome = 2 — the extras are the whitelisted selection outline + label inside the iframe, and the danger-red Remove control in the chrome.
Search & highlight tokens (role-split)
Highlight roles are deliberately split across dedicated semantic tokens — do not share one token across unrelated highlight UIs.
matched-keyword-bg / matched-keyword-fg — background and foreground of the search panel <mark> element. Driven by --color-matched-keyword-bg / --color-matched-keyword-fg; live-editable in the Design Token Panel. This is the single source of truth for "why is this color yellow in the search results" — the panel swatch matches the rendered highlight 1:1.
warning — drives admonitions (:::warning), find-in-page (.find-match, .find-match-active), and any UI that is semantically a warning. Do not reuse it for new UI-chrome highlights.
Rule: when a new highlight role appears (new kind of mark, new pill, new callout), add a dedicated semantic token rather than bolting it onto --color-warning or another existing token. Each visible highlight color should map to exactly one panel swatch.
Hover-state underline for link-like elements
Any element that navigates (rendered as <a href> or behaves as a link) MUST have hover:underline focus-visible:underline. Keyboard users need the same affordance as mouse users — never add hover:underline without the focus-visible:underline pair.
- Links (do underline): doc content links, sidebar items, header main-nav, header overflow menu items, color-tweak panel unselected tabs, search result rows, footer links, doc history entries, breadcrumb trails, mobile TOC entries.
- Controls (do NOT underline): buttons, toggles, sidebar resizer, palette selectors, color swatches, close icons. These use border/bg hover instead.
Precedents to copy the pattern from: pages/lib/_search-widget-script.ts (search result rows use group-hover:underline group-focus-visible:underline).
See also: /css-wisdom for light-mode / dark-mode contrast rules and the broader three-tier token strategy.
Server-rendered Preact vs client islands
- Default to server-rendered Preact
.tsx — emits zero JS. See root CLAUDE.md's
"Components" section for the canonical rule.
- Promote to a client island only when interactivity is needed: mark the module
"use client" and mount it via zfb's <Island> wrapper (see pages/lib/_body-end-islands.tsx)
- Both follow the same utility-class approach