zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Color

Created Mar 13, 2026Updated Jul 18, 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 to keep every color on the site themeable. The package's @takazudo/zudo-doc/theme.css does not import Tailwind's default color theme: its @theme block resets the color namespace with --color-*: initial before defining the shipped tokens. A project's later @theme block is an override point. This ensures switching a color scheme updates the entire site at once.

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 @theme (a project's later @theme 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 Tailwind utility for a raw ramp stop (see Reaching a raw ramp stop).

Tier 2: Semantic Tokens

The 23 semantic roles are the color surface components actually use. The package's @takazudo/zudo-doc/theme.css maps them (plus the 4 base roles) into Tailwind-compatible tokens. A scaffold imports that file before its own src/styles/global.css @theme override block, so redefine only the aliases you need to change:

@takazudo/zudo-doc/theme.css
@theme {
  --color-*: initial;  /* reset ALL Tailwind defaults */

  /* Base */
  --color-bg: var(--zd-bg);
  --color-fg: var(--zd-fg);
  --color-sel-bg: var(--zd-selection-bg);
  --color-sel-fg: var(--zd-selection-fg);

  /* Semantic aliases */
  --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);

  /* Search highlight (dedicated, live-editable in Design Token Panel) */
  --color-matched-keyword-bg: var(--zd-matched-keyword-bg);
  --color-matched-keyword-fg: var(--zd-matched-keyword-fg);
}

Once registered in @theme, these become standard Tailwind utility classes: bg-surface, text-accent, border-muted, etc.

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 }Links, active states, CTA
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 Darkdanger 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 LightaccentHover, 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 modesmatchedKeywordBg / 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 Tailwind utility for the raw --palette-* ramp stops — semantic tokens are the only Tailwind-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>
<a class="text-accent hover:text-accent-hover">Link</a>

<!-- 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>

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",
    // ...
  }),
);

To use one fixed scheme with no light/dark toggle, 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

Don't use Tailwind defaults — they are reset to initial:

<!-- 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-07-19T07:26:05+09:00

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…