zudo-doc-design-system
Project-specific CSS and component rules for the zudo-sg styleguide: accent budget (≤2-3 accent elements per viewport, hover neutral by default), border ladder, 14px functional-text floor, component-f...
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 neutral by default (fg color-mix / brightness) — do not spend accent merely to indicate pointer hover.
Interactive chrome (handles, resizers, guides) is muted/neutral at rest; reserve accent for the closed role whitelist below.
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, and structural chrome 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/ |
Shared spacing (hsp-*/vsp-*) + typography (Tier 1/2 --text-*) tokens | packages/ |
@zudo-sg/ui semantic colors (ink/paper/surface/line/brand/state), three-tier color system, consumption model | packages/, packages/ (§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/ 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-primaryare forbiddenUse 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 toinitialNEVER use arbitrary values (
text-[0.875rem],p-[1.2rem]) when a token existsNEVER use hardcoded hex values in components
Spacing (shared everywhere — root,
packages/ui,apps/demo):hsp-*(horizontal),vsp-*(vertical), 7-8 steps2xs/3xs→2xl. Defined once inpackages/.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 aliasestext-micro/caption/small/body/heading/display(each avar()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 fromsrc/'sstyles/ global. css --zd-*→--color-*mapping. Utilities:text-fg,bg-surface,border-border,text-accent, plus the rawp0–p15palette. Doc prose (.zd-content) consumes the Tier-2 typography aliases (text-body,text-caption, ...) via@takazudo/zudo-doc'scontent.css.UI-component world (
packages/, also consumed byui/ src/ ** apps/demoand by the root's/catalog): colors come fromcomponents/ * packages/'s semantic tokens —ui/ styles/ colors. css bg,surface/surface-2,border,fg/muted,accent/accent-hover,on-accent,focus,success/danger/warning/info,loading-scrim, and the persistent-dark-navrail-*family (rail-bg,rail-bg-strong,rail-fg,rail-muted,rail-border,rail-hover-bg— intentionally NOT alight-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. Seepackages/'s header comment for the two-tier rationale.ui/ styles/ tokens. css Ten token names exist in both worlds (
bg,fg,surface,muted,accent,accent-hover,success,danger,warning,info— widened from an original four when the UI palette adopted these names). On root-host pages the doc-chrome@themeblock inglobal.cssre-asserts all ten to the--zd-*values (source order wins), so a root-rendered@zudo-sg/uicomponent still matches the docs palette. Inapps/demo(no re-assertion), the same ten names resolve to@zudo-sg/ui's own values. The remaining UI-only names (surface-2,focus,on-accent,loading-scrim, therail-*family) are NOT re-asserted — doc-chrome consumes none of them.borderis the one exception: doc-chrome now defines its own--color-border/--color-border-strongas LOCAL@themetokens inglobal.css— 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/styleguide catalog (regular docs pages still don't use it — see the border ladder section below). Never assume a color utility means the same thing in both worlds — check which file you're in.components The chrome-free
/iframe document (components/ preview <html data-sg-preview-doc>) has no--zd-*injected, sosrc/restores the overlappingstyles/ preview. css @zudo-sg/uisemantic colors that the root-host re-assertion above would otherwise leave undefined. It is not a standalone entrypoint:global.cssimports it into the one site-wide stylesheet. Its rules are scoped tohtml[data-sg-preview-doc](specificity beats the:rootthe@themeblock emits, so it's order-independent and never affects regular doc-chrome pages).
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 fitsTier 2 (semantic):
text-fg,bg-surface,border-border,border-border-strong,text-accent— prefer thesePalette 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 aline-*ramp per business line. Plain:rootvars (not@theme, so nobg-palette-*utility is ever generated) — never referenced by components directlyTier 2 (
--color-*): semantic roles, each alight-dark()pair of Tier-1 refs — this is what components bind to (bg-accent,text-fg, ...)Full contract + rationale:
packages/§"Three-tier color system"ui/ STORIES. md
Border ladder
The complete structural border ladder is defined in src/. The component-preview scope in src/ restores only the provider's --color-border value after the host re-assertion; it is not a second token source and does not mirror --color-border-strong:
| 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 (for example a bordered modal or panel edge) — one step up from --color-border. |
Use the default border for catalog cards, panel dividers, and quiet structural separation. Use the strong border only when the hierarchy genuinely needs a second rung. Never substitute --color-muted, because it is a text color.
Accent budget in the styleguide
Audit the actual viewport at rest. Catalog cards, code-panel dividers, resizers, search results, and token controls should normally use neutral foreground, surface, and border roles. Accent is reserved for focus, selection/current state, pressed controls, and the primary action. A hover-only state does not qualify; use a neutral foreground mix or border-strength change instead.
Functional labels stay at --text-caption or larger. --text-micro is only for true metadata such as counts or timestamps, and must not be combined with uppercase letter-spacing to simulate importance.
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/ (search result rows use group-hover:underline group-focus-visible:underline).
See also: / 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 rootCLAUDE.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 (seepages/)lib/ _ body- end- islands. tsx Both follow the same utility-class approach