# Color

> How the five hues become a full light and dark palette, and the contrast gate that keeps every pair readable.

URL: https://apogee.avaui.dev/docs/color
Last updated: 2026-10-16

How the five hues become a full light and dark palette, and the contrast gate that keeps every pair readable.

Last updated October 16, 2026

Components never read a raw palette step. They read a hue and a role (`bg-primary`, `text-neutral-faded`, `text-on-critical`), so a theme is a token override, not a rebuild. The full role list is on [Design tokens](https://apogee.avaui.dev/docs/tokens); this page is about where the values come from.

## Five inputs

`scripts/hues.mjs` holds one colour per hue, by Tailwind name or as OKLCH:

```js
export default {
  primary: 'blue-700',   // a name is a Tailwind colour; 'blue' means 'blue-600'
  critical: 'red-600',
  warning: 'amber-400',
  positive: 'green-700',
  neutral: 'slate',      // the grey family: slate, gray, zinc, neutral, stone, mauve, olive, mist, taupe
}
```

Accents accept any name from `scripts/palettes.mjs` with a step from 400 to 700, or an object `{ l, c, h }` for a brand colour that is not in the palette.

The neutral entry tints every grey: page, borders, text, disabled states and the hover and selected fills. A grey name takes that family’s chroma and hue; chroma peaks at mid lightness and tapers to nothing at white and black. For a custom tint pass `{ l: 0.94, c, h }`, where `c` is the peak chroma and `c: 0` gives pure grey.

`npm run build:tokens` derives everything else and writes it between the `@generated` markers in `registry/tokens/theme.css`.

## What the generator derives

-   **Dark fill.** Lightness inverts around the midpoint, weighted by chroma, and is clamped so a dark brand never turns black. Chroma drops a little.
-   **Faded tint.** The fill at low alpha, so it composites over any surface.
-   **Hover steps.** A small move in lightness. If that move would push the on-colour under AA, the step goes the other way.
-   **Foreground.** The hue pulled toward the page’s text until it passes 4.5:1 on the page and on the faded tint.
-   **On-colour.** White when white passes 4.5:1 on the fill, black otherwise. `warning` is light, so its on-colour is black in both themes.
-   **Neutral extras.** Page, elevation steps, disabled set, inverted surface.

```astro
<Button>Primary</Button>
<Button color="critical">Critical</Button>
<Button color="positive" variant="faded">Positive</Button>
<Button color="warning">Warning</Button>
<Badge color="neutral">Neutral</Badge>
```

## Dark mode

Set `data-theme="dark"` on `<html>` or on any subtree. Both themes come out of the same inputs, so a brand change updates both. See [Dark mode](https://apogee.avaui.dev/docs/dark-mode).

```astro
<div data-theme="dark" class="rounded-lg bg-page p-4">
<Alert color="primary" title="Dark subtree">Everything inside recolours.</Alert>
</div>
```

## The contrast gate

`npm run check:contrast` reads the real values from `theme.css` and measures every pair the generator produces, in both themes:

-   each `--foreground-{hue}` on the page, on `elevation-raised`, and on its own faded tint;
-   each `--on-{hue}` on the fill and on the hovered fill;
-   `--foreground-neutral-faded` on the page and on the faded neutral fill;
-   `--on-inverted` on the inverted surface;
-   the focus ring on the page at 3:1;
-   body text on every elevation.

Faded tints are alpha colours, so the gate composites them over the page in gamma sRGB, the way a browser paints them, before measuring. It exits non-zero on the first miss, and `prepublishOnly` runs it.

## Overriding one token

Set the variable after the theme import:

```css
[data-brand="larkspur"] {
  --background-primary: oklch(0.45 0.18 300);
  --background-primary-highlighted: oklch(0.4 0.18 300);
  --foreground-primary: oklch(0.42 0.18 300);
  --on-primary: white;
}
```

A hand override skips the generator, so run the gate afterwards. Overriding a fill without its on-colour is the usual way to break AA.

## Do / Don’t

-   **Do** change `scripts/hues.mjs` and rebuild rather than editing generated lines.
-   **Do** keep `primary` dark enough for white text; the gate will tell you.
-   **Don’t** reach for a Tailwind palette step (`bg-blue-600`) in a component.
-   **Don’t** put `text-primary` on `bg-primary`; the on-colour exists for that.
