Color
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:
| Tier | Name | Purpose | Defined In |
|---|---|---|---|
| 1 | Ramps | Shared OKLCH color ramps a scheme is built from | your colorSchemes override in zfb.config.ts (optional — packaged defaults otherwise) → ColorSchemeProvider → :root |
| 2 | Semantic | Design meaning — what each color represents | packaged @takazudo/ :root (a project's later :root block can override it) |
| 3 | Component | Scoped overrides for specific components | .zd-content (shared content.css) |
This layering means you can:
Retune a ramp stop (override the
colorSchemesfield inzfb.config.ts) → every semantic role built from it updatesRemap a semantic role (e.g. point
accentat a different ramp stop) → every component usingaccentupdatesOverride 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/.
ramps— the shared source of truth: a warm-neutral base ramp (5 stops, index0= 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
| Ramp | Stops | CSS custom properties |
|---|---|---|
base | 5 — index 0 (lightest) → 4 (darkest) | --palette-base-0 … --palette-base-4 |
accent | 3 | --palette-accent-0 … --palette-accent-2 |
state | 4 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/); 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-isA 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/) 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/ exposes 23 bare color aliases and 23 matching --color-zd-* aliases. A scaffold imports that file before its own src/ :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:
: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/. Default Light re-points several of these to different ramp stops or per-mode literals; see Per-mode literal overrides.
| Token | Default ramp ref | Usage |
|---|---|---|
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 |
danger | per-mode literal | Errors, destructive actions |
warning | { state: "warning" } | Warning messages, admonitions, find-in-page highlight |
info | { state: "info" } | Informational highlights |
mermaidNodeBg / mermaidText / mermaidLine / mermaidLabelBg / mermaidNoteBg | see @takazudo/ | Mermaid 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 / matchedKeywordFg | shared literal | Search 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 key | CSS custom property | Inherits from | zfb renderer roles |
|---|---|---|---|
syntaxComment | --zd-syntax-comment | muted | comment |
syntaxString | --zd-syntax-string | success | string, escape |
syntaxNumber | --zd-syntax-number | warning | number, constant |
syntaxKeyword | --zd-syntax-keyword | accent | keyword, heading |
syntaxCallable | --zd-syntax-callable | info | function |
syntaxType | --zd-syntax-type | warning | type, namespace |
syntaxName | --zd-syntax-name | codeFg | property, variable, tag, attribute |
syntaxInserted | --zd-syntax-inserted | success | inserted |
syntaxDeleted | --zd-syntax-deleted | danger | deleted |
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), andchatAssistantBgcollapse ontobg. 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 —
dangeruses a literal (oklch(.655 .170 25)) tuned slightly lighter than the sharedstate.dangerramp value, so the danger-admonition title clears AA on its 12%-tint background.Default Light —
accentHover,success,danger,warning, andinfoall 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 (.) 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>Link colors
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
ContentLinkor a link in a version banner — usetext-accent underline.Chrome links — nav lists, cards, chips, the pager — stay
text-fg(ortext-mutedfor tertiary chrome: footer columns, the TOC, the breadcrumb) and turnaccentonly 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-fgwith a static underline, turningaccenton 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-fgdrive the search panel<mark>element. Because they are dedicated tokens (not acolor-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.warningdrives admonitions (:::warning), find-in-page (.find-match,.find-match-active), and any UI that is semantically a warning. Do not reusewarningfor 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:
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:
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/). 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/) 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:
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 },
};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);
}