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/zudo-doc/theme.css` `: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 `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

| 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/zudo-doc/color-schemes-defaults`); only the per-mode `map` differs between them.

[Dynamic content unavailable in text export]

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](https://zudo-doc.takazudomodular.com/docs/reference/color#role-merge-philosophy) below.



### `RampRef` — how a role points at a ramp stop

```ts
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](https://zudo-doc.takazudomodular.com/docs/reference/color#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:

```css
: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](https://zudo-doc.takazudomodular.com/docs/reference/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](https://zudo-doc.takazudomodular.com/docs/reference/color#reaching-a-raw-ramp-stop-rare)).

## 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:

```css title="@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](https://zudo-doc.takazudomodular.com/docs/reference/design-system).

Bare aliases are the surface for project code; `--color-zd-*` is the surface for embedders — see [Coexisting with another `@theme`](https://zudo-doc.takazudomodular.com/docs/reference/design-system#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](https://zudo-doc.takazudomodular.com/docs/reference/color#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](https://zudo-doc.takazudomodular.com/docs/reference/color#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/zudo-doc/color-schemes-defaults` | 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:

```ts
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:

```ts
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:

```html
<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:

```css
.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);
}
/* ... */
```

[Dynamic content unavailable in text export]

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:

```html
<!-- 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 `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.

```html
<!-- 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](https://zudo-doc.takazudomodular.com/docs/reference/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:

```ts title="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:

```ts title="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](https://zudo-doc.takazudomodular.com/docs/reference/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:

```ts title="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 },
};
```

```ts title="zfb.config.ts"
import { colorSchemes } from "./src/color-schemes";

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

[Dynamic content unavailable in text export]

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

[Dynamic content unavailable in text export]

**Use registered colors** — default palette names are not registered:

```html
<!-- 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:

```html
<!-- 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:

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

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