# Appearance tokens

Appearance is expressed as design tokens compiled to CSS custom properties. Editing colours in the admin panel changes tokens, never template files.

## The four layers

**1. Primitive** — raw colours and font stacks.

**2. Semantic** — role-based names that templates actually use:

`bg`, `surface`, `surface_soft`, `text`, `muted`, `border`, `primary`, `primary_hover`, `secondary`, `accent`, `success`, `warning`, `danger`

**3. Component** — button, card, table, header, sidebar, input and badge tokens, each defaulting to a semantic token:

```css
--card-bg: var(--color-surface);
--card-border: var(--color-border);
--card-radius: var(--radius-lg);
--button-primary-bg: var(--color-primary);
```

**4. Page** — per-page measures such as `--home-feed-gap`, `--question-max-width`, `--topic-max-width`, `--article-max-width`.

Layering means a site can restyle every card by changing one component token, without touching thirteen colour values.

## Typography

`font_body`, `font_heading`, `font_arabic` select from a fixed set of stacks. Fonts are served locally; the public shell does not depend on an external font CDN at runtime.

Sizes and weights (`body_size`, `body_line_height`, `h1_size`, `h1_weight`, `h2_size`, …) are validated dimensions and weights, not free-form CSS.

## Shape and shadow

`radius_sm`, `radius_md`, `radius_lg`, `radius_xl`, and the shadow scale.

## Validation

Before tokens are applied:

- colours must be `#RRGGBB`
- font stacks must be one of the known stacks
- dimensions must match the safe dimension pattern
- weights must fall between 400 and 900
- **text on surface and text on background must reach at least 4.5:1 contrast**

The contrast rule is not advisory. An administrator cannot save a palette that makes the site unreadable.

## Backward compatibility

Older installations that stored `theme.primary`, `theme.surface` and friends are mapped into the semantic defaults, so an upgrade preserves the existing appearance rather than resetting it.

## Layout tokens

`home_preset`, `question_preset`, `topic_preset`, `header_preset`, `density`, `sidebar_position` are normalised against an allow-list and emitted as `data-*` attributes on `<body>`:

```html
<body data-home-layout="community" data-density="comfortable" data-sidebar-position="end">
```

Stylesheets react to those attributes. Presets change composition; they do not replace templates.

## Where tokens end up

The compiled tokens render as a `<style data-theme-tokens>` block containing `:root` custom properties. Templates and theme CSS consume the properties, never hard-coded colour literals.

## Dark mode

Dark mode is a viewer preference stored in `localStorage` and stamped on the root element as `data-theme-mode="dark"`. It falls back to `prefers-color-scheme`, and an inline script in `<head>` applies it before first paint so the page never flashes light first.

The dark rules re-point surfaces, text and borders per theme. **Accent colours are overridden too**: leaving `--color-primary` and `--color-secondary` at their light values put every theme below the contrast floor on a dark surface — one at 1.80:1, which is effectively invisible. The dark accents keep each theme's hue and raise only lightness, and only as far as 4.5:1 requires.

Both modes are gated at the same floor:

| Pair | Threshold |
|---|---|
| `text` on `surface` | 4.5:1 |
| `text` on `bg` | 4.5:1 |
| `--color-primary` on the dark surface | 4.5:1 |
| `--color-secondary` on the dark surface | 4.5:1 |

`tests/Theme/DarkModeContrastRegression.php` measures every installed palette in both modes and fails the build below the floor, so a new theme cannot ship a dark mode nobody can read.
