# Theming & tokens

> Reskin the whole site by editing CSS variables in global.css — the design tokens, the tri-state dark mode, and how components resolve them.

Every color, radius, shadow, and font in an astro-ignite site resolves through a
small set of **CSS variables** declared in `src/styles/global.css`. Components
never hardcode a color — they read tokens like `--color-bg` and `--color-primary`.
So a full reskin is a handful of edits in one file, not a sweep across the tree.

## The token layer

Tokens live in a Tailwind v4 `@theme` block at the top of `global.css`. There are
two tiers:

- **The zinc scale** (`--color-zinc-50` … `--color-zinc-950`) — raw palette
  values, theme-invariant. The single source of truth for every neutral.
- **Functional tokens** — semantic names that _point at_ the scale. Components
  only ever reference these:

| Token | Role |
| --- | --- |
| `--color-bg` | Page background |
| `--color-surface`, `--color-surface-2` | Raised panels, insets |
| `--color-fg` | Body text |
| `--color-fg-muted`, `--color-fg-subtle` | Secondary / tertiary text |
| `--color-border`, `--color-border-strong` | Hairlines, stronger dividers |
| `--color-primary`, `--color-primary-fg` | Interactive fill + its text |
| `--color-ring` | Focus ring |
| `--color-success` / `--color-warning` / `--color-danger` | Functional status only |

Beyond color, the same block defines `--font-display` / `--font-mono`,
`--radius`(`-sm`/`-md`/`-lg`), `--shadow`(`-sm`), container widths, and the
`--ease-out-soft` motion curve.

<Callout variant="note" title="No accent by default">
  The shipped design has no accent color — the interactive color is just the
  inverted neutral (near-white on near-black). Introducing a brand accent is the
  most common reskin; the walkthrough below does exactly that.
</Callout>

## How components read tokens

Components express color through Tailwind v4 utilities that resolve a token —
either the theme-mapped shorthand (`bg-primary`, `text-fg`, `border-border`) or
the arbitrary form (`bg-[var(--color-bg)]`). For example, the `button` atom's
default variant is `bg-primary text-primary-fg`. Change `--color-primary` once
and every button, link, and focus state moves with it.

There is no separate stylesheet to keep in sync: `inlineStylesheets: 'always'`
ships the full stylesheet in the HTML on first paint, so a token edit is the
whole change.

## Tri-state dark mode

The design is **dark-first**. With no class on `<html>`, the `@theme` values
apply. A `.light` class flips the functional tokens to their light values:

```css
.light {
  --color-bg: var(--color-zinc-50);
  --color-fg: var(--color-zinc-950);
  --color-primary: var(--color-zinc-900);
  /* …the rest of the functional tokens, re-pointed at the scale */
}
```

The third state is "force dark on a light page" — a `.dark` class wins over
`.light`, wired through one declaration:

```css
@variant dark (&:where(:not(.light), :not(.light) *));
```

`ThemeToggle.astro` flips the `.light` class and persists the choice to
`localStorage`; an anti-flash inline script in `BaseLayout` applies the stored
preference before first paint, so there's no flicker. The first-visit default
differs by template: the **docs** template exposes it as
`siteConfig.defaultTheme` (`light` | `dark` | `system`, shipping `light`),
while the **starter** has no config knob — its anti-flash script falls back to
the system preference when nothing is stored.

## Walkthrough: a real reskin

Give the site a violet brand accent and slightly rounder corners — without
touching a single component.

<Steps>
  <Step title="Add your brand color to the scale">
    Declare the new palette values alongside the zinc scale in the `@theme`
    block, so both themes can point at them:

    ```css
    @theme {
      /* …existing zinc scale… */
      --color-brand: oklch(62% 0.21 280);
      --color-brand-fg: var(--color-zinc-50);
    }
    ```

  </Step>

  <Step title="Re-point the functional tokens">
    Make `--color-primary` your brand color (dark mode), and the focus ring a
    tint of it:

    ```css {3,4}
    @theme {
      /* functional tokens */
      --color-primary: var(--color-brand);
      --color-primary-fg: var(--color-brand-fg);
      --color-ring: var(--color-brand);
    }
    ```

    Do the same inside `.light` so the accent holds in light mode.

  </Step>

  <Step title="Round the corners">
    The whole UI scales off four radius tokens. Bump them once:

    ```css {2}
    @theme {
      --radius: 10px;
      --radius-sm: 8px;
      --radius-md: 12px;
      --radius-lg: 16px;
    }
    ```

  </Step>

  <Step title="Run it">
    `pnpm dev` and click around. Buttons, links, focus rings, badges, and cards
    all carry the new accent and radius — because they were reading tokens the
    whole time. No component edits, no find-and-replace.
  </Step>
</Steps>

<Callout variant="tip" title="Swap the typeface">
  Fonts are tokens too. Repoint `--font-display` / `--font-mono` and update the
  self-hosted font pipeline (`astro:fonts`) — see the FONTS deep dive in the
  template's `docs/` folder.
</Callout>

## Rules that keep it clean

- **Tokens only in components** — never `bg-zinc-900` or a raw hex. The zinc
  scale exists solely as the source of token values.
- **Add a token before a one-off color.** If two components need the same new
  color, it's a token.
- Keep the `.light` block in lock-step with `@theme`: every functional token
  you add needs a light value.

This is also the surface a planned `customize-theme` skill will drive — point
an agent at "make the site violet" and it edits these same tokens.
