# Theming

> Design tokens for color, radius, and typography, live theme re-resolution, chrome passes, and token-only restyles across apps.

- Repository: vercel-labs/native
- GitHub: https://github.com/vercel-labs/native
- Human docs: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a
- Complete Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/llms-full.txt

## Source Files

- `src/primitives/canvas/themes/geist.zig`
- `docs/src/app/theming/layout.tsx`
- `examples/deck/src/chrome.zig`
- `examples/soundboard/README.md`
- `examples/deck/README.md`

---

---
title: "Theming"
description: "Design tokens for color, radius, and typography, live theme re-resolution, chrome passes, and token-only restyles across apps."
---

Every built-in control reads its colors, radii, type rungs, control heights, state washes, and focus geometry from one `DesignTokens` value resolved by `UiApp.effectiveTokens()` on each rebuild. There are no per-component style props and no emitter special cases: retheme the tokens and the whole widget register moves together. Packs, sparse overrides, full custom registers, markup token refs, and optional chrome display-list passes all hang off that same surface.

## Architecture

```mermaid
flowchart TB
  subgraph sources [Theme inputs]
    AppZon["app.zon theme"]
    ThemeOpt["ThemeOptions\npack / scheme / contrast\ndensity / reduce_motion"]
    Overrides["DesignTokenOverrides"]
    Appearance["OS Appearance\nscheme · high_contrast · reduce_motion"]
    Model["Model via tokens_fn"]
  end

  subgraph resolve [Resolution]
    Pack["DesignTokens.theme\nhouse | geist"]
    Apply["withOverrides"]
    Effective["UiApp.effectiveTokens\nstamps pixel_snap.scale"]
  end

  subgraph consumers [Consumers]
    Finalize["Ui.finalizeWithTokens\nstyle_tokens → WidgetStyle"]
    Emitters["Widget emitters\nControlTokens + ColorTokens"]
    Chrome["UiApp.chrome\nprefix · widgets · suffix"]
  end

  AppZon --> ThemeOpt
  Appearance --> Model
  Appearance --> Effective
  ThemeOpt --> Pack
  Pack --> Apply
  Overrides --> Apply
  Model --> Effective
  Apply --> Effective
  Effective --> Finalize
  Effective --> Emitters
  Effective --> Chrome
```

| Layer | Owner | Role |
| --- | --- | --- |
| Pack register | `ThemePack` + `DesignTokens.theme` | Full palette, control tables, metrics, type scale per scheme/contrast |
| Overrides | `DesignTokenOverrides` | Sparse brand deltas; null fields keep the base |
| App tokens | `Options.tokens` / `tokens_fn` | Fixed look or model-owned look |
| Runtime stamp | `effectiveTokens` | Always writes live `pixel_snap.scale` after app resolution |
| Markup refs | `StyleTokenRefs` / `finalizeWithTokens` | Named color and radius fields re-resolve on every rebuild |
| Chrome | `ChromeOptions` | Fixed-count display-list prefix/suffix around widgets |

## Theme packs

Built-in packs are complete design systems selectable by name. Pack selection is manifest/API vocabulary (`app.zon` `theme`, `ThemeOptions.pack`, `UiApp.Options.theme`), never markup.

| Pack | Default | Register |
| --- | --- | --- |
| `house` | Yes | Monochrome neutral scale in `tokens.zig` defaults: near-black filled controls on light, porcelain on dark; semantic hues only where they mean something |
| `geist` | No | Cool neutrals over pure white/black pages; monochrome primaries; blue focus and info; 6px control corners / 12px surfaces; 32/40/48 control ladder; segmented spinner |

Declare the pack in `app.zon`:

```zig
.{
    .id = "com.example.app",
    .name = "example",
    .version = "0.1.0",
    .theme = "geist",
}
```

Unknown names fail at compile time via `app_runner.manifestThemePack()` — valid names are `house` and `geist`, never a silent fallback. Scaffold wiring:

```zig
.theme = app_runner.manifestThemePack(),
```

Apps that own tokens pick the pack themselves:

```zig
return canvas.DesignTokens.theme(.{
    .color_scheme = scheme,
    .pack = .geist,
    .contrast = if (high_contrast) .high else .standard,
    .reduce_motion = reduce_motion,
});
```

### Pack comparison (control geometry)

| Token | House default | Geist |
| --- | --- | --- |
| Control heights sm / default / lg | 28 / 32 / 36 | 32 / 40 / 48 |
| Button insets sm / default / lg | (house defaults) | 6 / 10 / 14 |
| Radius sm–lg / xl | 6 / 8 / 10 / 14 | 6 / 6 / 6 / 12 |
| Focus | Mid-gray ring | Blue ring, 2px + 2px offset |
| Spinner | House arc | Segmented dial (12 pills, 1200ms) |
| Primary fill light | Near-black `#171717` | Pure black `#000000` |

## ThemeOptions axes

`ThemeOptions` composes every live axis a pack honors:

| Field | Type | Default | Effect |
| --- | --- | --- | --- |
| `color_scheme` | `ColorScheme` | `.light` | Light or dark palette |
| `contrast` | `ColorContrast` | `.standard` | Standard or high-contrast register |
| `density` | `Density` | `.regular` | `compact` / `regular` / `spacious` metric scaling |
| `reduce_motion` | `bool` | `false` | When true, motion durations become `MotionTokens.reduced()` |
| `pack` | `ThemePack` | `.house` | Which built-in register resolves first |

Resolution order inside `DesignTokens.theme` / `themeWithOverrides`:

1. Pack register for scheme + contrast (`house` tables or `themes/geist.zig`)
2. Reduce-motion motion zeroing and density assignment
3. Sparse `DesignTokenOverrides` (if any)
4. Runtime-stamped `pixel_snap.scale` and platform text measurement (not themable)

## Wiring tokens into UiApp

`UiApp.Options` exposes three related fields:

| Field | When to use |
| --- | --- |
| `theme: ThemePack` | Stock tokens only (no `tokens` / `tokens_fn`): pack + system appearance |
| `tokens: ?DesignTokens` | Fixed design system; does not re-derive on OS flips unless you rebuild options |
| `tokens_fn: ?*const fn (*const Model) DesignTokens` | Model-owned theming; consulted every install/rebuild |

`effectiveTokens()` priority:

1. `tokens_fn(model)` then stamp `pixel_snap.scale`
2. Else static `tokens`
3. Else stock `DesignTokens.theme` from tracked system appearance + `Options.theme`

Default behavior: an app that sets neither `tokens` nor `tokens_fn` follows the OS light/dark, high-contrast, and reduce-motion settings live without a restart.

```zig
const MyApp = native_sdk.UiApp(Model, Msg);

// Model-owned (soundboard / deck pattern)
.tokens_fn = tokensFromModel,
.on_appearance = onAppearance,

fn onAppearance(appearance: native_sdk.Appearance) ?Msg {
    return .{ .set_appearance = appearance };
}

pub fn tokensFromModel(model: *const Model) canvas.DesignTokens {
    return theme.tokens(
        model.colorScheme(),
        model.appearance.high_contrast,
        model.appearance.reduce_motion,
    );
}
```

Register custom faces before the first build when typography points at app fonts:

```zig
.fonts = &[_]MyApp.FontRegistration{
    .{ .name = "pixel", .ttf = @embedFile("fonts/MyFace.ttf") },
},
// tokens set typography.font_id / mono_font_id to registered ids
```

## Live theme re-resolution

Theme changes do not require markup rewrites or widget forks.

| Signal | Path | Result |
| --- | --- | --- |
| OS scheme / contrast / motion | Platform appearance → runtime (`followsSystemAppearance`) or `on_appearance` → model → `tokens_fn` | Next rebuild gets new `DesignTokens` |
| Model brand flag | Msg updates model; `tokens_fn` branches packs or overrides | Token-only restyle of the same tree |
| Markup style attrs | `background="surface"`, `radius="md"`, series colors | Stored as `StyleTokenRefs`; `finalizeWithTokens(effectiveTokens())` resolves names against the live palette |
| Explicit Zig `style` | Set on the widget | Wins over a token ref on the same field |

Constraints:

- Style token attributes take **literal** token names only (compile/validation error otherwise). Dynamic styling stays in Zig.
- Numeric type sizes on controls are refused; retheme `TypographyTokens` to move the scale.
- Token refs re-resolve every rebuild; raw colors embedded in views do not.

Color token names are the fields of `ColorTokens` (`background`, `surface`, `text`, `accent`, `destructive`, …). Radius names are `sm` / `md` / `lg` / `xl`.

```xml
<column gap="8" background="surface" radius="md">
  <text foreground="text_muted">Secondary copy</text>
  <badge background="accent" foreground="accent_text">Live</badge>
</column>
```

Charts take the same color token vocabulary on series so both schemes stay honest.

## Token groups

`DesignTokens` groups (all overridable via matching `*Overrides` structs):

| Group | Contents |
| --- | --- |
| `colors` | Page/surface washes, text inks, border, accent pair, destructive/success/warning/info + knockout inks, focus ring, shadow ink, scrim, disabled |
| `controls` | Per-control visual tables (`button_primary`, `input`, `slider`, `search_field`, `panel`, …): background/hover/active/pressed/disabled, foreground, border, radius, stroke; null fields fall through to palette + state formulas |
| `states` | Interaction formulas when a control table is silent (hover/pressed alpha cuts, disabled wash, selection washes) |
| `metrics` | Control height ladder, button insets/label steps, icon extents, slider track/thumb, tabs indicator thickness/gap, button-group gap, spinner geometry |
| `typography` | `font_id` / `mono_font_id`, families, body/label/title/button/heading/display sizes |
| `radius` | Corner scale sm–xl |
| `stroke` | Hairline, regular, focus width + focus offset |
| `spacing` | xs–xl |
| `shadow` / `blur` / `motion` / `scroll` / `layer` / `pixel_snap` | Elevation, backdrop blur, durations/easing, scroll physics (including overscroll defaults), z-bands, snapping |
| `density` | Compact / regular / spacious |

### Overrides layer

`DesignTokenOverrides` is a sparse overlay: every left-null field keeps the base, so brand layers survive pack updates.

```zig
var tokens = canvas.DesignTokens.themeWithOverrides(.{
    .color_scheme = scheme,
    .pack = .geist,
}, .{
    .colors = .{
        .accent = canvas.Color.rgb8(223, 38, 112),
        .accent_text = canvas.Color.rgb8(255, 255, 255),
        .focus_ring = canvas.Color.rgb8(223, 38, 112),
    },
    .controls = .{
        .slider = .{ .active_background = canvas.Color.rgb8(223, 38, 112) },
    },
    .radius = .{ .sm = 4, .md = 4, .lg = 4 },
});
```

Some control tables state their own hues (for example house/geist slider `active_background`). Moving only `colors.accent` does not always recolor them — restated entries need a matching controls override.

## Chrome display-list pass

Non-widget decoration (chassis fills, bevels, gradients, segment readouts) uses `UiApp.Options.chrome: ChromeOptions`, not widget trees.

| Field | Meaning |
| --- | --- |
| `prefix_commands` | Command count drawn **behind** widgets |
| `suffix_commands` | Command count drawn **in front of** widgets |
| `build` | `fn (model, *Builder, size, tokens) !void` emitting exactly `prefix + suffix` commands in that order |

Install path: chrome build → append prefix → `layout.emitDisplayList` → append suffix → `setCanvasDisplayList` + `emitCanvasWidgetDisplayListWithChrome`. A mismatched count returns `error.InvalidChromeCommandCount`.

Rules that keep chrome honest under tests:

- Fixed command counts across model states: hide by drawing offscreen, not by skipping commands.
- Path points and gradient stops must stay alive until the runtime deep-copies the list (file-scope storage for computed paths).
- Chrome receives the same `DesignTokens` as widgets; high-contrast paths typically drop decoration and fall back to `tokens.colors.*`.

Deck is the reference extreme: fixed 512×264 window, enamel prefix + glass/bevel/readout suffix, layout table shared with the widget views so machining cannot drift from controls.

## App patterns

### Token-only brand (soundboard)

- Pack: `geist` (also stated in `app.zon` as `.theme = "geist"`).
- One brand decision: pink accent via `DesignTokenOverrides` on `colors.accent` / `accent_text` / `focus_ring` and `controls.slider.active_background`.
- Follows system scheme live through `on_appearance` + `tokens_fn`.
- High contrast: **skips** brand overrides and returns the pack’s loud register untouched (accessibility beats brand).
- Reduce motion: `ThemeOptions.reduce_motion` zeroes motion tokens.

### Full custom skin (deck)

- Does **not** follow OS color scheme; one hardware finish.
- Starts from light house base, then replaces `colors`, radii, typography (registered Geist Pixel on both font slots), metrics, and per-control plating.
- Material mapping reuses semantic slots deliberately (for example `background` = smoked glass, `accent` = live phosphor).
- High contrast abandons the skin for the framework high-contrast light palette and stock faces; chrome keeps command counts but strips grain/glare/scanlines.
- Chrome pass supplies 3D hardware; tokens only state fills and inks widgets need.

| Concern | Soundboard | Deck |
| --- | --- | --- |
| Pack | Geist + pink overrides | Custom register (not a pack restyle) |
| OS scheme | Live | Ignored for color |
| Typography | Pack defaults | App-registered pixel face, grid sizes |
| Chrome | None (widgets + markup) | Full prefix/suffix machining |
| High contrast | Pack HC, no brand | Framework HC light + stripped chrome |

## Authoring a full design system

1. Start from published values (neutral scale, accent, semantics, type rungs, radii, heights), not screenshots.
2. Fill `colors` for all four scheme/contrast pairs (high contrast is not optional).
3. Put per-control exceptions only where the system diverges from its own semantic palette.
4. Restate `states` / `metrics` only when interaction feedback or the size ladder differs.
5. Hand the result through `tokens_fn` (or static `tokens`). Never cache `pixel_snap.scale` or platform text measurement — the runtime stamps them after your function.
6. Pin a reference surface signature in tests; re-bless only after reviewing captures.

```zig
pub fn brandTokens(scheme: canvas.ColorScheme, contrast: canvas.ColorContrast) canvas.DesignTokens {
    return .{
        .colors = brandColors(scheme, contrast),
        .controls = brandControls(scheme, contrast),
        .typography = .{ .body_size = 14, .title_size = 20 },
        .radius = .{ .sm = 4, .md = 6, .lg = 8, .xl = 12 },
        .metrics = .{ .control_height = 36 },
    };
}
```

## Theme, eject, or build

When a built-in is wrong for the product, order of operations:

1. **Theme it** — Engine controls are themed, never forked. Packs, overrides, control tables, and full registers cover every visual decision.
2. **Eject it** — Library composites can become app code via `native eject component <name>` when shape ownership is required (see Components).
3. **Build it** — Compose from the same primitives; new composites still read `DesignTokens`.

Tokens restyle the register; they do not restructure control semantics, states, or layout contracts. That boundary is what makes pinned pixel signatures meaningful across packs.

## Verification

| Check | How |
| --- | --- |
| Pack name valid | `native check` / `manifestThemePack()` compile error lists `house`, `geist` |
| Theme follows OS | Flip system appearance; unthemed or `tokens_fn`+`on_appearance` apps re-ink without restart |
| Brand overrides | Assert resolved `colors.accent` (and any controls that hard-code hue) under both schemes |
| High contrast | Confirm brand layer drops when required; chrome counts unchanged if chrome is used |
| Chrome counts | Rebuild chrome across model states; runtime rejects wrong prefix+suffix totals |
| Markup tokens | Unknown style token names fail validation/compile |
| Design-system pin | Render reference surface under pack/app tokens; pin pixel signature in CI (SDK does this for house and geist catalogs) |

Example suite commands from the music examples:

```sh
native test -Dplatform=null
```

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| Dark mode does nothing | Static `tokens` set without scheme branching; or `tokens_fn` ignores model appearance |
| Accent moved but slider/focus kept old hue | Control table states its own color; override `controls.*` and `focus_ring` |
| High contrast looks branded | Overrides applied on the HC path; gate brand on `!high_contrast` |
| Chrome panics / install fails | Emitted command count ≠ `prefix_commands + suffix_commands` |
| First frame wrong face | Font registration after first build; put faces in `Options.fonts` |
| Markup color does not flip | Used raw style color instead of a token name |
| Unknown `app.zon` theme | Typo; only `house` and `geist` resolve |

## Constraints

- Pack choice is not a markup attribute.
- Style token attributes are literals; no bindings.
- Runtime owns surface scale after app token resolution.
- Chrome is optional; when present, counts are part of the contract.
- Accessibility policies (high contrast, reduce motion) are first-class theme axes, not afterthoughts.

## Related pages

<CardGroup>
  <Card title="Native UI markup" href="/native-ui">
    Style token attributes, size rungs, and how markup binds without owning theme state.
  </Card>
  <Card title="App model" href="/app-model">
    Model/Msg wiring, rebuild boundaries, and where tokens_fn sits in the loop.
  </Card>
  <Card title="Components" href="/components">
    Catalog inventory, eject workflows, and when theming ends and composition begins.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Chromeless shells, titlebar styles, and host presentation that chrome skins sit on.
  </Card>
  <Card title="Testing" href="/testing">
    Headless truth drivers and frame-level checks for token and chrome pins.
  </Card>
</CardGroup>
