# Theming

> Presets, the brand tokens and how to override them, dark mode without a flash, self-hosted fonts and icons — a config edit, not a find-and-replace.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/configure/theming/

---

Retheming is a config edit, not a find-and-replace. Every colour, corner and font on the site
resolves to one `--brand-*` custom property: a preset sets them all, `theme.tokens` overrides
any of them, and dark mode is the same tokens under `[data-theme=dark]`. Components use
semantic utilities only, never a palette name, so no component changes when the brand does.
The free edition keeps the `default` preset only.

## Pick a preset

`theme.preset` in `site.config.ts`. `default` declares every token on `:root`; the others
override under `:root[data-preset=<name>]` and inherit the rest, fonts included.

| Preset | Brand | `radius` / `radiusSm` |
| --- | --- | --- |
| `default` | blue, `oklch(0.55 0.2 260)`, white background | `0.75rem` / `0.375rem` |
| `warm` | terracotta, `oklch(0.555 0.165 40)`, warm off-white paper | `1rem` / `0.5rem` |
| `mono` | near-black, `oklch(0.16 0 0)`, neutral greys | `0` / `0`, square |

```ts title="site.config.ts"
theme: { preset: 'warm' },
```

Each preset also carries an `ogColors` block in `src/config/presets.ts`, in hex, because the OG
renderer cannot read `oklch()`: change a brand colour and its hex together. A hex
`theme.tokens.primary` is the one override the images pick up.

## Overriding tokens

```ts title="site.config.ts"
theme: {
  preset: 'warm',
  tokens: { primary: 'oklch(0.55 0.19 15)', radius: '0.75rem' },
  darkTokens: { primary: 'oklch(0.75 0.15 15)' },
},
```

Only the tokens you name change; the rest still come from the preset. `Base.astro` emits them
as an unlayered inline `<style>`, which outranks every `@layer` rule in `global.css` without
`!important`. Two rules, reported by `pnpm check:config` with the field path:

- Raw CSS values, not class names. `primary: 'bg-blue-600'` fails with
  `site.config.ts › theme.tokens.primary: must be a CSS value, not a Tailwind class name`;
  anything starting `bg-`, `text-`, `border-`, `ring-`, `fill-`, `stroke-`, `font-` or
  `rounded-` is refused.
- No `;`, `}` or `<`: `must not contain ";", "}" or "<"`, because the value is written straight
  into a `<style>` element.

The token names are the keys listed under [`theme`](https://zerodirs.com/docs/configure/reference/#theme) in the
configuration reference. Two of them are easy to misread:

- `line` is a hairline: card borders, dividers, `<hr>`. `lineStrong` is a control boundary:
  inputs, checkboxes, outline buttons. WCAG 1.4.11 wants a control's border at 3:1 against every
  background it sits on; a card border is decoration, and a hairline dark enough for 3:1 boxes
  every card in. Override `lineStrong` with something pale and `pnpm test theme` fails.
- `radius` is the only corner you set. Cards take 1.5× it (`rounded-brand-lg`); buttons, chips
  and badges take 3× (`rounded-brand-pill`, a pill at any height under 72px). The multiples
  live in `src/styles/global.css`, so `mono`'s `0` stays square everywhere and `warm`'s `1rem`
  is rounder without a third token. `radiusSm` is the small logo tile.

## Dark mode

| `theme.darkMode` | What happens |
| --- | --- |
| `'system'` (default) | follows the OS; the header shows a toggle, and a choice is remembered in `localStorage` |
| `'light'` / `'dark'` | fixed: `data-theme` is written on `<html>` at build time, no toggle and no script |

Under `'system'` a short inline script is the first thing in `<head>`: it reads
`localStorage.theme`, falls back to `matchMedia('(prefers-color-scheme: dark)')` and sets
`data-theme` before the first paint, so there is no flash of the wrong theme. The toggle flips
the attribute and writes the same key. Those two are the only scripts on a content page. The
dark variant is keyed on `[data-theme=dark]`, not on `prefers-color-scheme`, which is what lets
an explicit choice beat the OS setting.

## Fonts

```ts title="site.config.ts"
theme: {
  font: { provider: 'fontsource', family: 'Inter', weights: [400, 600, 700], preloadWeight: 600 },
},
```

The default is `{ provider: 'system' }`: the system UI stack, no font files, no request. With
`fontsource`, `google` or `bunny`, Astro's font pipeline downloads the family at build time and
self-hosts it, latin subset, normal style, only the weights you list, and preloads
`preloadWeight`, which must be one of `weights` or `pnpm check:config` stops with
`theme.font.preloadWeight: must be one of weights [400, 600, 700]`. Nothing is fetched from a
third party at runtime. `fontDisplay` follows `font` unless you set it.

## Icons

Icons are [lucide](https://lucide.dev), inlined as SVG at build time by `astro-icon`; nothing is
fetched at runtime. `theme.icons` is the allow-list of names to bundle, and every
`categories[].icon` is merged into it for you, so list only the icons your own markup uses; a
name outside the list is not in the bundle. Your own SVGs go in `src/icons/`: drop
`acme-mark.svg` there and render it with `<Icon name="local:acme-mark" />`. The directory ships
with only a README: without it every build prints
`[astro-icon] Failed to load icons from "src/icons"`, which looks like an error and is not.

## Add a preset

Three files, all required, then the config. The `theme.preset` enum and the `PresetName` type
are derived from the registry, so nothing else is edited.

1. Write the CSS.

   Copy `src/styles/presets/warm.css` to `src/styles/presets/<name>.css` and scope it to
   `:root[data-preset=<name>]` and `:root[data-preset=<name>][data-theme=dark]`. Every colour
   and radius token goes in both blocks; the font tokens may inherit.

2. Import it, next to the existing presets.

   ```css title="src/styles/global.css"
   @import "./presets/<name>.css";
   ```

3. Register it.

   ```ts title="src/config/presets.ts"
   { name: '<name>', description: '…', ogColors: { bg: '#…', fg: '#…', primary: '#…', primaryFg: '#…', muted: '#…' } },
   ```

   `pnpm check` now accepts the name in `theme.preset`.

4. Use it and test it.

   ```ts title="site.config.ts"
   theme: { preset: '<name>' },
   ```

   `pnpm test theme` asserts the triple and measures the contrast. In the paid starter,
   `pnpm og:default` then regenerates `public/og-default.png` with the new hex.

The [New theme recipe](https://zerodirs.com/docs/agents/recipes/new-theme/) walks an agent through the same steps.

## Verify

| | Where | You should see |
| --- | --- | --- |
| The config parses | `pnpm check:config` | `site.config.ts OK — <your site name> (https://your-domain.com)`; a bad token fails here with its field path |
| The palette holds up | `pnpm test theme` | `Test Files  1 passed`. The file converts each preset's OKLCH values to relative luminance and asserts `fg` and `mutedFg` at 4.5:1 on `bg`, `surface` and `muted`; `primaryFg` at 4.5:1 on `primary`; `lineStrong` at 3:1 on all three, light and dark |
| The registration | the same run | every preset in `PRESETS` has its file, its `@import` and its dark block; the token names in `schema.ts` and `default.css` agree |
| The pages | `pnpm build`, then `pnpm preview` | `http://localhost:4321/` in the new colours; the header toggle switches without a flash, and a reload keeps the choice |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `theme.preset` | `'default'` | which preset file and `ogColors` block the site uses |
| `theme.tokens`, `theme.darkTokens` | — | per-token overrides for light and dark, raw CSS values |
| `theme.darkMode` | `'system'` | `system`, `light` or `dark` |
| `theme.font` | `{ provider: 'system' }` | the self-hosted family, its weights and the preloaded one |
| `theme.icons` | `[]` | the lucide names to bundle, on top of the category icons |
| `theme.viewTransitions` | `false` | adds `@view-transition{navigation:auto}` to every page |

See [`theme`](https://zerodirs.com/docs/configure/reference/#theme).

## Files

- src/
  - styles/
    - global.css Tailwind entry, the `@theme inline` map, the radius multiples, the component classes
    - presets/ one file per preset; `default.css` is the baseline
    - theme.ts `themeCss()` for the override block and the dark-mode bootstrap script
  - config/
    - presets.ts the registry: names, descriptions, `ogColors`
    - schema.ts the token vocabulary and the CSS-value rules
  - components/site/ThemeToggle.astro the toggle and its inline script
  - icons/ your own SVGs, the `local:` collection
- tests/unit/theme.test.ts the registration and contrast assertions

## Related

- [Make it yours](https://zerodirs.com/docs/start/make-it-yours/) — Name, domain, categories and colours in one pass.
- [OG images](https://zerodirs.com/docs/configure/og-images/) — The images read the preset's hex colours, not the CSS.
- [Configuration reference](https://zerodirs.com/docs/configure/reference/#theme) — Every theme field, its type and its default.
