# Theming

> Theme the kit with --zg-* CSS variables derived from two colors.

Every color, shadow and radius the kit draws is a `--zg-*` CSS variable. The palette engine derives the full set from a seed (a canvas color and an accent) and checks text contrast. Each built-in theme also becomes a seed.

The desktop app's [appearance settings](https://zidane.si/docs/desktop-appearance.md) write these tokens. The terminal app's [themes](https://zidane.si/docs/tui-themes.md) are a separate system. The preview below scopes its own variables, so picking a theme there leaves the page's theme alone.

## Tokens

`styles/tokens.css` defines every variable with dark fallbacks.

| Group | Variables |
| - | - |
| Canvas and surfaces | `--zg-bg` (canvas), `--zg-tray`, `--zg-raised`, `--zg-track`, `--zg-overlay`, `--zg-well`, `--zg-tray-ring`, `--zg-raised-edge`, `--zg-hairline`, `--zg-row-hover`, `--zg-row-active` |
| Text | `--zg-text`, `--zg-text-muted`, `--zg-text-subtle` |
| Lines | `--zg-border`, `--zg-border-strong`, `--zg-ring` (focus) |
| Accent | `--zg-accent`, `--zg-accent-hover`, `--zg-accent-soft`, `--zg-accent-soft-strong`, `--zg-accent-fg`, `--zg-accent-fill`, `--zg-accent-fill-hover`, `--zg-accent-fill-fg`, `--zg-accent-alt`, `--zg-model` |
| Status | `--zg-success`, `--zg-warn`, `--zg-danger`, `--zg-info`, each with a `-soft` wash, and `--zg-danger-fill`, `--zg-danger-fill-hover`, `--zg-danger-fill-fg` |
| Code and diffs | `--zg-code-bg`, `--zg-code-fg`, `--zg-selection`, `--zg-diff-add-bg`, `--zg-diff-remove-bg`, `--zg-diff-add-content-bg`, `--zg-diff-remove-content-bg`, `--zg-diff-add-fg`, `--zg-diff-remove-fg` |
| Chips | `--zg-chip-default-bg` / `-fg`, `--zg-chip-skills-bg` / `-fg`, `--zg-chip-files-bg` / `-fg` |
| Depth | `--zg-shadow-raised`, `--zg-shadow-live`, `--zg-shadow-inset`, `--zg-shadow-float`, `--zg-shadow-well`, `--zg-shadow-fill`, `--zg-fill-sheen`, `--zg-scrim` |
| Contrast | `--zg-contrast-base`, `--zg-contrast-boost`, `--zg-contrast-boost-border`, `--zg-contrast-target`, `--zg-depth-mix`, `--zg-depth-hairline-mix` |
| Shape and type | `--zg-radius-xs` / `-sm` / `-md` / `-lg` / `-xl` (4, 6, 8, 12, 16px), `--zg-font-ui`, `--zg-font-chat`, `--zg-font-prompt`, `--zg-font-mono`, `--zg-font-size-chat`, `--zg-font-size-prompt`, `--zg-font-size-code` |
| Motion | `--zg-motion-<name>` (with `-duration` and `-ease`), `--zg-ease-out`, `--zg-ease-in-out`, `--zg-ease-drawer`, `--zg-panel-ms` |

Text, borders and surfaces are computed: a palette writes raw inputs (`--zg-text-raw`, `--zg-tray-raw`, ...) and the contrast layer mixes them. Do not read the `-raw` names. Avoid the legacy aliases `--zg-surface-1` to `-3`, `--zg-bg-elevated`, `--zg-bg-hover` and `--zg-error`.

Mermaid diagrams in the transcript read these tokens through Pele's `--pele-*` variables, set on `.zg-diagram`. A custom theme styles them with no extra work ([diagrams](https://zidane.si/docs/ui-transcript.md#diagrams)).

With the [UnoCSS preset](https://zidane.si/docs/ui-kit.md#styling), tokens are utility colors: `bg-zg-raised`, `bg-zg-tray`, `text-zg-muted`, `border-zg-hairline`, `bg-zg-danger/10`.

## Surfaces

Surfaces built from the kit's pieces match the app in every theme. Follow these rules:

| Rule | Do |
| - | - |
| Layers | Canvas, tray, raised, plus overlay for anything floating. Sections sit in a tray, controls sit raised on it, menus and dialogs use the overlay |
| Tone before line | Separate layers by lightness. Use `--zg-hairline` only where tone cannot (a divider under a header, a diff in a tray) |
| Light from above | Dark themes lift with a faint top highlight, light themes with a soft shadow and white top edge. Never hardcode a shadow or surface color |
| Group, then lift | Related controls share one track (`.zg-segment`). Only the hovered, open or active one is raised |
| Two voices | Sans for content and actions, `.zg-meta` (small muted tabular mono) for metadata. `.zg-section-label` is the section label. All text is sentence case, never uppercase or small caps |
| Accent for meaning | You, the model, send and status. Never decoration |

| Piece | Class / component | Use |
| - | - | - |
| Tray | `.zg-tray`, `Tray` | A section, grouped rows, a quiet card |
| Raised | `.zg-raised` (with `border`), `Raised` | A card or control on a tray. `data-live` (`live` prop) lifts it one more step while a run streams |
| Track and segment | `.zg-segment`, `.zg-segment-item` | Controls grouped on one recessed track |
| Overlay | `.zg-float` | Menus, popovers, dialogs |
| Well | `.zg-well` | Input fields. It is translucent, so it reads the same on any layer |
| Fill | `.zg-fill`, `.zg-fill zg-fill-danger` | Accent-filled buttons (send). With `.zg-fill-danger`, the destructive commit (delete) |
| Track chip | `.zg-track-chip` | Status set into a surface |
| Row | `.zg-menu-row`, `MenuRow` | Rows in menus, lists, navigation |

Depth transitions live on these classes, never inline, so the [motion](#motion) switch stops them.

## Palettes from a seed

`derivePalette(seed)` (`zidane-ui`) turns a `ThemeSeed` into a contrast-checked `ResolvedPalette`: body text at least 4.5:1, muted text 3.4:1, status and on-fill text 3:1. Only `canvas` and `accent` are required. The engine derives the rest.

| `ThemeSeed` field | Description |
| - | - |
| `canvas` | Window background. Required |
| `accent` | Buttons, links, focus, selection. Required |
| `appearance` | `'light'` or `'dark'`. Default: inferred from canvas luminance |
| `text`, `textMuted`, `textSubtle` | Text colors |
| `textStrong` | Primary text where it reads strongest: the sidebar and the inbox in dark (white by default) |
| `surface1`, `surface2`, `surface3`, `border`, `borderStrong` | Surface and line overrides |
| `ring` | Focus ring. Default: `accent` |
| `accentAlt` | Secondary hue (success green in most themes) |
| `model` | Model-identity tint |
| `success`, `warn`, `danger`, `info` | Status hues |
| `selection`, `codeBg` | Text selection, code background |
| `diff` | `Partial<DiffColors>`: add/remove backgrounds and foregrounds |
| `chips` | Per-kind chip colors (`default`, `skills`, `files`) |
| `throbber` | The working indicator's two colors |
| `money` | Cost figures |

`paletteToCssVars(palette)` (`zidane-ui`) returns the variable map. Add `contrastToCssVars` and `depthToCssVars` for the [contrast](#contrast) level (100 is neutral).

```ts
import { contrastToCssVars, depthToCssVars, paletteToCssVars } from 'zidane-ui'
import { derivePalette } from 'zidane-ui'

const palette = derivePalette({ canvas: '#101418', accent: '#8b5cf6' })
const vars = {
  ...paletteToCssVars(palette),
  ...contrastToCssVars(100, palette.appearance),
  ...depthToCssVars(100, palette.surfaces),
}
```

## Built-in themes

`BUILTIN_THEMES` (`zidane-ui`) holds the desktop app's themes by id:

| Id | Label |
| - | - |
| `default` | Default (dark) |
| `light` | Light |
| `catppuccin-mocha`, `catppuccin-macchiato`, `catppuccin-frappe` | Catppuccin (dark) |
| `catppuccin-latte` | Catppuccin Latte (light) |
| `gruvbox-dark`, `gruvbox-light` | Gruvbox |
| `crush` | Crush (Pantera) |
| `vaporwave` | Vaporwave |

`seedFromTheme(theme)` (`zidane-ui`) turns one into a seed you can override: `derivePalette({ ...seedFromTheme(BUILTIN_THEMES.vaporwave!), accent: '#22c55e' })`. `themeCssVars(theme, contrast?)` (`zidane-ui`) returns the full map for a theme: a built-in id, or a `Theme` of your own.

## Applying a theme

### Static pages and server renders

`themeStylesheet({ dark?, light?, contrast? })` returns `:root.dark { ... } :root.light { ... }` for two themes: built-in ids (`default` and `light` unless you pick others) or `Theme` objects of your own, such as your brand's. Render it into a `<style>` in `<head>` and set the class on `<html>` before first paint, as this site does.

```tsx
import { themeStylesheet } from 'zidane-ui'

const PALETTES = themeStylesheet({ dark: 'catppuccin-mocha', light: 'catppuccin-latte' })
const APPEARANCE = `document.documentElement.classList.add(matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')`

export function Head() {
  return (
    <>
      <script dangerouslySetInnerHTML={{ __html: APPEARANCE }} />
      <style dangerouslySetInnerHTML={{ __html: PALETTES }} />
    </>
  )
}
```

### At runtime

For a user-picked theme or custom seed, write the map onto `<html>` and toggle the appearance class. `withoutTransitions` makes the swap instant.

```ts
import type { CssVarMap } from 'zidane-ui'
import type { Appearance } from 'zidane-ui'
import { withoutTransitions } from 'zidane-ui'

export function applyTheme(vars: CssVarMap, appearance: Appearance) {
  const root = document.documentElement
  withoutTransitions(() => {
    for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value)
    root.classList.toggle('dark', appearance === 'dark')
    root.classList.toggle('light', appearance === 'light')
  })
}
```

### On one container

To theme part of a page, put the variables on a container with the `zg-theme-scope` class and the appearance class. Computed tokens then resolve against the container, as in the playground.

```tsx
<div className={`zg-theme-scope ${palette.appearance}`} style={vars as React.CSSProperties}>
  …
</div>
```

> **Note.** Popovers, tooltips (with `portal`), row menus and overlays render into `<body>`, so they take the page's theme.

## Light and dark

The appearance is a `.dark` or `.light` class on `<html>` or a `zg-theme-scope` container. It sets `color-scheme` for native controls and scrollbars, picks the palette `themeStylesheet` renders and drives UnoCSS's `dark:` variant (`presetUno({ dark: { dark: '.dark' } })`). With neither class, you get the dark fallbacks.

## Contrast

One level from 50 to 200 scales the UI. `contrastToCssVars(level, appearance)` fades text toward the canvas below 100 and pushes it toward white or black above.

`depthToCssVars(level, surfaces)` compresses the surface ladder below 100. Above 150 it goes flat (no shadows, hairlines at full border strength). `themeStylesheet` and `themeCssVars` take the same level.

## Motion

`html[data-motion]` is the motion switch: `off`, `reduced` or `full` (same as no attribute). Stylesheets key transitions on it. In JS, read it with `useMotionLevel()` or `useReducedMotion()` (`zidane-ui`). `useReducedMotion()` is true for `off` and `reduced`.

The kit never reads `prefers-reduced-motion`, so set the attribute from it or from your own setting:

```ts
const reduce = matchMedia('(prefers-reduced-motion: reduce)')
const sync = () => {
  document.documentElement.dataset.motion = reduce.matches ? 'reduced' : 'full'
}
sync()
reduce.addEventListener('change', sync)
```

`readPanelMs` reads `--zg-panel-ms` for panel open and close. `tokens.css` sets it to `350ms` with springs on, `200ms` with `html[data-springs="off"]`, and `0ms` when motion is `reduced` or `off`. `MotionProvider` writes the live value.

## Reference

[`theme/themes`](https://zidane.si/docs/reference/ui-theme-themes.md), [`theme/derive`](https://zidane.si/docs/reference/ui-theme-derive.md), [`theme/css-vars`](https://zidane.si/docs/reference/ui-theme-css-vars.md), [`theme/stylesheet`](https://zidane.si/docs/reference/ui-theme-stylesheet.md), [`theme/without-transitions`](https://zidane.si/docs/reference/ui-theme-without-transitions.md).
