zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Color

Created Mar 13, 2026Updated Oct 9, 2026Takeshi Takatsudo

zudo-doc's three-tier color strategy, ramp-native color model, color schemes, and customization.

zudo-doc uses a three-tier color strategy. theme.css defines ordinary custom-property aliases; the zudo-wind preset registers their utility names with var(...) values. Later :root declarations override values through the CSS cascade.

Three-Tier Color Strategy

Colors are organized into three tiers. Each tier only references the tier above it:

TierNamePurposeDefined In
1RampsShared OKLCH color ramps a scheme is built fromyour colorSchemes override in zfb.config.ts (optional — packaged defaults otherwise) → ColorSchemeProvider → :root
2SemanticDesign meaning — what each color representspackaged @takazudo/zudo-doc/theme.css :root (a project's later :root block can override it)
3ComponentScoped overrides for specific components.zd-content (shared content.css)

This layering means you can:

  • Retune a ramp stop (override the colorSchemes field in zfb.config.ts) → every semantic role built from it updates

  • Remap a semantic role (e.g. point accent at a different ramp stop) → every component using accent updates

  • Override a component token without affecting other components

Tier 1: Ramps

zudo-doc's color engine is ramp-native: a ColorScheme is { ramps, map } — the type ships from @takazudo/zudo-doc/color-scheme-utils.

  • ramps — the shared source of truth: a warm-neutral base ramp (5 stops, index 0 = lightest), an accent ramp (3 stops), and 4 state colors (danger, success, warning, info). Light and dark modes share these ramp values.

  • map — the per-mode wiring: which ramp stop (or literal OKLCH override) each UI role points at.

The shared ramps

RampStopsCSS custom properties
base5 — index 0 (lightest) → 4 (darkest)--palette-base-0 … --palette-base-4
accent3--palette-accent-0 … --palette-accent-2
state4 named roles--palette-state-danger, --palette-state-success, --palette-state-warning, --palette-state-info

Default Light and Default Dark — the only two bundled schemes — share the exact same base/accent/state ramp values (one ramps object in the package's @takazudo/zudo-doc/color-schemes-defaults); only the per-mode map differs between them.

Note

Minimizing the ramp size is deliberate: 5 base stops and 3 accent stops leave no room for a "spare" near-white or near-black filler tone. Several semantic roles are intentionally merged onto the same stop instead of each getting their own — see Role-merge philosophy below.

RampRef — how a role points at a ramp stop

type RampRef =
  | { base: number }     // ramps.base[n]
  | { accent: number }   // ramps.accent[n]
  | { state: StateRole } // ramps.state[role]
  | string;              // a literal OKLCH value, used as-is

A scheme's map uses a RampRef for its 4 base roles (bg, fg, selectionBg, selectionFg) and all 23 semantic roles. Most roles reference a shared ramp stop; a handful carry a literal OKLCH string instead — used when a role needs a color no shared stop can produce, typically a per-mode AA-contrast tune. See Per-mode literal overrides.

How ramps are injected

The ColorSchemeProvider component (packages/zudo-doc/src/theme/color-scheme-provider.tsx) reads the active scheme and injects CSS custom properties on :root at build time — the resolved base roles, the bare ramp stops, and the 23 resolved semantic roles:

:root {
  --zd-bg: oklch(.185 .005 65);
  --zd-fg: oklch(.965 .004 65);
  --zd-selection-bg: oklch(.480 .008 65);
  --zd-selection-fg: oklch(.965 .004 65);

  --palette-base-0: oklch(.965 .004 65);
  --palette-base-1: oklch(.705 .008 65);
  /* ... through --palette-base-4 */
  --palette-accent-0: oklch(.755 .130 64);
  /* ... through --palette-accent-2 */
  --palette-state-danger: oklch(.640 .170 25);
  --palette-state-success: oklch(.680 .145 145);
  --palette-state-warning: oklch(.760 .135 82);
  --palette-state-info: oklch(.680 .130 245);

  --zd-accent: oklch(.700 .158 62);
  --zd-code-bg: oklch(.300 .006 65);
  /* ... 21 more --zd-{role} semantic properties */
}

The --zd-* properties (base roles + semantic roles) are the source of truth everything downstream resolves back to. The bare --palette-* ramp properties exist for the Design Token Panel's Palette tab and for scheme authors — content and components consume the semantic --zd-* tokens; there is no zudo-wind utility for a raw ramp stop (see Reaching a raw ramp stop).

Tier 2: Semantic Tokens

The package's @takazudo/zudo-doc/theme.css exposes 23 bare color aliases and 23 matching --color-zd-* aliases. A scaffold imports that file before its own src/styles/global.css :root override block, so redefine only the project aliases you need to change. These are the shipped bare-token and namespaced-token blocks, showing the color aliases:

@takazudo/zudo-doc/theme.css (color aliases)
:root {
  --color-bg: var(--zd-bg);
  --color-fg: var(--zd-fg);
  --color-sel-bg: var(--zd-selection-bg);
  --color-sel-fg: var(--zd-selection-fg);
  --color-surface: var(--zd-surface);
  --color-muted: var(--zd-muted);
  --color-accent: var(--zd-accent);
  --color-accent-hover: var(--zd-accent-hover);
  --color-code-bg: var(--zd-code-bg);
  --color-code-fg: var(--zd-code-fg);
  --color-success: var(--zd-success);
  --color-danger: var(--zd-danger);
  --color-warning: var(--zd-warning);
  --color-info: var(--zd-info);
  /* The scrim is intentionally theme-independent. */
  --color-overlay: #000;
  --color-image-overlay-bg: var(--zd-image-overlay-bg);
  --color-image-overlay-fg: var(--zd-image-overlay-fg);
  --color-chat-user-bg: var(--zd-chat-user-bg);
  --color-chat-user-text: var(--zd-chat-user-text);
  --color-chat-assistant-bg: var(--zd-chat-assistant-bg);
  --color-chat-assistant-text: var(--zd-chat-assistant-text);
  --color-matched-keyword-bg: var(--zd-matched-keyword-bg);
  --color-matched-keyword-fg: var(--zd-matched-keyword-fg);
  --color-zd-bg: var(--zd-bg);
  --color-zd-fg: var(--zd-fg);
  --color-zd-sel-bg: var(--zd-selection-bg);
  --color-zd-sel-fg: var(--zd-selection-fg);
  --color-zd-surface: var(--zd-surface);
  --color-zd-muted: var(--zd-muted);
  --color-zd-accent: var(--zd-accent);
  --color-zd-accent-hover: var(--zd-accent-hover);
  --color-zd-code-bg: var(--zd-code-bg);
  --color-zd-code-fg: var(--zd-code-fg);
  --color-zd-success: var(--zd-success);
  --color-zd-danger: var(--zd-danger);
  --color-zd-warning: var(--zd-warning);
  --color-zd-info: var(--zd-info);
  --color-zd-overlay: #000;
  --color-zd-image-overlay-bg: var(--zd-image-overlay-bg);
  --color-zd-image-overlay-fg: var(--zd-image-overlay-fg);
  --color-zd-chat-user-bg: var(--zd-chat-user-bg);
  --color-zd-chat-user-text: var(--zd-chat-user-text);
  --color-zd-chat-assistant-bg: var(--zd-chat-assistant-bg);
  --color-zd-chat-assistant-text: var(--zd-chat-assistant-text);
  --color-zd-matched-keyword-bg: var(--zd-matched-keyword-bg);
  --color-zd-matched-keyword-fg: var(--zd-matched-keyword-fg);
}

The package wind preset registers these aliases, enabling bg-surface, text-accent, and border-muted. For a new name, add wind.tokens.colors as well as its CSS value; see Design System.

Bare aliases are the surface for project code; --color-zd-* is the surface for embedders — see Coexisting with another @theme.

Semantic Token Reference (Default Dark wiring)

Both schemes wire all 23 roles; the table below shows Default Dark's map.semantic — the authored reference scheme shipped from @takazudo/zudo-doc/color-schemes-defaults. Default Light re-points several of these to different ramp stops or per-mode literals; see Per-mode literal overrides.

TokenDefault ramp refUsage
bg{ base: 4 }Page background
fg{ base: 0 }Primary text
surface{ base: 4 } (= bg)Panel/sidebar surfaces — merged onto bg, see Role-merge philosophy
muted{ base: 1 }Muted text, borders, comments
accent{ accent: 1 }Prose links, active/current states, CTA; hover/focus ink for chrome links
accentHover{ accent: 0 }Hover state for accent
codeBg{ base: 3 }Code block background
codeFg{ base: 0 }Inline code text
success{ state: "success" }Success states, confirmations
dangerper-mode literalErrors, destructive actions
warning{ state: "warning" }Warning messages, admonitions, find-in-page highlight
info{ state: "info" }Informational highlights
mermaidNodeBg / mermaidText / mermaidLine / mermaidLabelBg / mermaidNoteBgsee @takazudo/zudo-doc/color-schemes-defaultsMermaid diagram colors
chatUserBg / chatUserText{ accent: 1 } / { base: 4 }AI chat user bubble
chatAssistantBg / chatAssistantText{ base: 4 } (= bg) / { base: 0 }AI chat assistant bubble — merged onto bg
imageOverlayBg / imageOverlayFg{ base: 4 } / { base: 0 }Image enlarge overlay — merged onto bg
matchedKeywordBg / matchedKeywordFgshared literalSearch result <mark> — dedicated tokens, live-editable in the Design Token Panel

Syntax semantics

ModeMap.syntax is an optional partial map of nine syntax-specific roles. If the map is absent, or an individual role is omitted, that role inherits the existing semantic role shown below. Schemes and v3 token JSON authored before syntax tokens existed therefore remain valid, while a named variation or one mode can override only the roles it needs.

ModeMap.syntax keyCSS custom propertyInherits fromzfb renderer roles
syntaxComment--zd-syntax-commentmutedcomment
syntaxString--zd-syntax-stringsuccessstring, escape
syntaxNumber--zd-syntax-numberwarningnumber, constant
syntaxKeyword--zd-syntax-keywordaccentkeyword, heading
syntaxCallable--zd-syntax-callableinfofunction
syntaxType--zd-syntax-typewarningtype, namespace
syntaxName--zd-syntax-namecodeFgproperty, variable, tag, attribute
syntaxInserted--zd-syntax-insertedsuccessinserted
syntaxDeleted--zd-syntax-deleteddangerdeleted

The remaining zfb roles (operator and punctuation) use codeFg. This compression keeps zfb's 18 renderer roles as component-level detail instead of making all 18 public color decisions. Inserted/deleted backgrounds are also component tokens: zudo-doc mixes 15% of the corresponding syntax foreground into codeBg with color-mix() rather than exposing two more public roles.

For example, override only keywords in one mode:

const darkMap: ModeMap = {
  // ...base and semantic mappings...
  syntax: {
    syntaxKeyword: { state: "info" },
  },
};

The provider emits all nine resolved --zd-syntax-* variables. Both native fenced-code output and HtmlPreview's lazy WASM output consume them through zfb's --zfb-hi-* component bridge. Because the bridge retains var() references, scheme switches and Design Token Panel edits recolor existing hi-* DOM without rerunning highlighting. Shipped foreground/background pairs, including the rendered inserted/deleted tints, are validated at a minimum 4.6:1 contrast ratio.

Role-merge philosophy

The palette was deliberately minimized to keep the number of distinct tones small. Several roles are merged onto a shared stop instead of each carrying their own:

  • surface, codeBg (light mode), and chatAssistantBg collapse onto bg. Header version boxes, doc cards, and the chat-assistant bubble render as page background + border only — no separate gray fill.

  • 5 base stops leave no subtle near-white (light mode) or near-black (dark mode) filler tone. Rather than stretch the ramp to manufacture one, elevated surfaces are border-only by design.

  • The result: fewer tones to keep in sync, and a flatter, more consistent visual language across panels, cards, and chat bubbles.

Per-mode literal overrides

Not every role can be satisfied by a shared ramp stop in both modes. The accent and state colors are authored against a dark background; on a light background several need a darker, per-mode-only literal to clear WCAG AA — the shared ramp stop stays untouched so Default Dark is unaffected:

  • Default Dark — danger uses a literal (oklch(.655 .170 25)) tuned slightly lighter than the shared state.danger ramp value, so the danger-admonition title clears AA on its 12%-tint background.

  • Default Light — accentHover, success, danger, warning, and info all use per-mode literals (darkened versions of the shared ramp/state colors) to clear AA against the near-white page background.

  • Both modes — matchedKeywordBg / matchedKeywordFg (the search-result highlight) are shared literals, not ramp refs, so the amber highlight looks identical in both modes.

See the color-scheme-a11y skill (.claude/skills/color-scheme-a11y/SKILL.md) for the full contrast-pair matrix, thresholds, and tuning methodology behind these literals.

Adjusting a scheme's semantic wiring

Edit map.semantic in your own colorSchemes override module, passed via the colorSchemes field in zfb.config.ts. A role can point at a shared ramp stop or a literal OKLCH string:

const darkMap: ModeMap = {
  bg: { base: 4 },
  fg: { base: 0 },
  selectionBg: { base: 2 },
  selectionFg: { base: 0 },
  semantic: {
    accent: { accent: 1 },
    // Per-mode AA-tuned literal — shared state.danger is too dark for the
    // danger-admonition title on its 12%-tint dark bg.
    danger: "oklch(.655 .170 25)",
    // ...
  },
  // Optional; omit it entirely when all syntax roles should inherit.
  syntax: {},
};

Reaching a raw ramp stop (rare)

There is no zudo-wind utility for the raw --palette-* ramp stops — semantic tokens are the only zudo-wind-facing color surface. If a one-off style genuinely needs a raw ramp stop, reference the CSS custom property directly:

<div style="background: var(--palette-accent-1)">...</div>

This should be uncommon. Prefer an existing semantic token, or add a new semantic role if the same raw stop is needed in more than one place.

Tier 3: Component Tokens

Some components define their own color variables that consume Tier 2 semantic tokens. These are internal implementation details.

Content Typography

The .zd-content class provides direct element styling using semantic tokens — no external typography plugin:

.zd-content {
  color: var(--color-fg);
  font-size: var(--text-body);
  line-height: var(--leading-relaxed);
}

.zd-content :where(a) {
  color: var(--color-accent);
}

.zd-content :where(code:not(pre code)) {
  color: var(--color-code-fg);
  background-color: var(--color-code-bg);
}

.zd-content :where(li::marker) {
  color: var(--color-muted);
}
/* ... */

Info

Tier 3 tokens are internal to their components. When building your own UI, use Tier 2 semantic tokens directly.

Using Color Tokens

Use semantic tokens for standard UI patterns:

<!-- Text -->
<p class="text-fg">Primary text</p>
<p class="text-muted">Secondary text</p>

<!-- Backgrounds -->
<div class="bg-bg">Page background</div>
<div class="bg-surface">Panel or sidebar</div>

<!-- Borders -->
<div class="border border-muted">Bordered element</div>

Links are colored by where they sit, not by the fact that they are links:

  • Prose links — a link inside running text, such as a Markdown link rendered by ContentLink or a link in a version banner — use text-accent underline.

  • Chrome links — nav lists, cards, chips, the pager — stay text-fg (or text-muted for tertiary chrome: footer columns, the TOC, the breadcrumb) and turn accent only on hover, on focus, or as the current item. They never carry a static underline; instead each one needs a structural affordance: a visible list, a card box, or a leading icon.

  • Inline link rows in tertiary chrome — the home hero links line and the footer copyright line — sit between the two: text-fg with a static underline, turning accent on hover.

<!-- Prose link -->
<a class="text-accent underline hover:text-accent-hover">Prose link</a>

<!-- Chrome link -->
<a class="text-fg hover:text-accent hover:underline focus-visible:text-accent focus-visible:underline">Chrome link</a>

Search & highlight tokens (role-split)

Highlight roles are deliberately split across dedicated semantic tokens — reusing one token across unrelated highlight UIs is an anti-pattern.

  • matched-keyword-bg / matched-keyword-fg drive the search panel <mark> element. Because they are dedicated tokens (not a color-mix() of another role), the Design Token Panel swatch is the single source of truth for the highlight color — what you see on the swatch is what the highlight renders as.

  • warning drives admonitions (:::warning), find-in-page (.find-match, .find-match-active), and any UI that is semantically a warning. Do not reuse warning for new UI-chrome highlights.

When a new highlight role appears (new kind of <mark>, new pill, new callout), add a dedicated semantic token rather than bolting another responsibility onto an existing role-overloaded token. Each visible highlight color in the product should map to exactly one panel swatch.

Color Schemes

By default, colorMode is enabled, so lightScheme and darkScheme select the active scheme and take precedence over colorScheme. Its defaultMode is "dark", mapping to the packaged dark scheme; with respectPrefersColorScheme: true, an operating-system preference can select the other mode instead:

zfb.config.ts
import { defineConfig } from "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";

export default defineConfig(
  zudoDoc({
    colorMode: {
      defaultMode: "dark",
      lightScheme: "Default Light",
      darkScheme: "Default Dark",
      respectPrefersColorScheme: true,
    },
    siteName: "My Docs",
    // ...
  }),
);

Appearance preference

The header menu's Light, Dark, and System choices are saved preferences; the page still resolves to a light or dark color scheme. With the default respectPrefersColorScheme: true, a first visit with no saved preference selects System and follows the device. Selecting Light or Dark pins that appearance until the visitor chooses System again. The selected preference is stored in localStorage under zudo-doc-theme.

The compact menu icon always identifies the selected preference: sun for Light, moon for Dark, and monitor for System. If the device changes from light to dark while System is selected, the page changes color scheme and the monitor remains selected. defaultMode stays a "light" | "dark" fallback; setting respectPrefersColorScheme: false makes that fallback the first-visit choice when there is no saved preference. A visitor may still explicitly select System, which follows the device.

To use one fixed scheme with no light/dark appearance menu, set colorMode: false; in that case colorScheme chooses the scheme:

zfb.config.ts
export default defineConfig(
  zudoDoc({
    colorMode: false,
    colorScheme: "My Theme",
  }),
);

Default Themes

zudo-doc ships exactly two color schemes — Default Light and Default Dark — sharing one set of ramps (see the package's @takazudo/zudo-doc/color-schemes-defaults). Both are hand-authored and AA-tuned in place. There is no bundled catalog of community or terminal presets to pick from; an earlier iteration of the Design Token Panel had a "Scheme…" dropdown for browsing 50+ bundled presets, but the ramp-native model dropped it — see Design Token Panel.

Adding a Custom Color Scheme

colorSchemes is one of zudoDoc()'s escape hatches — when omitted, the two packaged schemes above are used as-is. A fresh scaffold has no color-scheme module; create one (for example, src/color-schemes.ts) to add or replace a scheme, then pass it through the colorSchemes field in zfb.config.ts. A new scheme needs its own ramps and a map for each mode you want (spread SEMANTIC_RAMP_DEFAULTS and override only what needs to differ). Spread in defaultColorSchemes too if you want to keep the two packaged schemes alongside your own:

src/color-schemes.ts
import { SEMANTIC_RAMP_DEFAULTS } from "@takazudo/zudo-doc/color-scheme-utils";
import { defaultColorSchemes } from "@takazudo/zudo-doc/color-schemes-defaults";
import type { ColorScheme, ModeMap, Ramps } from "@takazudo/zudo-doc/color-scheme-utils";

const myRamps: Ramps = {
  base: [
    "oklch(.97 .01 250)", // 0 — lightest
    "oklch(.72 .02 250)", // 1
    "oklch(.48 .03 250)", // 2
    "oklch(.28 .03 250)", // 3
    "oklch(.15 .02 250)", // 4 — darkest
  ],
  accent: [
    "oklch(.78 .12 30)", // 0
    "oklch(.68 .18 30)", // 1
    "oklch(.48 .16 30)", // 2
  ],
  state: {
    danger: "oklch(.62 .18 25)",
    success: "oklch(.66 .15 145)",
    warning: "oklch(.75 .14 82)",
    info: "oklch(.66 .13 245)",
  },
};

const myMap: ModeMap = {
  bg: { base: 4 },
  fg: { base: 0 },
  selectionBg: { base: 2 },
  selectionFg: { base: 0 },
  semantic: {
    ...SEMANTIC_RAMP_DEFAULTS,
    accent: { accent: 1 }, // only override the roles that need to differ
  },
  syntax: {}, // optional; add only syntax-specific overrides here
};

export const colorSchemes: Record<string, ColorScheme> = {
  ...defaultColorSchemes, // keep "Default Light" / "Default Dark"
  "My Theme": { ramps: myRamps, map: myMap },
};
zfb.config.ts
import { colorSchemes } from "./src/color-schemes";

export default defineConfig(
  zudoDoc({
    colorMode: false,
    colorScheme: "My Theme",
    colorSchemes,
    // ...
  }),
);

Tip

In this showcase repository, contributors run pnpm contrast:audit and use the color-scheme-a11y skill when changing packaged schemes. Those are showcase-maintenance tools, not generated-scaffold commands; validate the contrast requirements appropriate to your project when you create a custom scheme.

What NOT to Do

Color Anti-Patterns

Use registered colors — default palette names are not registered:

<!-- WRONG -->
<div class="bg-gray-800 text-blue-500">No visible color</div>

<!-- RIGHT -->
<div class="bg-surface text-accent">Works correctly</div>

Don't hardcode hex values — it breaks theming:

<!-- WRONG -->
<div class="bg-[#1e1e2e]">Breaks on theme switch</div>

<!-- RIGHT -->
<div class="bg-surface">Adapts to any theme</div>

Don't reference component-scoped variables in your own components:

/* WRONG — don't use hardcoded colors */
.my-component {
  color: #3b82f6;
}

/* RIGHT — use semantic tokens */
.my-component {
  color: var(--color-accent);
}

Revision History

Takeshi TakatsudoCreated: 2026-03-14T08:13:16+09:00Updated: 2026-10-09T16:26:27Z

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…