makeover 5.4.0

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
# makeover

Shared theme loading for the make-family apps. Parses theme metadata and color values from `.toml` files on disk, resolves them into intent-based tokens, and derives the rest perceptually (OKLab) with WCAG contrast checks.

The crate ships the theme set it loads, in `themes/`. Consumers get working themes from a clean checkout without depending on any sibling repo.

Used by MNW server, GoingsOn, Balanced Breakfast, audiofiles, and Alloy.

The crate ships 31 themes. `bundled_themes_dir()` hands back the directory; `embedded_themes()` hands back the same set as `(id, toml_source)` pairs with no filesystem path involved, for consumers that embed at compile time or bundle assets at build time.

## Usage

```rust
use makeover::{load_theme, list_themes_from_dirs, bundled_themes_dir};
use std::path::PathBuf;

// Set up theme directories (later entries override earlier ones)
let bundled = bundled_themes_dir().expect("makeover ships themes/");
let custom = PathBuf::from("/path/to/user/custom-themes");
let dirs = vec![(bundled, false), (custom, true)];

// List available themes (sorted by name)
let themes = list_themes_from_dirs(&dirs);
for t in &themes {
    println!("{} ({}, {})", t.name, t.id, t.variant);
}

// Load a specific theme by ID
let theme = load_theme(&dirs, "catppuccin-mocha").unwrap();
println!("Name: {}", theme.meta.name);         // "Catppuccin Mocha"
println!("Variant: {}", theme.meta.variant);     // "dark"
println!("BG: {}", theme.colors["surface.page"]);        // "#1e1e2e"

// Build-from-source fallback: the themes this crate ships
if let Some(dir) = bundled_themes_dir() {
    // dir = <makeover checkout>/themes
}
```

## Theme File Format

Colors are declared by **intent** (the role a color plays), not by hue. See `themes/` for the 31 bundled themes.

```toml
[meta]
name = "Nord"             # Display name; falls back to the filename
variant = "dark"          # "dark", "light", or "high-contrast" (default: "dark")

[surface]                 # Container backgrounds by elevation
page = "#2e3440"
raised = "#3b4252"
sunken = "#434c5e"
overlay = "#3b4252"

[content]                 # Text and ink by emphasis
primary = "#d8dee9"
secondary = "#e5e9f0"
muted = "#616e88"

[action]                  # Interactive / brand color
primary = "#81a1c1"

[status]                  # State semantics
danger = "#bf616a"
success = "#a3be8c"
warning = "#ebcb8b"
info = "#88c0d0"

[line]
border = "#4c566a"

[category]                # Optional: tag and label colors
```

### Derived tokens

Interactive states are not authored. `resolve()` derives them perceptually in
OKLab, so theme files stay small and every consuming app derives them
identically rather than each recomputing its own: `action-hover`,
`content-on-action`, `focus-ring`, `hover-surface`, `border-strong`, the
translucent `overlay` scrim, the `bevel-light` / `bevel-dark` pair that a raised
surface is lit and shadowed with, `surface-well`, the content surface cut
into a raised one, and the table rows' `row-hover`, `row-rule` and
`row-selected`.

Text inks are derived for every colour that is authored as a fill:
`danger-text`, `success-text`, `warning-text` and `info-text`, and
`category-one-text` through `category-six-text`. Each keeps its source's hue and
takes the nearest lightness that reads at 4.5:1 on every surface text is set
on, the two row tones included, and a status ink on its own toned surface too.
The fill tokens stay as authored; set text in the `-text` ones.

Highlighted code takes `syntax-keyword`, `syntax-string`, `syntax-comment`,
`syntax-constant`, `syntax-entity`, `syntax-variable` and `syntax-support`, drawn
from the category colours (the comment from `content-muted`) and held to the
same 4.5:1 on the text surfaces and on a diff's added and removed rows.

There is no stripe token. A table that alternates its rows puts every second
one on `surface-page`, a step of the theme's own neutral ramp, rather than on a
further mix of its ink. `row-stripe` was removed in 5.0.0.

Each is emitted only when the intents it reads from are present, so a partial
theme resolves to a partial token set rather than failing.

`surface-well` is the one derivation that inverts by theme: a well is lighter
than its face on a light theme and darker on a dark one. The direction is read
off `content`, not off the theme's `variant` field, so a theme whose text is
dark resolves as a light theme whatever its metadata says. A face too close to
the end of its ramp to hold the step takes the well the other way, since a
clamped well would land on the same colour as the lit bevel edge.

Bevel and well geometry is not derived here. Thickness, radius, inset depth and
which side takes which edge are the consuming app's, and only the tones are
shared.

`intent_css_vars()` renders a resolved theme as a `:root { … }` block for web
consumers; native consumers read RGB tuples off the same resolved tokens.

### Switching themes without a reload

A sheet that carries one theme encodes the choice in which sheet is linked, so
changing the pin means re-linking the document. An htmx navigation swaps the
body and leaves the head alone, so the change lands at the next launch.

`all_themes_css(dirs, defaults)` emits every installed theme into one sheet:
an ambient-following default in `:root`, behind `prefers-color-scheme` and
`prefers-contrast`, then one `:root[data-theme="<id>"]` block per theme.
Choosing a theme is setting `THEME_ATTRIBUTE` on the root element. The
attribute selector outranks the bare `:root` on specificity, so a pin beats the
default without `!important`, and an attribute naming no theme (`"system"`, or
none at all) falls back to following the OS. A picker can preview a theme by
writing the attribute and undo by writing the old one, with no request either
way.

It is a separate call because the bundle is not free: the shipped set is 27 KB
of custom properties (5 KB gzipped) against the one block a server-rendered
page injects per response. A surface that ships a single theme keeps paying for
a single theme.

### Terminals without truecolor

`ANSI_16`, `ANSI_256` and `ANSI_240` are the palettes a terminal addresses by
index, and `quantize` maps a theme color onto the nearest entry of any of them in
OKLab. `quantize_against` does the same for a color that has to stay legible
against a known background, such as a border on a page, and it is the wrong
choice for a pair of colors that must also stay apart from each other, because it
optimizes each one against the background alone.

Prefer `ANSI_240`, the 6x6x6 cube and the gray ramp. Every emulator lets the user
repaint the low sixteen, so a match landing there is a match against a color that
may have moved. Add `ANSI_240_OFFSET` to the returned index to get the one the
terminal wants.

Color depth decides how much of a theme survives. Two tones a hair apart in
24-bit round onto one entry at 256 and onto the same gray at 16.

The other direction is `ansi_intent(index, variant)`: which authored intent
*paints* ANSI slot `index`, for a program that owns a terminal palette rather
than one drawing into somebody else's. Twelve slots are chromatic and fixed
(slot 1 is the theme's danger tone in either polarity); the four achromatic ones
invert with it, because "black" and "white" mean the darkest and lightest tone
the theme has, and which intent that is flips between a light theme and a dark
one. A bare Linux console, a terminal emulator and a generated config that all
consult this agree on what red means.

### Theme ID

The theme ID is the filename without `.toml` (e.g., `catppuccin-mocha.toml` has ID `catppuccin-mocha`). It is a `ThemeId`: 1 to 64 ASCII letters, digits, hyphens and underscores, checked where it is made. `ThemeId::new` checks at run time; `ThemeId::from_static` in a `const` checks at compile time. Every function that takes an id takes a `ThemeId`, so none of them can be handed a path.

## API

| Function | Description |
|----------|-------------|
| `list_themes_from_dirs(dirs)` | Scan the search path: `Listed { themes, broken }`, the themes sorted and every file that would not load with its `LoadError` |
| `load_theme(dirs, id)` | Load a theme by ID, returning `ThemeColors` (metadata + color map) |
| `find_theme_path(dirs, id)` | Find the file path for a theme ID (highest-priority directory wins) |
| `parse_meta(id, table, is_custom)` | Parse `[meta]` from a TOML table into `ThemeMeta` |
| `extract_colors(table)` | Flatten color sections into a `HashMap<String, String>` |
| `ThemeId::new(id)` | Check a string is a theme ID |
| `bundled_themes_dir()` | The `themes/` directory this crate ships, for build-from-source fallback |
| `embedded_themes()` | The shipped themes as `(id, toml_source)` pairs, embedded at compile time (no path needed) |
| `parse_theme_str(id, source, is_custom)` | Parse a theme from a string, for use with `embedded_themes()` |
| `resolve(theme)` | Resolve authored intents into the full token set, deriving interactive states. Read a token with `tokens.color(Token::Action)`, `hex`, `css`, `rgb` or `rgba` |
| `intent_css_vars(tokens)` | Render resolved tokens as a `:root { … }` CSS block |
| `keyed_intent_css_vars(id, tokens)` | The same block keyed by `THEME_ATTRIBUTE`, as `:root[data-theme="<id>"]` |
| `all_themes_css(dirs, defaults)` | Every installed theme in one sheet: an ambient default plus a keyed block each |
| `THEME_ATTRIBUTE` | The root attribute the keyed blocks answer to, so a frontend and its sheet cannot disagree |
| `ansi_intent(index, variant)` | Which authored intent paints ANSI slot 0-15, for a program filling a terminal palette |

## Choosing a theme

Loading a theme file is one half; choosing one is the other. These types are
the choosing half, so no app re-rolls it.

| Item | Purpose |
| --- | --- |
| `Variant` | `light` / `dark` / `high-contrast`, as a value. `ThemeMeta::variant` holds it |
| `ThemeSelection` | `Follow` or `Fixed(id)` — what the user chose, not what is rendered |
| `ThemeSelection::parse(stored)` | Read a stored value from any store; absent or `"system"` is `Follow` |
| `ThemeSelection::as_str()` | The string to persist, whatever the store is |
| `ThemeSelection::resolve(ambient, defaults, available)` | Turn a selection into a theme ID that exists |
| `ThemeDefaults::new(light, dark)` | The app's own fallbacks, one per ambient mode |
| `ThemeDefaults::names_high_contrast()` | Whether one was named, which a per-mode emitter asks before writing a `prefers-contrast` block |
| `ThemeDirs` | Build the search path with the tiers named |

The store stays the app's: `localStorage`, a config table, a TOML file. What is
shared is the string it holds and what that string means, so `"system"` means
the same thing in all of them, and the key is `theme` everywhere.

`resolve` picks by `Variant`, so an app follows the system into any installed
theme of the right kind rather than into a hardcoded pair. A `Fixed` ID whose
theme has been deleted falls back rather than being handed back to fail later.

```rust
let selection = ThemeSelection::parse(store.get("theme"));
const LIGHT: ThemeId = ThemeId::from_static("flatwhite");
const DARK: ThemeId = ThemeId::from_static("nord");
let defaults = ThemeDefaults::new(LIGHT, DARK);
let id = selection.resolve(ambient, &defaults, &list_themes_from_dirs(&dirs).themes);
```

## Directory Priority

Every loader takes a `ThemeDirs`, the search path with its tiers named, so the
order is not the caller's to get backwards. Later tiers override earlier ones
by theme ID, and only the custom tier sets `is_custom` on `ThemeMeta`.

```rust
let dirs = ThemeDirs::new()
    .bundled(bundled_themes_dir())
    .system(Some("/usr/share/myapp/themes".into()))
    .custom(config_dir.map(|c| c.join("themes")));
```

The user's themes win, then the system's, then the app's own. Directories that
do not exist are skipped, so every tier can be offered unconditionally.

## License

MIT.

The themes in `themes/` adapt palettes from third-party projects (Catppuccin, Dracula, Nord, gruvbox, Tokyo Night, Rosé Pine, and others). Each upstream, its license, and the exact copyright line that license requires reproducing are recorded in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), verified against the upstream LICENSE files themselves. Every adapted theme file also carries that information in a header comment, so credit travels with the file.

If an attribution is wrong or you would prefer your work not be included, write to info@makenot.work and it will be corrected or removed.