# Polarfuchs (fox) design system — usage guide

Source at [gitlab.com/miranyx/fox-cdn](https://gitlab.com/miranyx/fox-cdn),
served at `https://cdn.m.foobar.vip/` (gallery of every icon + the palette at
the site root). Everything here shares one source of truth for
colors/fonts/radii (`web/fox-tokens.js`, in this repo) so a web tool, a
QWidgets app, a Kirigami/QML app, a Compose app and a Godot game can look like
the same product.

## Getting it

Six published artifacts, all built from the same source and sharing one
version number per release (`vN` — currently **v42**):

- **CDN** (this section and below) — `https://cdn.m.foobar.vip/`. Every
  release keeps a full `/vN/` snapshot live forever, e.g.
  `https://cdn.m.foobar.vip/v42/fox.css` — pin here for a version that will
  never change under you. Flat URLs with no version segment
  (`https://cdn.m.foobar.vip/fox.css`) are **frozen at whatever content they
  had when this repo took over serving** (the CSS/JS files) or **latest,
  in-place** (icons, fonts, `FoxTheme.qml`, the freedesktop theme files) —
  see this repo's README for exactly which is which. `/latest/` mirrors the
  current release and is the one path here that's deliberately *not*
  immutable.
- **npm** — [`@miranyx/fox-cdn`](https://gitlab.com/miranyx/fox-cdn/-/packages)
  (`fox.js`, `fox-icons.js`, `fox-sheets.js`, `fox-sentry.js`, `fox-dialog.js`,
  `fox-tokens.js`, `fox.css`, with generated `.d.ts`). `bun add
  @miranyx/fox-cdn`, registry
  `https://gitlab.com/api/v4/projects/85262907/packages/npm/` (works
  anonymously — only *publishing* needs auth). The source is plain JS with
  JSDoc types, and `tsc`'s JSDoc-to-declaration inference doesn't hold up
  over the 55-entry `ICONS` object, so the generated `.d.ts` can't give a
  literal icon-name union type — derive one locally from the package's real
  `ICON_NAMES`/`ICON_TEMPLATES` if you need
  it, the way
  [marks-novum does](https://git.foobar.vip/mira/marks-novum/-/blob/dev/frontend/scripts/generate-fox-icon-types.mjs).
- **Qt/native tarball** — a GitLab generic package, one archive with the
  icon SVGs, `.qrc`, the freedesktop theme JSON, `sync-fox-icon-theme.sh`,
  `FoxTheme.qml`, both `.qss`, all six font families in both web and desktop
  formats, and the six license files covering them:
  `https://gitlab.com/api/v4/projects/85262907/packages/generic/fox-qt/v42/fox-qt-v42.tar.gz`.
  See "Resolving icons by name" below for how to re-sync a bundled
  freedesktop theme from it offline, instead of one HTTPS request per icon.
- **Maven** (Kotlin/Compose Multiplatform, `jvm()` + `androidTarget()`) —
  `vip.foobar.polarfuchs:fox-icons-compose` (generated `ImageVector`s) and
  `vip.foobar.polarfuchs:fox-theme-compose` (`FoxColors`, `FoxTheme`, bundled
  Atkinson Hyperlegible), both at
  `https://gitlab.com/api/v4/projects/85262907/packages/maven`.
- **Godot 4 addon** — a GitLab generic package, one archive holding an
  `addons/fox/` folder: the `FoxTokens`/`FoxIcons`/`FoxMarks` scripts, all 60
  icon SVGs, the desktop-format files for all six font families (22 in total,
  no `woff2` — see "Font" below) and a license file for each:
  `https://gitlab.com/api/v4/projects/85262907/packages/generic/fox-godot/v42/fox-godot-v42.tar.gz`.
  Unpack it into your project root and the tokens are global classes — no
  autoload, no plugin to enable. See "Godot 4" below.
- **iced (Rust) tarball** — a GitLab generic package holding the `fox-iced`
  crate: the palette as `iced::Color` consts, an `iced::Theme`, all 60 icon
  SVGs and the same 22 desktop-format font files the Godot addon carries, each
  with its licence:
  `https://gitlab.com/api/v4/projects/85262907/packages/generic/fox-iced/v42/fox-iced-v42.tar.gz`.
  Unpack it beside your crate and depend on it by path. See "Rust (iced)"
  below.

## CSS (web)

```html
<link rel="stylesheet" href="https://cdn.m.foobar.vip/fox.css">
<script type="module">
  import PolarfuchsSystem from 'https://cdn.m.foobar.vip/fox.js';
</script>
```

(Existing consumers pinned at `?v=19`-style URLs from before this repo
existed keep working unchanged — see "Getting it" above. New consumers don't
need a query string at all; pin `/v42/fox.css` explicitly if you want a
specific release rather than the frozen legacy default.)

`fox.css` is the full design system: palette, typography, `.fox-btn`/
`.fox-input`/`.fox-settings`/etc. components, and the print-sheet layout
system used by card/puzzle generators. See the file itself — it's heavily
commented.

## Accessibility — motion, focus, hit targets

The reading settings near the end of this file hand the reader their typeface
and their text metrics. These hand over the rest of what is theirs: which
theme, how much the interface moves, whether they can see where they are, and
whether they can hit what they are aiming at.

Most of it is tokens you can point a setting at. Hit targets are mostly
rules, because no stylesheet can enforce them — and those rules are the part
that gets skipped, so they are written down here rather than assumed.

The gallery at the CDN root wires every one of these into a single
**Accessibility** panel, which is the working example the sections below
point at. Three sections at the end of this chapter — [Theme](#theme),
[A settings panel](#a-settings-panel) and [Resetting](#resetting) — are the
plumbing that panel is built from.

### Theme

`fox.css` follows the OS on its own via `prefers-color-scheme`; you get that
by including the stylesheet. `data-theme` on `<html>` is the override, and it
has **three** states, not two:

| `data-theme` | Result |
|---|---|
| absent | follow the OS — the default |
| `"light"` | force light regardless of the OS |
| `"dark"` | force dark regardless of the OS |

`initTheme()`, which `fox.js` runs for you on load, wires two kinds of
control to it. They can both be on the page at once; one writer owns the
attribute, so neither can leave the other stale:

```html
<!-- Flips between light and dark. fox.js sets its label and aria-pressed. -->
<button type="button" class="fox-btn fox-theme-toggle" data-fox-theme-toggle>Dark mode</button>

<!-- Says the third state too, which a button cannot. -->
<select data-fox-theme-select>
  <option value="">Match system</option>
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>
```

The empty value is the one worth having: picking it **removes** `data-theme`
and clears the stored key rather than saving a "system" sentinel, so the
reader genuinely goes back to following the OS. Without a select on the page
there is no way to express that once the button has been clicked.

The choice persists under `fox-theme` in `localStorage` and resolves
`localStorage` -> OS preference -> light, the same three steps
`initReadingPrefs()` uses. `.fox-theme-toggle` is worth putting on the button
alongside `.fox-btn`: it sets a `min-width` so the label flipping between
"Dark mode" and "Light mode" doesn't shift the layout around it.

A `foxthemechange` event fires on `document` after every change:

```js
document.addEventListener('foxthemechange', (e) => {
  e.detail.theme;   // 'dark' | 'light' - what is on screen right now
  e.detail.choice;  // 'dark' | 'light' | '' - what the READER picked
});
```

The two differ exactly when `choice` is `''`, which is the only way to tell
"the reader asked for dark" from "the OS says dark". It also fires when the
OS flips while the page is open *and* the reader is on "Match system" —
`fox.css` repaints on its own there, but anything you've drawn from
`detail.theme` (a canvas, a chart, an `<svg>` you colour by hand) needs
telling.

### A settings panel

`initSettings()` animates any `details.fox-settings` open and closed and
remembers the state. It needs three things — the key to persist under, a
`summary.fox-settings-header` to bind to, and a `.fox-settings-content`
wrapper to measure:

```html
<details class="fox-settings" data-fox-settings-key="a11y">
  <summary class="fox-settings-header">
    <div class="fox-settings-title-wrap">
      <span class="fox-settings-icon"><!-- 24px icon svg --></span>
      <span class="fox-settings-title">Accessibility</span>
    </div>
    <span class="fox-settings-chevron"><!-- chevronDown svg --></span>
  </summary>
  <div class="fox-settings-content">
    <p class="fox-settings-desc">What these controls do.</p>
    <div class="fox-cfg-grid">
      <!-- .fox-field-group + .fox-label + .fox-select / .fox-input -->
    </div>
    <div class="fox-btn-row"><!-- .fox-btn --></div>
  </div>
</details>
```

The open state persists under `fox-settings-open-<key>`. The gallery ships
its panel `open` because a collapsed demonstration of a panel demonstrates
nothing; in a real app you almost certainly want it closed, which is the
default without the attribute.

Don't put an `<h2>` inside the `<summary>`. Its content model takes phrasing
content, or a single heading as its *only* child, and the title/chevron
structure above is neither — a `<summary>` is already a named disclosure
button, so there's nothing to gain.

### Resetting

`initPrefsReset()` wires any `[data-fox-reset-prefs]` button to
`PolarfuchsSystem.resetPrefs()`, which you can also call directly:

```html
<button type="button" class="fox-btn" data-fox-reset-prefs>Reset to defaults</button>
```

It clears nine keys — `fox-theme`, `fox-font`, `fox-font-heading`,
`fox-motion`, `fox-line-height`, `fox-letter-spacing`, `fox-word-spacing`,
`fox-paragraph-spacing`, `fox-measure` — removes the attributes and inline
properties they set, and re-syncs every control **in place**. No reload: a
reload takes scroll position and form state with it, and a reset that costs
you your place in the page isn't much of a reset. Both `foxthemechange` and
`foxreadingchange` fire, so anything mirroring these values re-syncs without
needing to know it ran.

Two things it deliberately does *not* do. It doesn't clear
`fox-settings-open-*`: which panels you left open is a UI position rather
than a preference about how text renders, and collapsing the panel the reader
is standing in reads as a crash. And it doesn't sweep every key beginning
`fox-`, because your app's own keys start that way too and `fox.js` knows
nothing about them.

### Motion

`fox.css` honours `prefers-reduced-motion` on its own: transitions and
animations collapse to nothing, infinite animations stop repeating, and
smooth scrolling turns off. You get that by including the stylesheet.

What is new is the **override on top of it**, because the OS preference is a
single switch for an entire machine and it is regularly the wrong answer for
one app. A reader who turned it on because of one hostile site should not
have to choose between that site and yours. So `data-motion` on `<html>` —
the same one-attribute shape as `data-theme` and `data-font`:

```html
<html data-motion="reduced">   <!-- reduce it, whatever the OS says -->
<html data-motion="full">      <!-- don't, whatever the OS says -->
<html>                         <!-- no answer: the OS decides -->
```

Three states, not two, and the third is the default. `data-motion="full"`
needs no rules of its own — it simply stops the `prefers-reduced-motion`
block from matching, which is why it can beat a system preference.

Unlike `data-font`, this is **root-only**. Which face reads best can differ
between a reading pane and the chrome around it; how much movement is safe
cannot. Vestibular disorders are a property of the person at the screen, not
of a region of it.

**Wiring it to a settings UI** is `initReadingPrefs()` again, the same
function that handles the typeface and the metrics — a `<select>`, because a
checkbox cannot express the third state:

```html
<select data-fox-reading-pref="motion">
  <option value="">Match system</option>
  <option value="full">Full</option>
  <option value="reduced">Reduced</option>
</select>
```

Persisted under `fox-motion` in `localStorage`, restored on load, and
reported in the `foxreadingchange` event's detail alongside `font` and the
metrics. Values outside `full`/`reduced` are dropped rather than applied.

**Reading the state** — `--motion` resolves to `full` or `reduced`, and
`--motion-scale` to `1` or `0`, so anything the cascade cannot reach can ask:

```js
const reduced = getComputedStyle(document.documentElement)
    .getPropertyValue('--motion').trim() === 'reduced';
```

That is how you handle a canvas loop, a `<video autoplay>`, an SVG `<animate>`
or a scroll-driven effect — none of which a `transition-duration` rule
touches. Or fold the multiplier straight into a duration:
`calc(200ms * var(--motion-scale))`.

**One thing this cannot do for you.** Nothing may flash **more than three
times in any one second** (WCAG 2.1 SC 2.3.1). That is not a preference and
`data-motion` is not a defence: a flash that can trigger a seizure is a
hazard at `full` as much as at `reduced`, and a reader who has not found your
settings panel yet is exactly the reader at risk. If something in your UI
flashes faster than that, the fix is to make it not do that. The motion
switch is for comfort and vestibular safety (SC 2.3.3), not for this.

**Off the web**, no channel can read the OS preference for you, so each one
ships the vocabulary and a multiplier and you resolve the preference yourself:

| Channel | Where | Notes |
|---|---|---|
| QML | `FoxTheme.motion`, `.motionScaleFull`, `.motionScaleReduced` | `motion` is the *default*, not a reading of the machine — a generated `readonly` singleton cannot know. `duration: 200 * (reduce ? FoxTheme.motionScaleReduced : FoxTheme.motionScaleFull)`. |
| Godot | `FoxTokens.Motion.FULL` / `.REDUCED` / `.DEFAULT` / `.SCALE_FULL` / `.SCALE_REDUCED` | `create_tween().tween_property(node, "modulate:a", 0.0, 0.3 * scale)`. |
| Compose | `FoxMotion.Full` / `.Reduced`, `LocalFoxMotion`, `FoxTheme(motion = …)` | The one channel where a platform layer *can* detect it (Android's `TRANSITION_ANIMATION_SCALE`); pass the answer in. `tween((300 * LocalFoxMotion.current.scale).toInt())`. |
| QSS | **nothing** | Qt stylesheets have no `transition` or `animation` property, so there is no duration in a `.qss` file to scale. Drive a QWidgets app's animations from `QPropertyAnimation` and multiply there. |

The scales are `1` and `0`, and `0` means *instant*, not *skipped*: a
zero-length animation still completes and still emits its finished signal, so
code awaiting one cannot deadlock. `fox.css` uses `0.01ms` for the same
reason — `transition: none` never fires `transitionend`.

### Hit targets and pointer input

**`.fox-btn` is handled.** Its own box measures 39px tall — five pixels under
the 44×44 of WCAG 2.5.5, and not the kind of thing anyone catches by looking.
(A `<button>` does not inherit `line-height` from `body`: the UA's `font`
shorthand resets it, so the text sits at `normal` rather than at
`--line-height`. Worth knowing before you compute a control's height on
paper.) A pseudo-element grows the *hit* region to 44×44 without touching
layout or paint, so nothing moved for anyone and every button got easier to
hit.

**`.fox-input`, `.fox-select` and `.fox-textarea` are not**, and that is a
decision rather than an oversight. `<input>` and `<select>` are replaced
elements: they have no `::before`/`::after`, so the trick above is simply
unavailable, and the only remaining option is to make them ~4px taller — a
visible change to every form in every consuming app, to fix something the
design system cannot see the context of. It is one line if you want it:

```css
.fox-input, .fox-select, .fox-textarea { min-height: 44px; }
```

Worth pairing with `.fox-label`, which forwards clicks to the control it
labels and so is already part of the target. (For a `<select>` a label click
focuses without opening, so treat it as a complement, not a substitute.)

**In Qt**, do this with `setMinimumSize`, *not* a stylesheet `min-height`:
Qt's `min-height` addresses the content rectangle, so on a `foxClass="btn"`
(18px padding, 1px border) `min-height: 44px` yields a 62px widget.

```cpp
button->setMinimumSize(44, 44);
```

`FoxTokens.Focus`/`FoxTheme.focusWidth` have no target-size counterpart on
purpose — 44 is a platform constant, not a Polarfuchs one.

**Two rules no token can enforce**, both of which are about people whose hands
shake, who use a head pointer or a switch, or who are on a bus:

- **Never make an interaction drag-only** (SC 2.5.1). Every drag needs a
  single-pointer equivalent — a slider needs arrow keys and a number input,
  a reorderable list needs move-up/move-down, a drag-to-dismiss needs a close
  button. The drag can stay; it just cannot be the only way.
- **Activate on pointer-**up**, not pointer-down** (SC 2.5.2), so that
  pressing the wrong thing and sliding off cancels it. A native `<button>`
  and its `click` event already do this — you get it free, and the way it
  gets lost is a hand-rolled `pointerdown`/`mousedown` handler. That is the
  actual argument for using a real button element rather than a `<div>`.

### Focus

Every interactive `.fox-*` class draws the same ring on `:focus-visible`:
`.fox-btn` (and so `.fox-btn-primary`, `.fox-theme-toggle`), `.fox-input`,
`.fox-select`, `.fox-textarea`, `.fox-settings-header`, and the checkbox
inside `.fox-big-toggle`. Nothing to opt into; include the stylesheet.

| Token | CSS property | Light | Dark |
|---|---|---|---|
| `light.focusRing` / `dark.focusRing` | `--focus-ring` | `#3f8aab` | `#4488aa` |
| `focus.width` | `--focus-width` | `2px` | `2px` |

**These rings changed colour in this release.** They were `--ice`, which
measures **1.8:1** against `--snow` — a focus indicator a sighted keyboard
user cannot find, and a clear failure of SC 1.4.11 (3:1 for non-text
contrast). The values above clear 3:1 against `--snow`, `--dusk` **and**
`--ink`; the last matters because the ring's inner edge sits on
`.fox-btn-primary`'s filled face. If you want the old look back it is one
line, and now it is a choice you are making:

```css
:root { --focus-ring: var(--ice); }
```

**There is no shipped control for `--focus-width`,** and that's a decision
rather than an oversight. A free slider would let a reader set it *below*
2px — shipping a way to fail SC 1.4.11 from inside an accessibility panel is
worse than shipping nothing. It would also demo as a no-op, since a ring is
only visible while you're keyboard-navigating. Widening it is a one-liner
you can make on your users' behalf:

```css
:root { --focus-width: 4px; }
```

If you do wire it to a reader setting, give it a floor of `2px` and validate
against that floor, the way `initReadingPrefs()` range-checks its metrics.

There is no `--focus-offset` token. The offset is per-component, not
systemic: `.fox-btn` rings outside itself, `.fox-settings-header` rings
*inside* because `.fox-settings` clips its children. That is a call each
component makes, the way padding is.

**Why an `outline` and not a `box-shadow`.** Forced-colors mode (Windows
High Contrast and friends) replaces author colours with system ones and
**erases box-shadows entirely** — so a shadow-based ring vanishes in the one
display mode that exists for people who need maximum contrast. An outline is
kept and recoloured for you, which is why `fox.css` no longer sets
`outline: none` anywhere and why you should not either. Nothing here sets
`forced-color-adjust`; letting the OS win is the correct behaviour.

**Off the web:**

| Channel | Where | Notes |
|---|---|---|
| QSS | `QPushButton[foxClass="btn"]:focus`, the three input classes | Already applied in `fox-light.qss`/`fox-dark.qss`. Qt has **no `:focus-visible`**, only `:focus`, so the ring shows on a mouse click too. Not fixable in a stylesheet — an app that cares can branch on `Qt::FocusReason` in `focusInEvent()`. |
| QML | `FoxTheme.light.focusRing` / `.dark.focusRing`, `FoxTheme.focusWidth` | Qt Quick Controls draw their own focus frame; these are what you paint when you replace it. |
| Godot | `FoxTokens.Light.FOCUS_RING` / `.Dark.FOCUS_RING`, `FoxTokens.Focus.RING_WIDTH` | A `StyleBoxFlat` with `draw_center = false` on the `"focus"` theme style — see the constant's own doc comment. |
| Compose | `FoxColors.focusRing`, `FoxFocusWidth` | `Modifier.border` inside an `indication`, or a `FocusRequester`-driven border on `onFocusChanged`. |

## Dialog (web)

```html
<script type="module" src="https://cdn.m.foobar.vip/fox-dialog.js"></script>
```

Built on the native `<dialog>` element rather than a hand-rolled overlay
`<div>`, so the modal focus trap, Escape-to-close and focus-return to
whatever opened it are the platform's, not this file's. `fox-dialog.js`
adds the three things `<dialog>` doesn't give for free: backdrop-click
dismiss, severity-styled chrome, and two promise-based builders
(`confirm()`/`alert()`) for the case where you don't want to hand-author
markup at all.

### Markup contract

For custom content, write the `<dialog>` yourself - this is reference
markup, copyable verbatim, the same rule `a11yPanel()` follows for the
settings panel:

```html
<dialog class="fox-dialog fox-dialog--warning" id="my-dialog" aria-labelledby="my-dialog-title">
  <form method="dialog">
    <div class="fox-dialog-header">
      <span class="fox-dialog-icon fox-dialog-icon--warning"><!-- 22px icon svg --></span>
      <h2 class="fox-dialog-title" id="my-dialog-title">Delete this?</h2>
      <button type="submit" value="cancel" class="fox-dialog-close" aria-label="Close"><!-- 18px close icon --></button>
    </div>
    <div class="fox-dialog-body"><p>This can't be undone.</p></div>
    <div class="fox-dialog-actions fox-btn-row">
      <button type="submit" value="cancel" class="fox-btn" autofocus>Cancel</button>
      <button type="submit" value="ok" class="fox-btn fox-btn-primary">Delete</button>
    </div>
  </form>
</dialog>
```

`<form method="dialog">` is required for the buttons: a native form
submission inside it sets `dialog.returnValue` to the clicked button's
`value` and closes the dialog, so OK/Cancel need no click handlers at all.
`.fox-btn`/`.fox-btn-primary` are reused verbatim - Cancel plain, the
affirmative action primary. Note that `.fox-btn-primary` is a *modifier* and
carries only the filled face: it needs `.fox-btn` alongside it for the
padding, type, radius, focus ring and 44px hit area, exactly as
`.fox-theme-toggle` does. `aria-labelledby` should point at
`.fox-dialog-title`'s id; add `aria-describedby` pointing at
`.fox-dialog-body`'s id if there is one.

### Opening it

```html
<button type="button" class="fox-btn" data-fox-dialog-open="my-dialog">Delete</button>
```

`PolarfuchsDialog.init()` wires every `[data-fox-dialog-open="id"]` trigger
to `document.getElementById(id).showModal()`, and runs once automatically
on load - the same zero-JS-call convention as `fox.js`'s `data-fox-theme-toggle`
etc. It's safe to call `PolarfuchsDialog.init()` again after inserting new
dialog markup at runtime; both the trigger wiring and the dialog wiring are
idempotent.

### Dismissing it

Escape and focus-return to the button that opened it are native - nothing
to wire. Clicking the backdrop closes the dialog by default; opt out with
`data-fox-dialog-persistent` on the `<dialog>` for a confirmation that must
be answered explicitly:

```html
<dialog class="fox-dialog" data-fox-dialog-persistent>...</dialog>
```

Note this only affects backdrop-click - Escape itself isn't currently
interceptable by this attribute, a known limitation rather than an
oversight to work around later if a use case needs it.

One more native-form consequence to know about if you put a text field in a
dialog: the header `[x]` is a `type="submit"` button and is the first one in
tree order, which makes it the form's implicit-submission target. Pressing
Enter in a field therefore closes the dialog with `returnValue = "cancel"`.
`confirm()`/`alert()` have no fields, so this only affects hand-authored
content; if it matters, drop the `[x]` with `closable: false` (or omit it
from your markup) and rely on Escape and the action row.

### The `foxdialogclose` event

Fires on `document` whenever any `.fox-dialog` closes, for any reason
(button, Escape, backdrop, or a script calling `.close()` directly) -
mirrors `fox.js`'s `foxthemechange`/`foxreadingchange` convention:

```js
document.addEventListener('foxdialogclose', (e) => {
  console.log(e.detail.id, e.detail.returnValue); // e.g. "my-dialog", "ok"
});
```

### `confirm()` and `alert()`

For the common case, skip the markup entirely:

```js
const ok = await PolarfuchsDialog.confirm({
  title: 'Delete this?',
  message: "This can't be undone.",
  severity: 'warning',
});
```

```js
await PolarfuchsDialog.alert({ title: 'Saved', severity: 'success' });
```

Both build a `<dialog class="fox-dialog">` on the fly, show it modally, and
remove it from the DOM once it closes. `confirm()` resolves `true`/`false`;
`alert()` resolves once its one OK button is pressed. Options:

| Option | Default | Notes |
|---|---|---|
| `title` | required | — |
| `message` | none | set via `textContent`, safe against caller-supplied strings |
| `severity` | none | `'info'` \| `'success'` \| `'warning'` \| `'error'` |
| `okLabel` | `"OK"` | — |
| `cancelLabel` (`confirm()` only) | `"Cancel"` | — |
| `focusOk` (`confirm()` only) | `false` | Cancel is the safe autofocus target for a destructive confirm; opt in per call |
| `persistent` | `false` | disables backdrop-click dismiss |
| `closable` | `true` | show the header `[x]` button |

### Severity variants

| `severity` | Icon | Color token |
|---|---|---|
| `info` | `infoCircle` | `--ice` |
| `success` | `checkCircle` | `--moss` |
| `warning` | `warning` | `--dawn` |
| `error` | `xCircle` | `--dawn` |

`warning` and `error` deliberately share `--dawn` - it's the palette's only
"urgent" signal, and the two variants are distinguished by glyph, not hue.

The severity shows up in two places, and hand-authored markup has to set
both: `fox-dialog-icon--{severity}` on the icon chip, and
`fox-dialog--{severity}` on the `<dialog>` itself, which tints the header the
same way `.fox-banner--warning` tints a banner. `confirm()`/`alert()` set both
for you from the `severity` option.

### Caveats

The entrance/exit fade uses `@starting-style` and
`transition-behavior: allow-discrete` (Chrome/Edge 117+, Safari 17.5+,
Firefox 129+). Older browsers just skip the fade; `showModal()` still opens
the dialog instantly either way, so there's nothing to polyfill.

## Standalone PWA

A design system can hand a reader a hyperlegible face and their own line
height, and it still ends up in a browser tab among thirty others, behind a
URL bar, losing its settings whenever the tab is evicted. Being installable
is the other half of the same promise, so two starter files ship here.

**The constraint that shapes all of this:** a service worker only ever
controls the origin it is *served from*, and a manifest's `name`, `start_url`,
`scope` and icons are all facts about your app, not about a CDN. So neither
file is something you link to and forget. They are things you copy.

### The manifest

`https://cdn.m.foobar.vip/v42/fox-app.webmanifest` is a **template**. Copy
it to your own origin, replace every `REPLACE-ME`, and link *your* copy:

```html
<link rel="manifest" href="/app.webmanifest">
```

Two fields are worth keeping exactly as they are, because they're generated
from the same tokens as `fox.css` and so can't drift from the stylesheet
beside them:

| Field | Value | Token |
|---|---|---|
| `background_color` | `#fdfdfb` | `--snow` — the splash screen while your app boots |
| `theme_color` | `#e8eef2` | `--dusk` — the OS window chrome around it |

A manifest carries one `theme_color` and has no dark equivalent, so a
document-level `<meta>` is what actually tracks the theme. Both tags, in this
order, and the browser prefers the matching one:

```html
<meta name="theme-color" content="#e8eef2" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#12171b" media="(prefers-color-scheme: dark)">
```

`display` is `standalone` with a `display_override` of
`["window-controls-overlay", "standalone"]` — a desktop install gets its title
bar back as app surface where that's supported, and falls back to a plain
standalone window everywhere else.

The two `icons` entries point at the Polarfuchs mark
(`icon-mark-fox.svg` for `purpose: "any"`, `fox-app-icon-maskable.svg` for
`purpose: "maskable"`) so a freshly copied manifest installs instead of
404ing. **They say "Polarfuchs", not "your app" — swap them.** The maskable
one is the mark on a solid `--dusk` field, sized into the inner 80% safe zone
so a launcher can crop it to a circle or a squircle without clipping the ears.

Both are SVG, `"sizes": "any"`. This repo has no rasterizer in its toolchain
and won't grow one to ship PNGs of someone else's logo; if you need raster
icons for Android home-screen fidelity, rasterize your own at 192 and 512.

### The service worker

`fox-sw.js` caches **this design system's** assets — `fox.css`, the six font
families, the icon SVGs — stale-while-revalidate, and touches nothing else.
The reason it's worth having: a reader who set their face and their measure
and then installed the result should get an app that still opens, still
themed and still legible, on a train with no signal. A standalone window that
half-loads into unstyled text is a worse offline story than not being
installable at all.

You cannot register it from this origin — browsers reject a worker script
from another origin outright. Import it from a one-line file at *your* origin
instead:

```js
// sw.js, served from YOUR origin
importScripts('https://cdn.m.foobar.vip/v42/fox-sw.js');
```

```js
// in your app
navigator.serviceWorker?.register('/sw.js');
```

It's plain classic-script JavaScript rather than the ES modules the rest of
this CDN serves, because `importScripts()` takes nothing else.

It deliberately does **not** call `skipWaiting()` or `clients.claim()`, and it
only calls `respondWith()` for requests to this CDN. Those are decisions about
*your* app's update and offline behaviour, so make them in your `sw.js`
alongside the import — your own `fetch` listener keeps owning your own
requests. If you mirror the CDN, point it at your mirror before importing:

```js
self.FOX_CDN_ORIGIN = 'https://cdn.example.internal';
importScripts('https://cdn.example.internal/v42/fox-sw.js');
```

Self-hosting the whole CDN? `application/manifest+json` is not in stock nginx
`mime.types`, so `.webmanifest` falls through to `application/octet-stream`
and every browser rejects it — an app that looks installable in DevTools and
simply isn't. See this repo's `nginx/nginx.conf` for the `types` entry.

## Print (CMYK)

`fox.css`'s `@media print` rules and `fox-sheets.js` are **browser** printing:
they emit sRGB, and no browser produces CMYK. That path is fine for a home
printer and is **not** a press-ready path. For a professional print service,
use the artifacts below.

```
fox-palette-print.json   machine-readable spec: CMYK, Lab, pair report
fox-print.html           printable spec sheet — hand this to the shop
fox-fogra39.ase          Adobe swatch library (ISO Coated v2 / FOGRA39)
fox-fogra51.ase          Adobe swatch library (PSO Coated v3 / FOGRA51)
```

Only the **light** palette's 12 solid tokens are separated — paper has no dark
mode, and `.fox-sheets` already pins the light literals for exactly that
reason. The `.ase` files import into Illustrator, InDesign and Scribus.

### Which FOGRA target

Ask the shop; it is their call, not yours. FOGRA51 (PSO Coated v3) is the
current standard and assumes paper with moderate optical brighteners; FOGRA39
(ISO Coated v2) is the older one many presses still run. Both sets are
generated so you don't have to guess. Neither characterises synthetic stock —
on non-paper substrates these are a starting point the shop's own profile
supersedes.

### `snow` is the substrate, not an ink

`snow` separates to roughly 0/0/0/0, which means *whatever the stock is* — not
`#fdfdfb`. Two consequences:

- Never lay a flat near-white tint across a card to "paint" the background.
- **Anything relying on a `snow`-vs-white distinction disappears.**
  `.fox-cutmark` uses `background: var(--snow)` over the sheet, which on paper
  is substrate on substrate. It needs a different treatment for press.

### Print layouts must be flat

Press has no alpha channel. Any translucent layer has to be pre-composited
against the substrate and separated as a solid, or it cannot exist on the card.

Today exactly one translucent layer reaches paper: `.fox-safe-margin-guide`
draws its rule in `rgba(var(--tint-hairline), 0.5)`, separated as the
pre-composited token `tintHairline50`. `--shadow-ink` never prints (the print
block sets `box-shadow: none`), and the remaining tint tokens are screen-only
chrome. If you add a translucent print element, add a composited token for it —
don't ship an alpha.

### Die cut (Stanzkontur)

The cutting contour is **not** a palette token. It is an explicit device
colour: `Stanzkontur`, 0/100/0/0, **spot**, set to overprint, 0.25pt stroke, on
its own layer, no fill. It ships in both `.ase` files flagged as a spot so it
separates onto its own plate — the shop removes that plate before press.

Verify it survived the import: open the separations preview in your layout tool
and confirm `Stanzkontur` is a plate of its own rather than a magenta object on
the artwork. If your tool flattens it on import, define it in the layout
instead.

### Reading the pair report

The spec sheet leads with **which pairs collapse**, not with per-colour
accuracy. That is deliberate: a colour can survive separation on its own while
its pairing partner shifts the other way, and the pair stops being
distinguishable. This palette encodes categories (see the 7-way register
encoding in `fox.css`), so distinguishability is the property that matters.

- **ΔE2000 ≥ 10** gates *every* pair, scored under normal, deuteranopia,
  protanopia and tritanopia vision.
- **Contrast ≥ 4.5:1** gates only pairs where one member is type. Contrast is
  reported for every pair but doesn't fail a non-text one — two category bands
  need to be told apart, not to have a luminance ratio between them. WCAG
  contrast is also relative-luminance only, so it is colourblind-agnostic by
  construction and is reported at normal vision only.

One row carries a caveat: **`hairline`/`snow` is scored for colour only.** A
colorimetric round trip models neither dot gain nor thin-line reproduction, so
nothing in the report tells you whether a 0.25pt dashed rule holds on stock.
That is a proof question — a passing colour cell is not an assurance about the
rule.

### Regenerating

The CMYK numbers are checked in (`web/fox-print-tokens.ts`) rather than
computed at build time, because the ECI profiles can't be redistributed. To
re-separate after a palette change you need `lcms2-utils` and both profiles —
see `profiles/README.md` — then:

```sh
bun run separate-print   # writes web/fox-print-tokens.ts (needs profiles)
bun run generate         # rebuilds the artifacts from it (needs nothing)
```

## QSS (C++ / QWidgets apps)

`fox-light.qss` / `fox-dark.qss` cover the same interactive UI chrome as
fox.css's component classes (buttons, inputs, toolbar/settings/group-box
framing, banners, save-state pill) — **not** the print-sheet/card layout
system, which has no equivalent in a desktop widget stylesheet.

```cpp
QFile f(":/fox-light.qss"); // or fox-dark.qss, or fetched at runtime
f.open(QFile::ReadOnly);
qApp->setStyleSheet(f.readAll());
```

Qt has no CSS-class concept, so components are selected by a `foxClass`
dynamic property instead of a class name — set it explicitly on each widget:

```cpp
auto *save = new QPushButton("Save");
save->setProperty("foxClass", "btn-primary");

auto *cancel = new QPushButton("Cancel");
cancel->setProperty("foxClass", "btn");
```

Available `foxClass` values: `btn`, `btn-primary` (QPushButton),
`input`/`select`/`textarea` (QLineEdit/QComboBox/QPlainTextEdit), `toolbar`/
`settings`/`group-box` (QFrame), `settings-title`/`group-title`/`banner`/
`banner-warning`/`savestate`/`totals`/`h1`/`h2`/`h3`/`h4`/`h5`/`h6` (QLabel).
The save-state pill also takes a `foxState` property: `dirty` or `error`.

**Gotcha:** unlike a browser, Qt does not re-poll the stylesheet when a
dynamic property changes on an already-shown widget. After changing
`foxClass`/`foxState` at runtime, force a re-evaluation:

```cpp
widget->style()->unpolish(widget);
widget->style()->polish(widget);
```

**Theme switching:** QSS has no media queries or custom properties, so
light/dark are two separate static files — pick one based on your own theme
detection and call `setStyleSheet()` again when it changes.

**Focus and hit targets:** the `:focus` rules in both files are already
painted in the focus-ring token, but Qt has no `:focus-visible` and QSS
`min-height` is not the border box — see "Accessibility" above before you
reach for either.

## QML / Kirigami

QSS does **not** apply to QML/Kirigami controls — for that stack, use the
generated `FoxTheme.qml` singleton instead:

```
# qmldir, next to your copy of FoxTheme.qml
singleton FoxTheme 1.0 FoxTheme.qml
```

Besides the palette and type below, the singleton carries `focusWidth` and
the `motion` / `motionScaleFull` / `motionScaleReduced` trio — see
"Accessibility" above for what to do with them.

```qml
import "." as Fox

Rectangle {
    color: Kirigami.Theme.darkMode ? Fox.FoxTheme.dark.snow : Fox.FoxTheme.light.snow
    Text {
        color: Kirigami.Theme.darkMode ? Fox.FoxTheme.dark.ink : Fox.FoxTheme.light.ink
        font.family: Fox.FoxTheme.fontSans
    }
}
```

`FoxTheme.light.*` / `FoxTheme.dark.*` expose the same palette as the QSS
files (`ink`, `inkSoft`, `snow`, `dusk`, `hairline`, `ice`, `moss`, `dawn`,
`birch`, `slate`, `aurora`, `btnPrimaryHover`), plus top-level `fontSans`/
`fontMono`/`fontSerif`/`radiusUi`/`radiusCard`.

For headings, point Kirigami's own `Kirigami.Heading` component at
`fontSans` — it already scales `font.pointSize` per `level`, so only
`font.family` needs to be set to pick up the design system's face instead of
the platform default. Headings use the same face as body text here; give them
`fontSerif` (or any of the alternates) instead if you want them to break away:

```qml
import "." as Fox
import org.kde.kirigami as Kirigami

Kirigami.Heading {
    level: 1 // 1-5, matches Kirigami's own scale
    font.family: Fox.FoxTheme.fontSans
    color: Kirigami.Theme.darkMode ? Fox.FoxTheme.dark.ink : Fox.FoxTheme.light.ink
    text: "Polarfuchs"
}
```

## Kotlin (Compose Multiplatform)

```kotlin
// build.gradle.kts
dependencies {
    implementation("vip.foobar.polarfuchs:fox-theme-compose:42.0.0")
    implementation("vip.foobar.polarfuchs:fox-icons-compose:42.0.0")
}
```

(Maven coordinates and the registry URL are under ["Getting it"](#getting-it)
above — same one-version-per-release scheme as the other three artifacts.)

```kotlin
import vip.foobar.polarfuchs.theme.FoxTheme
import vip.foobar.polarfuchs.theme.LocalFoxColors

@Composable
fun App() {
    FoxTheme {
        Text("Hello", color = LocalFoxColors.current.ink)
    }
}
```

`FoxTheme { }` is the Compose equivalent of `FoxTheme.qml`'s singleton:
`darkTheme` defaults to `isSystemInDarkTheme()`, matching how `fox.js`'s
`initTheme()` and the QML dark-mode checks both fall back to the system
preference — pass it explicitly to override. `motion` is the same kind of
parameter and provides `LocalFoxMotion`, but does *not* default to a system
reading, because Compose Multiplatform has no common API for one — see
["Accessibility"](#accessibility--motion-focus-hit-targets) above. It provides `LocalFoxColors`
(the same palette named above — `ink`, `dusk`, `dawn`, etc. — read via
`LocalFoxColors.current`) and also builds an additive Material 3
`ColorScheme` from it, so Material components dropped into a Polarfuchs
Compose app aren't stranded without one. `FoxShapes` (and the underlying
`FoxRadiusUi`/`FoxRadiusCard` `Dp` values) mirror the same `radius.ui`/
`radius.card` tokens QSS and QML round their corners to.

Both artifacts publish JVM and Android variants under the same
Maven coordinate, via Gradle Module Metadata — the same `implementation(...)`
line above resolves to the right variant whether the consuming module is a
JVM/desktop app or a real Android app, no separate Android-specific
dependency needed.

## Godot 4

Minimum **Godot 4.4** (typed `Array[String]` constants and `.uid` handling);
verified against **4.7.1**. No Mono/C# requirement — the addon is GDScript and
data.

Download the [Godot addon tarball](#getting-it) and unpack `addons/fox/` into
your project root:

```sh
curl -sSfL -o fox-godot-v42.tar.gz "https://gitlab.com/api/v4/projects/85262907/packages/generic/fox-godot/v42/fox-godot-v42.tar.gz"
tar -xzf fox-godot-v42.tar.gz
cp -r fox-godot-v42/addons/fox path/to/project/addons/
```

`addons/fox/VERSION` holds the bare release number (`42`) so Renovate and your
own drift checks have something machine-readable to read.

Open the project once (or run `godot --headless --import`) and that's the whole
install. If that first import crashes, just run it again — Godot 4.7.1's font
importer has a race that hits roughly 1 in 10 cold imports of *any* project
carrying several `.ttf`s (reproduced with unrelated fonts, and absent when the
fonts are removed). The pass is resumable, so a second run finishes it, and it
never recurs once the cache exists. **There is nothing to enable.** `FoxTokens`, `FoxIcons` and `FoxMarks`
are `class_name` globals whose values are compile-time constants, so they
resolve with full autocompletion and cost nothing at runtime — no autoload, no
edit to your `project.godot`. `addons/fox/plugin.cfg` exists only so the addon
shows up under Project → Plugins and carries a version; enabling it is optional
and does nothing.

```gdscript
var ink := FoxTokens.Dark.INK if dark else FoxTokens.Light.INK
var radius := FoxTokens.Radius.CARD          # int, for corner_radius_*
var font := load(FoxTokens.Fonts.SANS_REGULAR)
```

`FoxTokens.Light` / `FoxTokens.Dark` carry all 19 tokens — the 13 solid palette
colours (`INK`, `INK_SOFT`, `SNOW`, `DUSK`, `HAIRLINE`, `ICE`, `MOSS`, `DAWN`,
`BIRCH`, `SLATE`, `AURORA`, `BTN_PRIMARY_HOVER`, `FOCUS_RING`) plus the six
overlay/tint tokens. Unlike QSS and QML, which skip the overlays entirely, Godot gets them:
`StyleBoxFlat`, `modulate` and `Color` all handle alpha. They arrive **opaque**,
the same contract the Compose channel uses, so apply the alpha at the call site
the way `fox.css` writes `rgba(var(--tint-ice), 0.4)`:

```gdscript
var tint := FoxTokens.Light.TINT_ICE
tint.a = 0.4
```

`FoxTokens.Fonts` gives `SANS_FAMILY`/`MONO_FAMILY`/`SERIF_FAMILY` (for
matching an installed font), the full CSS `*_STACK` strings, and `res://` paths
to all four bundled weights, plus `SANS_ALL` listing them. Nothing here touches
the network — the fonts and icons are files inside the addon, the same rule the
web channel follows.

### Assembling a Theme

The addon deliberately ships **no** prebuilt `Theme` resource. A `.tres` binds
fonts and textures by project-relative `res://` path and UID, so a shipped one
would assume your folder layout, carry `.import` metadata from whatever project
built it, and need two variants to express light and dark. Twenty lines of
assembly is the cheaper contract:

```gdscript
static func build(dark: bool) -> Theme:
	var ink := FoxTokens.Dark.INK if dark else FoxTokens.Light.INK
	var snow := FoxTokens.Dark.SNOW if dark else FoxTokens.Light.SNOW
	var dusk := FoxTokens.Dark.DUSK if dark else FoxTokens.Light.DUSK
	var hairline := FoxTokens.Dark.HAIRLINE if dark else FoxTokens.Light.HAIRLINE

	var theme := Theme.new()
	theme.default_font = load(FoxTokens.Fonts.SANS_REGULAR)
	theme.default_font_size = 16

	theme.set_stylebox("panel", "PanelContainer", _flat(snow, hairline, FoxTokens.Radius.CARD))
	theme.set_stylebox("normal", "Button", _flat(dusk, hairline, FoxTokens.Radius.UI))
	theme.set_color("font_color", "Button", ink)
	theme.set_color("icon_normal_color", "Button", ink)
	theme.set_color("font_color", "Label", ink)

	theme.set_type_variation("HeaderLabel", "Label")
	theme.set_font("font", "HeaderLabel", load(FoxTokens.Fonts.SANS_BOLD))
	theme.set_font_size("font_size", "HeaderLabel", 24)
	theme.set_color("font_color", "HeaderLabel", ink)

	return theme

static func _flat(bg: Color, border: Color, radius: int) -> StyleBoxFlat:
	var box := StyleBoxFlat.new()
	box.bg_color = bg
	box.border_color = border
	box.set_border_width_all(1)
	box.set_corner_radius_all(radius)
	return box
```

That is the actual file the design system's own smoke test builds from
(`godot/smoke/theme_builder.gd` in this repo), not a snippet written for the
docs — it is compiled and rendered in both palettes on every pipeline.

### Icons in Godot

```gdscript
button.icon = load(FoxIcons.CHECK)
texture_rect.texture = load(FoxMarks.FOX_HEAD)
texture_rect.modulate = FoxTokens.Light.INK
```

`FoxIcons.ALL` and `FoxMarks.ALL` list every path, for a gallery or picker.
They are paths rather than `preload()`ed textures on purpose: a project using
two icons shouldn't pay to load sixty.

**The Godot SVGs are stroked white, not inked like the Qt ones.** Godot's
`modulate` and the `icon_normal_color` theme colour both *multiply* the source,
so a white drawing reproduces any palette colour exactly while a dark one could
only ever be darkened — the dark theme's `#e7edf1` ink would be unreachable.
This is the same reasoning behind the Compose channel building every
`ImageVector` in `Color.Black` and tinting via `Icon(tint = ...)`. The
consequence to expect: the icons look blank in the FileSystem dock's thumbnail
against a light background. They are there.

**No `.import` files ship with the addon.** Your editor generates them on first
open (and `godot --headless --import` does it in CI). They carry a `uid://` and
a path into your project's own `.godot/imported/` cache, so they belong to your
project rather than to the design system, and a sidecar written by one Godot
point release can be rewritten by the next — which would churn your VCS every
upgrade. Nothing in the addon is referenced by UID: the tokens resolve by global
class name and `plugin.cfg` by path, so there is nothing for a regenerated UID
to break.

## Rust (iced)

Requires **[iced](https://iced.rs) 0.14**. The crate is
[`fox-iced`](https://gitlab.com/miranyx/fox-cdn/-/tree/main/iced/fox-iced) —
tokens, an `iced::Theme`, all 60 drawings and all six font families, with no
runtime dependency beyond `iced_core`.

Download the [iced tarball](#getting-it) and unpack the crate beside your own:

```sh
curl -sSfL -o fox-iced-v42.tar.gz "https://gitlab.com/api/v4/projects/85262907/packages/generic/fox-iced/v42/fox-iced-v42.tar.gz"
tar -xzf fox-iced-v42.tar.gz
cp -r fox-iced-v42/fox-iced vendor/
```

```toml
# Cargo.toml
[dependencies]
fox-iced = { path = "vendor/fox-iced" }   # version 42.0.0
iced = { version = "0.14", features = ["svg"] }
```

The `svg` feature is only needed if you render the icons; the tokens, theme and
fonts work without it. `fox-iced` itself depends on **`iced_core`, not on
`iced`** — every type it hands you (`Color`, `Font`, `Theme`, `svg::Handle`) is
one `iced` re-exports unchanged, so they unify with yours, and a crate that only
wants the palette doesn't pull in a renderer.

### Tokens

```rust
use fox_iced::{tokens, FoxColors};

let colors = FoxColors::of(dark);        // or FoxColors::LIGHT / ::DARK
let ink = colors.ink;                    // iced::Color
let radius = tokens::radius::CARD;       // f32, for Border::radius
```

`FoxColors::LIGHT` / `FoxColors::DARK` carry the same 19 tokens the Godot
channel does — the 13 solid palette colours (`ink`, `ink_soft`, `snow`, `dusk`,
`hairline`, `ice`, `moss`, `dawn`, `birch`, `slate`, `aurora`,
`btn_primary_hover`, `focus_ring`) plus the six overlay/tint tokens. Every field
is a `const`, so nothing is computed at startup.

The overlay/tint tokens arrive **opaque**, the same contract Compose and Godot
use — they exist to be layered at a caller-chosen alpha, the way `fox.css`
writes `rgba(var(--tint-ice), 0.4)`:

```rust
let scrim = tokens::alpha(colors.shadow_ink, 0.4);
```

### Theme

```rust
use fox_iced::theme;

iced::application(App::new, App::update, App::view)
    .theme(|app: &App| theme::of(app.dark))    // or theme::light() / theme::dark()
    .run()
```

iced's `Palette` has six slots where Polarfuchs has nineteen, so
`theme::PALETTE_LIGHT`/`PALETTE_DARK` are a **lossy, additive** mapping —
`background ← snow`, `text ← ink`, `primary ← ice`, `success ← moss`,
`warning ← birch`, `danger ← dawn`. It exists so Polarfuchs apps aren't stranded
on iced's built-in `Light`/`Dark`, not to replace the token set: reach past it to
`FoxColors` for anything those six slots cannot say. The same relationship the
Compose channel's `toMaterial3ColorScheme` has to `FoxColors`.

### Fonts

All six families ship as bytes inside the crate — nothing is fetched at runtime.
Hand them to iced once at startup and then address a face by `Font`:

```rust
use fox_iced::fonts;

let mut app = iced::application(App::new, App::update, App::view)
    .default_font(fonts::SANS);

for bytes in fonts::ALL_BYTES {
    app = app.font(*bytes);
}

app.run()
```

`fonts::SANS`/`MONO`/`SERIF` are the design system's defaults;
`LUCIOLE`/`LEXEND`/`OPENDYSLEXIC` are the opt-in alternates and nothing points at
them until a reader does. Each family also carries `*_FAMILY` (the name, for
matching an already-installed font), `*_STACK` (the full CSS stack, if your app
also renders HTML), `*_BOLD`/`*_ITALIC`/`*_BOLD_ITALIC` where upstream publishes
them — **Lexend has no italic**, so use `fonts::FAMILIES` rather than assuming
four — and `*_BYTES` listing just that family's files, for an app that loads one
face on demand instead of all 22 up front.

### Icons

```rust
use fox_iced::{FoxIcon, FoxMark};
use iced::widget::svg;

svg(FoxIcon::Close.handle())
    .width(24)
    .height(24)
    .style(move |_, _| svg::Style { color: Some(colors.ink) })
```

`FoxIcon` is the 55-icon UI set on the 24×24 grid; `FoxMark` is the five brand
marks, each at its own native viewBox. Two enums rather than one because both
sets define a `paw`. `FoxIcon::ALL`/`FoxMark::ALL` list them for galleries and
pickers, `.name()` gives the design-system name (`"chevronDown"`) and
`FoxIcon::from_name` resolves one back.

The drawings are stroked in the **light theme's ink**, not `currentColor`, which
Rust has no equivalent of. That is not a limit: iced's `svg::Style { color }` is
a *replace* filter, not a multiply like Godot's `modulate`, so setting it to any
`FoxColors` value — including the dark theme's ink — works from a coloured
source. Leave it unset and the icon still reads on a light background.

### Motion and reading settings

`tokens::motion` and `tokens::typography` carry the same reader-facing values
every other channel does, as bare numbers (iced has no CSS units):

```rust
let scale = if reduce { motion::SCALE_REDUCED } else { motion::SCALE_FULL };
let fade = Duration::from_secs_f32(0.3 * scale);
```

`SCALE_REDUCED` is `0.0` rather than a signal to skip the animation, so an
`iced::animation` still reaches its end state and whatever awaits it cannot
stall. `DEFAULT` is the *design system's* default, not a reading of this
machine — iced exposes no reduced-motion query, so resolving the reader's
preference is your app's job (a settings screen, ideally). See "Reading
settings" below for the same argument at length.

`typography::LINE_HEIGHT` goes straight into `text::LineHeight::Relative`;
`LETTER_SPACING`, `WORD_SPACING` and `PARAGRAPH_SPACING` are em, so multiply by
the text size; `MEASURE` is a character count, so multiply by your font's average
advance for a wrap width.

### Caveats

**No widget styles ship.** iced styling is a per-widget closure over a `Theme`,
so a "fox button" would have to guess at your widget tree — the same reason the
Godot channel ships no prebuilt `Theme` resource. The tokens and the `Theme` are
what the design system owns; the `container::Style`/`button::Style` closures over
them are yours. `iced/gallery/` in this repo is a worked example of the whole
surface.

**The crate is generated.** Every file under `fox-iced/` comes out of
`generators/generate-fox-iced.ts`, and CI's `drift-guard` fails if a checked-in
byte disagrees. Hand-edits are overwritten by the next release, so send changes
to `web/fox-tokens.ts` or `web/fox-icons.ts` instead.

## Icons

**Web (JS):**

```js
import { icon, ICONS, iconAttrs, hasIcon } from 'https://cdn.m.foobar.vip/fox-icons.js';
// or: import { icon, ICONS, iconAttrs, hasIcon } from '@miranyx/fox-cdn/fox-icons.js';

el.innerHTML = icon('foxHead', 34, '#26343f'); // template-string API
```

Two sets, kept separate because they're drawn to different conventions:

`icon(name, size, strokeColor)` / `ICON_TEMPLATES` — the five illustrative
fox marks (`paw`, `sleepingFox`, `foxHead`, `sittingFox`, `scissors`), full
SVG strings, one per icon at its own native viewBox.

`ICONS` / `iconAttrs` / `hasIcon` — 55 icons of path/dot data on a uniform
24×24 stroke grid (stroke-width 1.7, `currentColor`, round caps and joins).
Assemble into an `<svg>` yourself, or via a framework component. Circles and
rounded rectangles are arc path data rather than `<circle>`/`<rect>`, so the
data shape stays exactly `{paths, dots}` and `dots` keeps meaning *filled*:

- **Marks** — `fox`, `paw`, `lemming`, `blahaj`, `blahajSmall`, `ice`,
  `syringe`, `aurora`, `book`, `branch`, `route`, `flame`, `lock`, `check`,
  `arrowUp`, `gear`
- **Actions** — `menu`, `close`, `plus`, `minus`, `trash`, `pencil`,
  `search`, `copy`, `download`, `share`, `externalLink`, `undo`, `refresh`,
  `cloudUpload`, `more`, `filter`, `sliders`, `calendar`, `clock`
- **Navigation** — `home`, `arrowLeft`, `arrowRight`, `arrowDown`,
  `chevronDown`, `chevronUp`, `chevronLeft`, `chevronRight`,
  `sidebarCollapseLeft`/`-Right`, `sidebarExpandLeft`/`-Right`
- **Status** — `xCircle`, `checkCircle`, `infoCircle`, `helpCircle`,
  `warning`, `spinner`
- **Theme** — `sun`, `moon`

`sliders` is deliberately distinct from `gear`: a gear *opens* settings,
sliders *are* the settings. `blahaj` and `blahajSmall` are the 100 cm and the
55 cm plush, and the difference between them is *proportion, not scale* —
both fill the 24×24 box to the same optical weight, and the small one reads
smaller because it is stubbier, blunter in the snout and larger in the eye.
Rendering `blahaj` at a smaller px size does not give you `blahajSmall`.
`spinner` is a static three-quarter arc — the
data set carries no colour and no motion, so animating it is your CSS.
Status glyphs are the glyph third of a status; colour and a label are the
other two, and none of them may be the only carrier of meaning.

**Qt (C++ / QML):** `fox-icons.qrc` lists 60 standalone SVG files (also
served individually, e.g. `https://cdn.m.foobar.vip/icon-fox-head.svg`),
default-colored with the light-theme ink (`#26343f`) since static QIcon/QML
SVG loading has no `currentColor` story the way a browser does — tint with
`Kirigami.Icon`'s `isMask` instead. The `ICON_TEMPLATES` set is named
`icon-<name>.svg` (`icon-paw.svg`, `icon-sleeping-fox.svg`,
`icon-fox-head.svg`, `icon-sitting-fox.svg`, `icon-scissors.svg`); the
`ICONS` set is `icon-mark-<name>.svg` (the `mark-` prefix avoids a collision
— both sets happen to define a `paw`).

Shipped as source XML + raw SVGs, not a compiled `.rcc` — there's no Qt
toolchain in the mtools repo to produce one. In your own CMake project:

```cmake
qt_add_resources(APP_RESOURCES fox-icons.qrc)
target_sources(myapp PRIVATE ${APP_RESOURCES})
```

```cpp
auto icon = QIcon(":/fox-icons/icon-fox-head.svg");
```

```qml
Kirigami.Icon { source: "qrc:/fox-icons/icon-mark-gear.svg" }
```

**Kotlin (Compose):** `FoxIcons` and `FoxMarks` are namespaced objects of
generated `ImageVector` extension properties — one per icon, named in
PascalCase: `FoxIcons.ChevronDown`, `FoxIcons.CloudUpload`,
`FoxMarks.FoxHead`, `FoxMarks.SleepingFox`.

**Naming across platforms.** Every channel follows its own language's
convention for the same drawing — the one thing to watch for when porting an
icon reference:

| Channel | Convention | `foxHead` (a mark) | `chevronDown` (a UI icon) |
| --- | --- | --- | --- |
| Web (JS) | camelCase string key | `icon('foxHead')` | `ICONS.chevronDown` |
| Qt/native | kebab-case filename | `icon-fox-head.svg` | `icon-mark-chevron-down.svg` |
| Kotlin (Compose) | PascalCase property | `FoxMarks.FoxHead` | `FoxIcons.ChevronDown` |
| Godot (GDScript) | `UPPER_SNAKE` constant | `FoxMarks.FOX_HEAD` | `FoxIcons.CHEVRON_DOWN` |

The `FoxIcons`/`FoxMarks` split in the last two rows, and the `icon-mark-`
filename prefix in the Qt row, all exist for the same reason: both sets define
a `paw`.

```kotlin
import androidx.compose.material3.Icon
import vip.foobar.polarfuchs.icons.FoxIcons
import vip.foobar.polarfuchs.icons.ChevronDown
import vip.foobar.polarfuchs.theme.LocalFoxColors

Icon(
    imageVector = FoxIcons.ChevronDown,
    contentDescription = "Expand",
    tint = LocalFoxColors.current.ink,
)
```

Compose tints for real via `Icon(tint = ...)` / `LocalContentColor`, so
unlike the Qt tarball's static, light-ink-colored SVGs, there are no
`-inverse` twins to manage — build once, recolor freely at the call site.

### Resolving icons by name (freedesktop theme)

Referencing icons by qrc path only works for icons *you* place. The toolkit
also asks for its own chrome by freedesktop name — Kirigami's hamburger, back
arrow and search-clear button among them — and on Android there is no system
icon theme at all, so an unresolved name draws nothing rather than falling
back. `sync-fox-icon-theme.sh` builds a real theme to cover that:

```sh
curl -sSfO https://cdn.m.foobar.vip/sync-fox-icon-theme.sh   # vendor it, then
sh sync-fox-icon-theme.sh path/to/resources/icons/mytheme [extras.txt]
```

That writes `index.theme` plus `scalable/{actions,apps,status}/`, one file per
name — 66 names over 40 drawings, since the toolkit asks for the same glyph
under several names (`dialog-close` / `window-close-symbolic`, the `-rtl`
variants, and so on). The target directory is **replaced**, not merged.

The default `FOX_CDN` (`https://cdn.m.foobar.vip`) means each sync does one
HTTPS request per distinct drawing (40+ round trips) against whatever's live
right now. For a reproducible sync pinned to one release instead — and no
network dependency on the CDN specifically — download and extract the
[Qt/native tarball](#getting-it) once, then point `FOX_CDN` at the extracted
copy over `file://`:

```sh
curl -sSfL -o fox-qt-v42.tar.gz "https://gitlab.com/api/v4/projects/85262907/packages/generic/fox-qt/v42/fox-qt-v42.tar.gz"
tar -xzf fox-qt-v42.tar.gz
FOX_CDN="file://$(pwd)/fox-qt-v42/icons" \
  sh fox-qt-v42/sync-fox-icon-theme.sh path/to/resources/icons/mytheme [extras.txt]
```

coffeine-tracker's
[`refresh-fox-assets.sh`](https://git.foobar.vip/mira/coffeine-tracker/-/blob/main/client/resources/refresh-fox-assets.sh)
wraps this whole pattern (tarball fetch + extract + sync + also updating
`FoxTheme.qml` and the fonts from the same release) into one script, worth
copying wholesale rather than reinventing per-consumer.

`extras.txt` declares names that belong to your app rather than to the design
system, one per line, `<name> <file> <context>`:

```
coffeine-over-limit icon-mark-flame.svg apps
coffeine-sleep      icon-mark-ice.svg   apps
```

Then point QIcon at it:

```cpp
QIcon::setThemeSearchPaths(QStringList(":/icons") + QIcon::themeSearchPaths());
QIcon::setFallbackThemeName("breeze");
QIcon::setThemeName("mytheme");
```

`fox-icon-theme.json` carries the same mapping machine-readably, for a build
system that would rather generate the tree itself. Both files are generated by
`generate-fox-qrc.js` from `FREEDESKTOP_ALIASES` in `fox-icons.js`.

## Font

Six self-hosted families, no third-party font origin contacted for any of
them. Three sit on the token slots (`fontSans`/`fontMono`/`fontSerif`) and are
what you get by default; three more are **opt-in alternates** with no default
target at all — see "Alternates" below.

Three different licenses are in play, so check which one you've picked up:

| License | Families |
|---|---|
| SIL OFL 1.1 | Atkinson Hyperlegible (`OFL.txt`), Atkinson Hyperlegible Mono (`OFL-MONO.txt`), Lexend (`OFL-LEXEND.txt`), OpenDyslexic (`OFL-OPENDYSLEXIC.txt`) |
| Robert Hillier's own EULA | Sylexiad Serif Medium (`LICENSE-SYLEXIAD.txt`) |
| CC BY 4.0 | Luciole (`LICENSE-LUCIOLE.txt`) |

The two non-OFL ones carry obligations OFL doesn't. Sylexiad Serif Medium is
free to use, embed and self-host, but *not* freely redistributable on its own,
not resellable, and its credit line ("Sylexiad Serif Medium" by Robert
Hillier, sylexiad.com) is a license term, not a courtesy. Luciole's CC BY 4.0
likewise makes attribution binding — ship the credit with anything that
embeds it. Read both full texts before shipping either further.

### Sans — Atkinson Hyperlegible

```
atkinson-hyperlegible-{regular,bold,italic,bolditalic}.{woff2,ttf}
```

**Web:** already wired up via `@font-face` in `fox.css` — just use
`var(--font-sans)`, no extra step needed.

**Qt (C++):**

```cpp
QFontDatabase::addApplicationFont(":/fonts/atkinson-hyperlegible-regular.ttf");
// ...and -bold/-italic/-bolditalic.ttf alongside it
QFont font("Atkinson Hyperlegible");
```

**QML:**

```qml
FontLoader { id: atkinsonRegular; source: "qrc:/fonts/atkinson-hyperlegible-regular.ttf" }
Text { font.family: atkinsonRegular.name }
```

(The ttf files aren't part of `fox-icons.qrc` — bundle them into your own
app's resource file alongside your other assets, using whichever prefix
suits your project.)

**Godot:** the four `.ttf`s ship inside `addons/fox/fonts/`, with `OFL.txt`
alongside them as `LICENSE-OFL.txt`. Load by path — there is no font database
to register with:

```gdscript
theme.default_font = load(FoxTokens.Fonts.SANS_REGULAR)
# FoxTokens.Fonts.SANS_ALL lists all four; SANS_FAMILY is "Atkinson Hyperlegible"
```

(Only the `.ttf`s are bundled, not the `woff2` twins — Godot can't load woff2,
so shipping them would be dead weight in every export.)

**Kotlin (Compose):** no manual loading step at all — Atkinson Hyperlegible
ships bundled as a Compose Multiplatform resource inside
`fox-theme-compose` and is already wired into `FoxTheme`'s Material 3
`Typography` (`rememberFoxTypography()`), so every `Text` under `FoxTheme { }`
gets it for free, the same way `var(--font-sans)` needs no extra step on the
web. (Mono and serif are not wired into Compose `Typography` — Material 3's
`Typography` has one `fontFamily` slot per text style, not per-family, so
there's nothing to swap them into yet.)

### Mono — Atkinson Hyperlegible Mono

The monospace sibling family, same SIL OFL license and Braille Institute
copyright as sans (`OFL-MONO.txt` — its copyright line names "The Atkinson
Hyperlegible Mono Project Authors" rather than "Braille Institute of America,
Inc." directly, hence the separate file).

```
atkinson-hyperlegible-mono-{regular,bold,italic,bolditalic}.{woff2,ttf}
```

**Web:** wired up via `@font-face` in `fox.css` — use `var(--font-mono)`.

**Qt (C++) / QML:** same pattern as sans above, substituting
`atkinson-hyperlegible-mono-*.ttf` and the family name
`"Atkinson Hyperlegible Mono"`.

**Godot:** ships in `addons/fox/fonts/` alongside the sans files, with
`OFL-MONO.txt` copied in as `LICENSE-OFL-MONO.txt`:

```gdscript
var mono_font := load(FoxTokens.Fonts.MONO_REGULAR)
# FoxTokens.Fonts.MONO_ALL lists all four; MONO_FAMILY is "Atkinson Hyperlegible Mono"
```

### Serif — Sylexiad Serif Medium

Designed for adult dyslexic readers (see sylexiad.com). Shipped under Robert
Hillier's own EULA, not SIL OFL — see `LICENSE-SYLEXIAD.txt` before
redistributing it further than "embedded in this design system's consuming
apps."

```
sylexiad-serif-medium-{regular,bold,italic,bolditalic}.{woff2,ttf}
```

**Web:** self-hosted via `@font-face` in `fox.css`. Nothing applies it for
you — use `var(--font-serif)` where you want it, or hand it the whole heading
scale with `--font-heading: var(--font-serif)` (see "Headings" below).

**Qt (C++) / QML:** same pattern as sans above, substituting
`sylexiad-serif-medium-*.ttf` and the family name
`"Sylexiad Serif Medium"`.

**Godot:** ships in `addons/fox/fonts/` alongside the other two families,
with the EULA copied in as `LICENSE-SYLEXIAD.txt`:

```gdscript
var serif_font := load(FoxTokens.Fonts.SERIF_REGULAR)
# FoxTokens.Fonts.SERIF_ALL lists all four; SERIF_FAMILY is "Sylexiad Serif Medium"
```

### Alternates — opt-in accessibility faces

Three more families ship exactly like the three above — self-hosted, on the
CDN, in the Qt tarball, in the Godot addon — but **nothing points at them**.
There is no `fontAlternate` token slot, no default they replace, and adding
them changed nothing for any existing consumer.

That's deliberate. Which face reads best is a property of the *reader*, not of
the product: a low-vision reader, a dyslexic reader and a reader with neither
want three different answers, and no single default is right for all of them.
So these belong in your app's preferences — ideally exposed to the user, not
chosen once by you — and the design system's job is to make the switch cheap
rather than to make it for you.

| Family | Drawn for | Styles shipped | License |
|---|---|---|---|
| Luciole | Low vision / visual impairment | regular, bold, italic, bold-italic | CC BY 4.0 |
| Lexend | Reading proficiency, visual stress | regular, bold (**no italic**) + variable | SIL OFL 1.1 |
| OpenDyslexic 3 | Dyslexia | regular, bold, italic, bold-italic | SIL OFL 1.1 |

**Switching on the web** is one attribute, the same shape as
`data-theme="dark"`:

```html
<html data-font="opendyslexic">
```

`luciole`, `lexend` and `opendyslexic` are the three values. Each repoints
`--font-sans` and `--font-serif`, and `h1`-`h6` follow along because they
fall back to `--font-sans` — so a reader who switches because they can't read
the default doesn't get left with headings they still can't read. `--font-mono` is deliberately left on Atkinson Hyperlegible
Mono — none of the three is a monospace family, and repointing it would drop
code blocks and tabular figures onto a proportional face.

The attribute isn't root-only — put it on any element and only that subtree
moves, which is how you'd show a preview of all three side by side, or give a
reading pane one face while the chrome around it keeps the default:

```html
<article data-font="lexend">…</article>
```

And if you'd rather not use the attribute at all, the custom properties are
there to be re-pinned directly:

```css
:root { --font-sans: var(--font-luciole); }
.reading-pane { --font-sans: var(--font-opendyslexic); }
```

A `--font-sans` you set in your own stylesheet wins over `data-font` (they tie
on specificity, and yours loads later), so the two compose the way you'd
expect rather than fighting.

The `@font-face` rules are already in `fox.css`, so nothing else is needed —
the files are fetched from wherever you loaded the stylesheet from.

**Switching off the web:** there is no cascade to lean on, so it's the same
work as loading any other family. Qt and QML take the usual
`QFontDatabase::addApplicationFont` / `FontLoader` route (see "Sans" above),
substituting the file prefix; QML additionally gets `Fox.FoxTheme.fontLuciole`,
`.fontLexend` and `.fontOpendyslexic` beside the three default stacks. Godot
gets `FoxTokens.Fonts.LUCIOLE_*`, `LEXEND_*` and `OPENDYSLEXIC_*` path
constants, loaded exactly like `SANS_REGULAR`. Compose is the one channel
without them — see the note at the end of "Sans" for why mono and serif aren't
wired into Material 3's `Typography` either.

#### Luciole

By Laurent Bourcellier and Jonathan Perez, commissioned by the CTRDV (Centre
Technique Régional pour la Déficience Visuelle) and drawn with low-vision
readers in mind: a large x-height, open apertures, and characters that are
routinely confused (`0`/`O`, `l`/`I`/`1`, `rn`/`m`) pulled deliberately apart.

```
luciole-{regular,bold,italic,bolditalic}.{woff2,ttf}
```

CC BY 4.0 (`LICENSE-LUCIOLE.txt`), which is the one license here that is *not*
OFL and *not* an EULA. Attribution is a license term: credit "Luciole" by
Laurent Bourcellier & Jonathan Perez wherever you'd credit any other
third-party asset.

```html
<html data-font="luciole">
```

```gdscript
theme.default_font = load(FoxTokens.Fonts.LUCIOLE_REGULAR)
# LUCIOLE_ALL lists all four; LUCIOLE_FAMILY is "Luciole"
```

#### Lexend

By Bonnie Shaver-Troup and Thomas Jockin, drawn to reduce visual stress and
raise reading proficiency — wider default spacing and simplified letterforms,
with the weight axis itself treated as a legibility control rather than an
emphasis one.

```
lexend-{regular,bold}.{woff2,ttf}
lexend-variable.woff2
```

Two things make Lexend the odd one out:

- **No italics.** It's a weight-axis family; upstream publishes no italic cut,
  so `font-style: italic` gets a browser-synthesised oblique. On Qt and Godot
  you get no italic at all — `FoxTokens.Fonts.LEXEND_ALL` has two entries, not
  four, and there is no `LEXEND_ITALIC` constant to reach for.
- **A variable face**, web-only. `lexend-variable.woff2` covers the whole
  100–900 `wght` axis, so any weight in between is reachable from CSS
  (`font-weight: 250`) once `--font-sans` points at Lexend. The two static
  instances are declared after it and win at 400 and 700. It isn't bundled
  into the Godot addon (every addon byte ships in every export) and there's no
  `.ttf` twin, so Qt sees only the two statics.

```html
<html data-font="lexend">
```

#### OpenDyslexic 3

By Abbie Gonzalez. Letters are weighted at the bottom, which gives each glyph
an unambiguous "this way up" and resists the rotation and swapping dyslexic
readers report. It's the sans counterpart to Sylexiad Serif Medium, already
shipping as `fontSerif` — worth offering both, since readers differ on which
helps.

```
opendyslexic-{regular,bold,italic,bolditalic}.{woff2,otf}
```

Two upstream quirks worth knowing before you wire it up:

- **`.otf`, not `.ttf`.** Upstream compiles CFF outlines and publishes no
  TrueType build. Rather than re-render someone else's curves to reach a
  uniform file extension, this is the only family here shipped as OpenType —
  which browsers, Qt and Godot all load identically. Only the extension in
  the path differs (`FoxTokens.Fonts.OPENDYSLEXIC_REGULAR` ends in `.otf`).
- **The italic names itself as its own family.** `opendyslexic-italic.otf`
  carries the family name "OpenDyslexic Italic" with a subfamily of "Regular",
  rather than joining "OpenDyslexic" as its italic member. On the web this is
  invisible — `fox.css` assigns the family in `@font-face` — but anything that
  matches fonts by name (`QFont("OpenDyslexic")` with an italic style set,
  `FoxTokens.Fonts.OPENDYSLEXIC_FAMILY`) will not find the italic that way.
  Load the file directly instead.

```html
<html data-font="opendyslexic">
```

```gdscript
var od_italic := load(FoxTokens.Fonts.OPENDYSLEXIC_ITALIC)
```

### Reading settings - line height, measure, spacing

The alternates above make *which face* the reader's call. These make the rest
of it: how much line height, how wide a line, how much letter and word
spacing. Same argument, four more dimensions - a reader with low vision, a
reader with dyslexia and a reader with neither want different answers, and
until this release these were literals scattered through `fox.css` with
nothing to point a slider at.

The defaults are the **WCAG 2.1 SC 1.4.12 (Text Spacing)** floor. 1.4.12 says
content must stay usable when a reader pushes line height to 1.5, letter
spacing to 0.12em, word spacing to 0.16em and paragraph spacing to 2em - so
those are the numbers your settings UI has to be able to *reach*, and the
floor is where it starts:

| Token | CSS property | Default | 1.4.12 wants |
|---|---|---|---|
| `typography.lineHeight` | `--line-height` | `1.5` | reachable to 1.5 |
| `typography.letterSpacing` | `--letter-spacing` | `0em` | reachable to 0.12em |
| `typography.wordSpacing` | `--word-spacing` | `0em` | reachable to 0.16em |
| `typography.paragraphSpacing` | `--paragraph-spacing` | `1em` | reachable to 2em |
| `typography.measure` | `--measure` | `65ch` | - (line *length*, not a 1.4.12 metric) |

Every default is what `fox.css` already rendered, so nothing moved when they
were extracted. `--measure` is line length, not a container width - the two
760px widths in `fox.css` are chrome and deliberately aren't this.
`--paragraph-spacing` is read by **`.fox-prose` and nothing else**, and that
is deliberate: `fox.css`'s reset zeroes every margin, so applying it to bare
`<p>` would retypeset page content the design system doesn't own — and `1em`
isn't what any existing page renders today, `0` is. Opt into it per block:

```html
<div class="fox-prose">
  <p>Two paragraphs, one --paragraph-spacing gap between them.</p>
  <p>Also capped at --measure, because prose is what line length is for.</p>
</div>
```

It uses `> * + *` rather than `p + p`, so a list or a blockquote between two
paragraphs still gets the break. That's the whole component — if you'd rather
apply the token yourself, it's one declaration and this one has no other
magic in it.

**Overriding them** is a custom property like any other, at any scope:

```css
:root { --line-height: 1.8; }
.reading-pane { --measure: 45ch; --word-spacing: 0.16em; }
```

One subtlety worth knowing before you raise `--letter-spacing` globally:
`fox.css`'s uppercase labels and titles carry their *own* tracking, and that
is typographic intent rather than a reader preference. They compose the two
rather than choosing:

```css
.fox-label { letter-spacing: calc(0.08em + var(--letter-spacing)); }
```

So a reader who widens their spacing widens those too, and the type's own
rhythm survives underneath. Do the same in your own rules.

**Wiring it to a settings UI** is `initReadingPrefs()`, which `fox.js` runs
for you on load, next to `initSettings()` and `initTheme()`. Give it controls
and it handles persistence, restore-on-load and validation:

```html
<select data-fox-reading-pref="font">
  <option value="">Atkinson Hyperlegible (default)</option>
  <option value="luciole">Luciole</option>
  <option value="lexend">Lexend</option>
  <option value="opendyslexic">OpenDyslexic 3</option>
</select>

<select data-fox-reading-pref="font-heading">
  <option value="">Match body text</option>
  <option value="serif">Sylexiad Serif</option>
  <option value="opendyslexic">OpenDyslexic 3</option>
</select>

<select data-fox-reading-pref="motion">
  <option value="">Match system</option>
  <option value="full">Full</option>
  <option value="reduced">Reduced</option>
</select>

<input type="range" data-fox-reading-pref="line-height"       min="1.2" max="2.4" step="0.1">
<input type="range" data-fox-reading-pref="letter-spacing"    min="0" max="0.24" step="0.01">
<input type="range" data-fox-reading-pref="word-spacing"      min="0" max="0.32" step="0.02">
<input type="range" data-fox-reading-pref="paragraph-spacing" min="0" max="2.5" step="0.25">
<input type="range" data-fox-reading-pref="measure"           min="30" max="100" step="5">
```

Give each slider a `max` that actually reaches the 1.4.12 number in the table
above. A control that stops short is a failure that looks fine in review: the
setting exists, it moves, and it can't get where the reader is entitled to go.

`<select data-fox-font-select>` is the older spelling of the typeface control
and still works, so nothing that shipped against it needs changing. Prefer
`data-fox-reading-pref="font"` in new markup — and never put both attributes
on one element, which would wire it twice.

The preferences split two ways. The three **name-valued** ones — face,
heading face, motion — land as attributes on `<html>` (`data-font`,
`data-font-heading`, `data-motion`), because each drives a *selector* in
`fox.css` rather than a value, and motion can't work any other way: a custom
property can't switch off a transition, and the reader's answer has to be
able to beat a `prefers-reduced-motion` media query in *both* directions. All
three are selects and not checkboxes because each has three states, and the
third — "no answer, the system decides" — is the default, which is what an
empty value carries.

The five **number-valued** ones land as inline custom properties on `<html>`,
which beat `:root` and still lose to a subtree that re-pins them, so a
reading pane with its own metrics keeps them. Choices persist under
`fox-font`, `fox-font-heading`, `fox-motion`, `fox-line-height`,
`fox-letter-spacing`, `fox-word-spacing`, `fox-paragraph-spacing` and
`fox-measure` in `localStorage`, and resolve
`localStorage` -> nothing -> whatever the stylesheet says, the same three
steps `initTheme()` uses. An unset preference sets no property at all rather
than re-asserting the default, so a page that re-pinned one for itself is
left alone. A `foxreadingchange` event fires on `document` with the current
values if you want to mirror them somewhere.

Values out of range - or anything that isn't a number - are dropped rather
than applied, because these go straight into a style property and anything
that can write to `localStorage` on your origin could otherwise inject CSS
into every page that loads `fox.js`. The name-valued three are checked the
same way at the same boundary, by membership of a fixed list; an unrecognised
name removes the attribute rather than setting it. That check is sufficient
*because* those values only ever get compared against a literal in a CSS
selector, so none of them reaches a style property.

The gallery at the CDN root has all of this wired up in one **Accessibility**
panel if you want to see it working before you build it - including the
theme, which is documented at the top of this file.

**Off the web**, there's no cascade, so each channel exposes the numbers and
you apply them:

| Channel | Where | Notes |
|---|---|---|
| QML | `FoxTheme.lineHeight`, `.letterSpacing`, `.wordSpacing`, `.paragraphSpacing`, `.measure` | `Text.lineHeight` takes the multiplier directly with `lineHeightMode: Text.ProportionalHeight`; `font.letterSpacing`/`font.wordSpacing` are in **pixels**, so multiply by `font.pixelSize`. `measure` is a character count. |
| Godot | `FoxTokens.Typography.LINE_HEIGHT` etc. | `line_spacing` wants *extra* pixels: `font_size * (LINE_HEIGHT - 1.0)`. No per-Label letter-spacing property exists, so those two are informational. |
| Compose | `FoxLineHeight`, `FoxLetterSpacing`, `FoxWordSpacing`, `FoxMeasure` | `FoxTheme(lineHeight = …, letterSpacing = …)` forwards to `rememberFoxTypography()`, which applies them across all fifteen Material 3 roles - see below. |
| QSS | **nothing** | Qt's stylesheet dialect has no `line-height`, `letter-spacing` or `word-spacing` property at all. Not an omission; there is nothing to set. Drive a QWidgets app's metrics from `QFont` instead. |

Compose is the one channel that does the work for you, because rebuilding
Material 3's fifteen type roles by hand to change one number is exactly the
kind of cost this is supposed to remove:

```kotlin
FoxTheme(lineHeight = prefs.lineHeight, letterSpacing = prefs.letterSpacing) {
    // ...
}
```

`lineHeight` is applied as a *ratio* against the default rather than imposed
flat: M3's roles don't share one ratio (displayLarge is 57sp/64sp, bodyLarge
is 16sp/24sp), and flattening them all to a single number would blow the
display roles apart. Scaling moves every role by the same proportion instead,
and leaving the parameter alone renders exactly as M3 does. `letterSpacing`
composes onto each role's own tracking, the same `calc()` rule as the web.

### Headings (h1-h6)

Headings use **the same face as body text**, and separate themselves by size
and weight instead. Whatever is in play — the default, a `data-font`
selection, a stack you re-pinned yourself — the headings are in it too.

They were pinned to Sylexiad Serif Medium until this release. That put a
second, unrelated face in the one place a reader couldn't override it: switch
to OpenDyslexic because you can't read the default, and every heading was
still a serif. If you want that pairing back, it's one line, and now it's a
choice you're making rather than one made for you:

```css
:root { --font-heading: var(--font-serif); }
```

`--font-heading` is the heading knob, on the web: any stack works there — one
of the `data-font` alternates, a face of your own, anything. Setting it moves
`h1`-`h6` only, leaving body text where it is, and it applies to whatever
subtree you set it on.

`data-font-heading` is the same thing by name, for when the answer comes
from a *reader* rather than from you — it's what the gallery's "Heading face"
control writes, and it's a plain shorthand for the line above:

| Attribute | Sets `--font-heading` to |
|---|---|
| `data-font-heading="serif"` | `var(--font-serif)` |
| `data-font-heading="luciole"` | `var(--font-luciole)` |
| `data-font-heading="lexend"` | `var(--font-lexend)` |
| `data-font-heading="opendyslexic"` | `var(--font-opendyslexic)` |
| *absent* | nothing — headings follow the body face |

One interaction to know about, because it reads as a bug otherwise.
`data-font-heading="serif"` resolves `var(--font-serif)` — and `data-font`
**repoints `--font-serif` on the same element**. So on

```html
<html data-font="opendyslexic" data-font-heading="serif">
```

the headings come out OpenDyslexic, not Sylexiad. That's deliberate and it's
the same principle as the paragraph below: a reader who switched because they
can't read the default shouldn't get headings they still can't read. If you
want a genuinely different heading face under an alternate body face, name it
(`data-font-heading="lexend"`) — those custom properties are never repointed.

It's *unset* by default, not pointed at `--font-sans`: `fox.css` reads it as
`var(--font-heading, var(--font-sans))`, so the fallback resolves at each
heading against whatever `--font-sans` means there. That's what lets a scoped
`data-font` — or any subtree that re-pins `--font-sans` — take its headings
with it. Declaring a default at `:root` would resolve the fallback once, up
there, and headings would stop tracking anything below it.

**Qt (QSS):** set `foxClass` to `h1` through `h6` on a `QLabel`, matching the
size/weight/color scale baked into `fox-light.qss`/`fox-dark.qss` — no
manual font selection needed:

```cpp
auto *title = new QLabel("Settings");
title->setProperty("foxClass", "h1");
```

**QML / Kirigami:** point `Kirigami.Heading.font.family` at
`Fox.FoxTheme.fontSans` — see the QML/Kirigami section above. There's no
`--font-heading` equivalent off the web: QSS has no custom properties, and
QML and Godot pick the family at the call site anyway, so "override the
heading font" there means passing a different stack or `*_BOLD` constant.

**Godot:** apply the `HeaderLabel` theme type variation built by
`FoxSmokeTheme.build()` (see "Assembling a Theme" above):

```gdscript
var heading := Label.new()
heading.theme_type_variation = "HeaderLabel"
```

**Web:** automatic — `fox.css` styles bare `h1`-`h6` with
`var(--font-heading)`, weight 700, `var(--ink)`, and a rem size scale
mirroring the QSS pt scale. No classes or markup changes needed; just include
the stylesheet.
