Themes

A gray and an accent, chosen by contrast, and `askr theme`

# Themes

A theme is two choices: a **gray** for the surfaces, text and lines, and an
**accent** for buttons, links, focus rings and checked boxes. Lauf has 9
grays and 18 accents, and any gray goes with any accent — 162 themes, each
in light and dark.

```sh
askr new shop --theme=stone/teal
askr theme                    # the theme now, and the ones there are
askr theme olive rose         # another one
askr theme askr               # back to Lauf's own
```

The grays are `slate`, `gray`, `zinc`, `neutral`, `stone`, `mauve`,
`olive`, `mist` and `taupe`. The accents are `base` — the gray itself, for a
black-and-white look — and `red`, `orange`, `amber`, `yellow`, `lime`,
`green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`,
`purple`, `fuchsia`, `pink` and `rose`. The shades come from Tailwind's
palette. [askrcode.com/themes](https://askrcode.com/themes) shows them on
real components, and writes out the lines.

## What a theme is in your app

Two imports in `frontend/src/app.css`, after Lauf's own tokens:

```css
@import 'tailwindcss';
@import '@askrcode/lauf/theme.css';
/* askr theme: the lines from here to the end marker are askr theme's. */
@import '@askrcode/lauf/themes/stone.css';
@import '@askrcode/lauf/themes/accent/teal.css';
/* askr theme end */
```

That is all `askr theme` changes. It rewrites what is between the two
markers and nothing else, so the rest of the file is yours — including
your own overrides after the block. If you remove the markers, which you
may, `askr theme` writes nothing and prints the lines to put in instead.
A guess at where a line goes in a file somebody else wrote is worse than
asking.

Without a theme — the default, called `askr` — the block is empty and you
get Lauf's own palette: warm off-white, brown accent.

The files are CSS and nothing else. There is no JavaScript, no build step
and no runtime: a theme is chosen when the stylesheet is built, and the
browser gets the tokens it would have got if you had written them yourself.

## What is in the files

A gray sets `--color-surface`, `--color-raised`, `--color-fg`,
`--color-muted`, `--color-line`, `--color-danger` and `--color-danger-fg`.
An accent sets `--color-accent`, `--color-accent-fg` (text on the accent)
and `--color-accent-content` (the accent as text on the surface — a link, a
check mark). An accent file never touches a gray's tokens, which is why
they combine freely. `base` is written in the gray's own tokens —
`var(--color-fg)` — so it follows whichever gray is imported.

Each file has the three states Lauf's own `theme.css` has: light, dark by
the system unless light was chosen, and dark by choice. A theme switch in
your layout that stamps `data-theme` works the same with every theme.

The files are generated by `frontend/lauf/scripts/themes.mjs` from the
Tailwind version Lauf is built with, and `themes.json` beside them lists
every value. Don't edit them; a test fails if they differ from what the
script writes.

## Every shade is chosen by contrast

The script does not pick "600 for light, 400 for dark" and hope. It
measures, and takes the first shade that meets every rule:

- Text, muted text, danger text and the accent as text read at **4.5:1**
  on both surfaces — and muted text on the tints components lay under it,
  such as a tab's count.
- The accent stands out from **every** gray's surfaces by **3:1**. It is
  the focus ring and the fill of a checked box, and those are what you
  must be able to see.
- Text on the accent reads at 4.5:1: white where white reads, in any
  shade, before near-black in any. A teal button with black text is not
  what anyone expects; a darker teal with white is.
- A badge — accent or danger text on its own 15% tint — reads at 4.5:1.

The script holds itself to 4.6 and 3.1, because its arithmetic clips a
wide-gamut colour where a browser reduces its chroma, and the two can
differ in the second decimal. A colour that cannot meet all of it in some
shade stops the script rather than shipping.

Then it is measured twice more. `tests/themes.test.js` parses the files
as written — not the script's own numbers — and checks every gray with
every accent, light and dark. `./askr lauf:check` loads a sample of
themes into Chrome and runs axe with contrast over the playground, and
`./askr make:check` runs `askr theme` on a generated app and runs axe
over its pages in that theme.

## Your own

Override any token after the block:

```css
/* askr theme end */

:root { --color-accent: #0a7d6c; }
```

Nothing measures that. Give it the three states if it differs in dark
mode, and check it the way Lauf does: axe with contrast, in a real
browser, both themes.

## What is not here

**Light-mode yellow is ochre, and amber is brown.** Yellow-400 is what
people picture, and it is 1.5:1 against a white surface. As a focus ring
or a checked box that is invisible, so the script goes darker until it
reaches 3:1 — and a yellow that stands out from white is `yellow-700`.
Lime goes to 700 for the same reason; orange stands out at 600, but only
700 carries white text. In dark mode they are the bright shades you
expect, with dark text on them.

**No fonts, radii or spacing.** A theme is colour. `--radius-control`
and `--radius-surface` are tokens too, and you override them the same
way, but no theme sets them.

**No switching theme at runtime.** The theme is in the stylesheet. Light
and dark switch at runtime; the gray and the accent do not. An app that
lets each user pick an accent can scope the tokens under its own
attribute, but that is the app's code, not a theme.

**No theme from an arbitrary colour.** The palette is Tailwind's, because
those are shades someone has already balanced across the range. A
generator that took any hex and derived a range would be taking a guess
the contrast rules then have to rescue.