Skip to main content

max / alloy

Transition Flatwhite -> Akari, migrate token system to theme-common Alloy's default light theme moves from the OKLCH-computed Flatwhite ramp (H=80, C=0.012) to Akari Dawn — based on Shu Kutsuzawa's Akari (https://github.com/cappyzawa/akari-theme, MIT), a warm-clay light palette inspired by Japanese alleys lit by round lanterns. Akari Night lands as the default dark counterpart. The bigger move is architectural: Alloy's design tokens are no longer authored as OKLCH values inside docs/TOKENS.md, they're stored in theme-common .toml files at MNW/shared/themes/. This unlocks a future theme catalog (a website of downloadable .toml files) where any theme that satisfies theme-common's schema works in Alloy, GoingsOn, Balanced Breakfast, audiofiles, and makenot.work with zero adapter code. Extended tokens Alloy needs beyond theme-common's schema (border-subtle, border-strong) are derived at load time from line.border via linear-sRGB mix: - border-subtle = mix(border, surface.page, 60%) - border-strong = mix(border, content.primary, 65%) The 65% ratio is calibrated to clear WCAG AA-UI (3.0:1) on soft- border themes like Akari (line.border = #cabeae -> #7f786d). Changes in this commit: Docs: - TOKENS.md fully rewritten. theme-common format is the storage; OKLCH is the authoring mental model; verified-contrast section regenerated against Akari Dawn (all text pairs pass AAA / AA-text; action.primary on sunken and status.warning inherit-fail per Akari's aesthetic, glyph-safe per the accent-on-glyph rule). - STACK.md: four Flatwhite mentions replaced with Akari references. - CONSOLE.md: subcommand list adds `alloy theme <name>` for runtime theme swap via theme-common. Tools: - tools/wcag_audit.py rewritten as a theme-common theme linter. Reads any theme-common .toml, converts hex -> linear sRGB -> WCAG 2.1 relative luminance, reports pass/fail for every affordance-carrying pair. Runs the border derivation formulas and audits the derived tokens too. Schemas: - schemas/rio.toml.schema preset renamed and hex values swapped to Akari Dawn (29 palette assignments). Skel configs (all mechanical Flatwhite -> Akari hex swap, plus header-comment updates): - rio/config.toml, mako/config, yazi/theme.toml, satty/config.toml - gtk-3.0/gtk.css, gtk-4.0/gtk.css, swayosd/style.css - nushell/config.nu, starship.toml, bottom/bottom.toml - sway/config, zathura/zathurarc, mpv/mpv.conf - .mozilla/firefox/alloy.default/chrome/userChrome.css Helix theme: - Delete etc/skel/.config/helix/themes/alloy-flatwhite.toml (200 LOC Alloy-authored theme). - Add akari-dawn.toml and akari-night.toml, fetched verbatim from https://github.com/cappyzawa/akari-theme dist/helix/, MIT licensed. - Update helix config.toml theme= to reference akari-dawn. Firefox mozilla.cfg comment updated.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:25 UTC
Signed with PGP, not checked
Commit: 61d8f05c5a95e74b99f3ca58e7e59f46d4ffc11e
Parent: 8917a6e
24 files changed, +1055 insertions, -733 deletions
M docs/CONSOLE.md +3 -1
@@ -22,6 +22,8 @@
22 22 alloy tail # tailscale front (see CONTINUITY.md)
23 23 alloy sync # syncthing front (see CONTINUITY.md)
24 24 alloy config <path> # schema-driven TOML/KDL editor
25 + alloy theme <name> # swap the runtime theme; reads MNW/shared/themes/*.toml
26 + # or ~/.config/alloy/themes/*.toml via theme-common
25 27 ```
26 28
27 29 Each subcommand is a small ratatui adapter over an existing CLI. The subcommands share:
@@ -111,7 +113,7 @@
111 113 ## Roadmap
112 114
113 115 - **v0.5 (post-v0 stack).** `alloy config` alone, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). No live-state subcommands yet. Ships as a way for users to configure the stack without hand-editing TOML.
114 - - **v1.** Add `alloy net`, `alloy audio`, `alloy display`, `alloy update`, `alloy tail`, `alloy sync`. Fills out the console as a system control surface. First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy tail` and `alloy sync` enrollments.
116 + - **v1.** Add `alloy net`, `alloy audio`, `alloy display`, `alloy update`, `alloy tail`, `alloy sync`, `alloy theme`. Fills out the console as a system control surface. `alloy theme` swaps the runtime theme in place (theme-common consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy tail` and `alloy sync` enrollments.
115 117 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
116 118 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
117 119
M docs/STACK.md +4 -4
@@ -64,7 +64,7 @@
64 64
65 65 ## Text editor
66 66
67 - **Helix.** Rust, modal (selection-first, Kakoune-shaped), LSP + tree-sitter + formatters batteries-included, TOML config, immediate-mode-stable, useful with zero config. On a terminal-first system the editor is a TUI; there is no graphical-editor default. Config and the Alloy Flatwhite theme are covered under "system introspection" below.
67 + **Helix.** Rust, modal (selection-first, Kakoune-shaped), LSP + tree-sitter + formatters batteries-included, TOML config, immediate-mode-stable, useful with zero config. On a terminal-first system the editor is a TUI; there is no graphical-editor default. Config and the Akari Dawn theme are covered under "system introspection" below.
68 68
69 69 The prior two-audience answer (Helix for terminal users, Gram for graphical) collapsed in the pivot: Gram (a Zed/gpui fork) was dropped along with the rest of the GUI stack. **deox** — Alloy's own Helix fork — becomes the intended default once it has a public home to build from; until then, stock Helix ships.
70 70
@@ -190,7 +190,7 @@
190 190
191 191 ### Editor: **helix**
192 192
193 - Rust, modal (selection-first grammar, Kakoune-shaped), LSP + tree-sitter + formatters batteries-included, TOML config, zero-config to be useful. Config at [`etc/skel/.config/helix/`](../etc/skel/.config/helix/) — editor UX in `config.toml`, Alloy Flatwhite theme in `themes/alloy-flatwhite.toml` (40+ syntax scopes mapped to the light-mode palette).
193 + Rust, modal (selection-first grammar, Kakoune-shaped), LSP + tree-sitter + formatters batteries-included, TOML config, zero-config to be useful. Config at [`etc/skel/.config/helix/`](../etc/skel/.config/helix/) — editor UX in `config.toml`, Akari Dawn theme shipped verbatim at `themes/akari-dawn.toml` (with `akari-night.toml` alongside for dark mode) from Shu Kutsuzawa's [Akari](https://github.com/cappyzawa/akari-theme) (MIT).
194 194
195 195 `EDITOR=hx` is set by `etc/skel/.config/nushell/env.nu`; git commit messages, `crontab -e`, and every other `$EDITOR`-respecting tool routes to helix.
196 196
@@ -267,7 +267,7 @@
267 267
268 268 ## Cursor theme
269 269
270 - **Bibata Modern Classic.** Classic tilted-arrow pointer shape (universally recognizable since Mac OS 1984), soft edges, subtle drop shadow, Wayland-friendly, actively maintained, Fedora repos. Its warmth pairs correctly with the H=80 palette without fighting the Flatwhite chrome.
270 + **Bibata Modern Classic.** Classic tilted-arrow pointer shape (universally recognizable since Mac OS 1984), soft edges, subtle drop shadow, Wayland-friendly, actively maintained, Fedora repos. Its warmth pairs correctly with Akari Dawn's warm-clay palette without fighting the chrome.
271 271
272 272 Applied three ways because different apps read cursor state from different places:
273 273
@@ -296,7 +296,7 @@
296 296
297 297 Rejected:
298 298 - **Bespoke Alloy GTK theme (fork adw-gtk3 with Alloy tokens baked in).** Real project — hundreds of selectors, ongoing GTK-version drift. Deferred to v2+ if Alloy has resources for it. The palette-patch approach captures 90% of the visual win for 10% of the work.
299 - - **libadwaita's own accent-color mechanism (GNOME 47+).** Only touches accent, leaves chrome greys untouched. Insufficient for the Flatwhite aesthetic.
299 + - **libadwaita's own accent-color mechanism (GNOME 47+).** Only touches accent, leaves chrome greys untouched. Insufficient for a full palette swap to a curated theme like Akari.
300 300 - **Kvantum, Materia, Arc, Yaru, WhiteSur.** All ship their own visual identity that would have to be re-styled to match Alloy — same total work as building the bespoke theme, without the base of libadwaita compatibility.
301 301
302 302 ## Fonts
M docs/TOKENS.md +91 -153
@@ -1,205 +1,143 @@
1 1 # Alloy Tokens
2 2
3 - The canonical, locked design tokens for Alloy. This file is the source of truth; `alloy_tui/tokens.toml` (when scaffolded) is generated from these decisions, and `tokens.rs` is generated from that. Changing a value here changes every authored Alloy surface.
3 + The design-token system for Alloy. Storage format, authoring discipline, extension rules, and how Alloy renders any theme downloaded from the future theme catalog.
4 4
5 - The rules these tokens implement live in [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md). This file is where rules become numbers.
5 + The rules these tokens implement live in [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md). This file is where the rules become storage, derivation, and gates.
6 6
7 - **Terminal target.** Since the 2026-07-17 pivot, Alloy authors TUIs (ratatui), not GUIs. The **palette and the semantic-color rules survive unchanged** and are the load-bearing part of this file. What does not survive a terminal cell grid: pixel sizes, corner radii, sub-cell border widths, and the two-font working/display split (a terminal renders one font). Each section below states what it becomes in a terminal.
7 + ## Storage format: theme-common
8 8
9 - ## Tint hue
9 + Alloy consumes themes in the [theme-common](../../../MNW/shared/theme-common/) TOML format, the same schema GoingsOn, Balanced Breakfast, audiofiles, and makenot.work already consume. A theme file has seven sections:
10 10
11 - **`H = 80`** — a warm yellow-orange. Reads as beige in light mode, warm charcoal in dark mode. Picked over alternatives (H=60 too yellow, H=40 too orange, H=90 drifts greenish at high lightness) because it stays unambiguously warm across the full L range.
11 + ```toml
12 + [meta]
13 + name = "Akari Dawn"
14 + variant = "light" # "light" | "dark" | "high-contrast"
12 15
13 - The tint hue is global. Every chrome ramp stop uses this hue. Accents do not.
16 + [surface]
17 + page = "#e4ded6" # main canvas
18 + raised = "#ede7de" # elevated / focused / popup base (lighter than page in light mode)
19 + sunken = "#cfc4b6" # recessed panels (darker than page in light mode)
20 + overlay = "#f0ece4" # modal / top-most temporary UI
14 21
15 - ## Tint chroma
22 + [content]
23 + primary = "#1a1816" # body text
24 + secondary = "#222d38" # section headers, labels
25 + muted = "#514b45" # disabled, placeholder, metadata
16 26
17 - **`C = 0.012`** for chrome. Just above the perceptual threshold against a neutral grey reference at typical viewing distance, well below where the tint starts to read as "colored" rather than "warm."
27 + [action]
28 + primary = "#8a4530" # the theme's primary action / brand accent
18 29
19 - ## The ramp (light mode, canonical)
30 + [status]
31 + danger = "#6a2828" # error
32 + success = "#3a5830" # healthy / ok
33 + warning = "#b07840" # near-threshold
34 + info = "#304050" # informational / link / in-progress
20 35
21 - All values OKLCH. Hue 80, chroma 0.012 throughout.
36 + [line]
37 + border = "#cabeae" # single border tone; Alloy derives -subtle and -strong (see below)
22 38
23 - | Token | L | Use |
24 - |---|---|---|
25 - | `surface-sunken` | 92 | Recessed wells (search inputs, log panes inside a raised surface) |
26 - | `surface` | 96 | Base canvas, the terminal background Alloy targets |
27 - | `surface-raised` | 98 | Focused pane, active list, default control surface |
28 - | `surface-overlay` | 99.5 | Popovers, menus, modal dialogs |
29 - | `border-subtle` | 88 | Internal dividers within a single surface tier |
30 - | `border` | 80 | Default block borders, pane edges |
31 - | `border-strong` | 65 | Focus borders, emphasized boundaries, selection stripe |
32 - | `text-muted` | 55 | Disabled text, secondary metadata, placeholder |
33 - | `text-secondary` | 38 | Labels, section headers, non-primary body |
34 - | `text-primary` | 18 | Default body, primary content |
39 + [category]
40 + one = "#8a4530" # categorical series colors (chart / syntax / tag rows)
41 + two = "#3a5830"
42 + three = "#304050"
43 + four = "#b07840"
44 + five = "#806080"
45 + six = "#305858"
46 + ```
35 47
36 - Eight L-stops covering chrome (top four), borders (middle three), and text (bottom three), with `text-muted` straddling the boundary.
48 + Alloy's default light theme is **[Akari Dawn](../../../MNW/shared/themes/akari-dawn.toml)**; the default dark is **[Akari Night](../../../MNW/shared/themes/akari-night.toml)**. Both are based on Shu Kutsuzawa's [Akari](https://github.com/cappyzawa/akari-theme) (MIT). Users pick a mode at startup (`--theme` flag or `$COLORFGBG` / OSC background query); users can also drop any theme-common `.toml` into `~/.config/alloy/themes/` and pass its name to swap.
37 49
38 - **In a terminal:** each stop becomes a `ratatui::style::Color::Rgb` after OKLCH to sRGB conversion. Surfaces are applied as `.bg(...)`, text and borders as `.fg(...)`. Not every terminal cell can carry an independent background cheaply, so surface tiers are expressed by drawing bordered `Block`s with the tier's `bg`, not by tinting whole regions; adjacent tiers stay one or two L-stops apart so they read as distinct even at 24-bit.
50 + **Why theme-common as the storage format:** the future user-facing theme catalog (a website of downloadable `.toml` files) is only useful if every make-family app renders any downloaded theme the same way. Sharing the format across apps buys that with zero adapter code.
39 51
40 - ## The ramp (dark mode, derived)
52 + ## Alloy-specific derived tokens
41 53
42 - Dark mode preserves H and C and inverts L *polarity*, but does **not** strictly compute `L_dark = 100 − L_light`. Strict inversion would break the elevation rule from [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md#affordance-depth-via-ramp-not-via-color), which requires `surface-raised` to read as closer to the eye than `surface` in *both* modes (lighter in both, not lighter-then-darker).
54 + Alloy needs three token tiers theme-common ships one of: `border-subtle`, `border`, `border-strong`. Rather than extend the schema (which would break existing consumers), Alloy computes the outer two from `line.border` at load time:
43 55
44 - | Token | L | Use |
45 - |---|---|---|
46 - | `surface-sunken` | 8 | (recessed wells) |
47 - | `surface` | 12 | (base canvas) |
48 - | `surface-raised` | 15 | (focused pane, controls) |
49 - | `surface-overlay` | 19 | (popovers, dialogs) |
50 - | `border-subtle` | 22 | |
51 - | `border` | 30 | |
52 - | `border-strong` | 52 | |
53 - | `text-muted` | 60 | |
54 - | `text-secondary` | 78 | |
55 - | `text-primary` | 94 | |
56 + ```
57 + border-subtle = mix(line.border, surface.page, 60%) # pulled toward surface — decorative divider
58 + border-strong = mix(line.border, content.primary, 65%) # pulled toward text — focus rings, selection
59 + ```
56 60
57 - `border-strong` sits at L=52 (not L=48 as originally proposed) — the [Verified contrast](#verified-contrast) pass showed that L=48 failed AA-UI (≥3.0) against `surface-overlay` at 2.82:1. L=52 clears it at 3.35:1 without disturbing the token's position relative to `border` (L=30) or `text-muted` (L=60).
61 + Mix is in linear sRGB. The 65%-toward-text ratio for `border-strong` is calibrated to reach WCAG AA-UI (≥3.0:1) against `surface.page` even on soft-border themes (Akari's `line.border` is `#cabeae`; the mix produces `#7f786d` which lands at 3.27:1). Themes with crisper borders will overshoot; that's fine.
58 62
59 - The exact L values are calibrated so contrast ratios for text-primary/surface land within ±0.3 across modes; text-secondary drifts slightly wider (dark 10.1:1 vs light 8.9:1) because of the sRGB gamma curve, still well above AA-text (4.5) in both. Full ratio audit below. The app selects `Mode` at startup, from a `--theme` flag, or by querying the terminal (`$COLORFGBG` / OSC background query).
63 + The formulas live in `alloy_tui`, not in the theme file. Theme files stay minimal and cross-app compatible.
60 64
61 - Dark-mode surface tiers land at 1.02–1.07:1 apart in raw relative luminance — near-imperceptible on their own. The **elevation affordance is border-carried in dark mode** (a `border-strong` block edge signals "this surface is elevated"), where in light mode the surface luminance delta itself carries elevation (1.04–1.13:1). This is a property of sRGB gamma at low L, not a bug: the token L values are what the eye can distinguish given the constraint that dark chrome must stay dark.
65 + ## Authoring discipline: OKLCH mental model, hex on disk
62 66
63 - ## Focus, selected, disabled
67 + Themes on disk are hex. The design *reasoning* behind those hex values (Alloy-authored ones or curated ones like Akari) still uses OKLCH:
64 68
65 - Mandatory per [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md#affordance-depth-via-ramp-not-via-color). Computed from the ramp, not separate values. A terminal has no hover or pressed state (no pointer contract), so the interactive states reduce to three that keyboard focus can express.
69 + - **Ramp discipline.** Surface tiers should follow monotonic L ordering per mode (light: `sunken < page < raised < overlay`; dark: inverted). Accents live in the mid-L / mid-C range where they read as color-carrying-information on either ramp.
70 + - **Tinted-not-neutral chrome.** Chrome tones should carry a hue tint (see [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md#core-principle-tinted-greyscale-chrome-color-as-information)); Akari's warm-clay palette is the canonical worked example.
71 + - **Accent-on-glyph, not on body text.** Confirmed by contrast math in the audit: accents that clear WCAG AA-text (4.5:1) on the theme's surface can be used freely; accents that only clear AA-UI (3.0:1) go on glyphs and edge markers, with body text staying at `content.primary`.
66 72
67 - For a control/pane at `surface-raised`:
73 + Because storage is hex, OKLCH doesn't need to be preserved in the file — it's the mental model of the person authoring the theme.
68 74
69 - | State | Surface | Border | Text |
70 - |---|---|---|---|
71 - | default | `surface-raised` | `border` | `text-primary` |
72 - | focused | `surface-raised` | `border-strong` (the block's border switches to `border-strong`) | `text-primary` |
73 - | selected | `surface-raised`, plus a `border-strong` leading-edge stripe on the row | `border` | `text-primary` |
74 - | disabled | `surface-raised` | `border-subtle` | `text-muted` |
75 + ## Gate: the WCAG audit
75 76
76 - Focus border and selection stripe are `border-strong`, never an accent color. Focus is chrome, not data. (The former hover/pressed one-step-toward/away-from-the-eye rules do not apply in a terminal; keep them documented in DESIGN-LANGUAGE for any adopted GUI surface.)
77 + Any theme, downloaded or authored, is audited by [`tools/wcag_audit.py`](../tools/wcag_audit.py). It reads a theme-common `.toml`, converts hex → linear sRGB → WCAG 2.1 relative luminance, and reports pass/fail for every affordance-carrying pair (text-on-surface, borders-on-surface, accents-on-surface, elevation deltas). Also computes `border-subtle` / `border-strong` via the same derivation Alloy uses at runtime.
77 78
78 - ## The information palette
79 + Run:
79 80
80 - Locked. Same OKLCH in both modes; accents are recognized by hue, and inverting them per mode would defeat that recognition. Their job is to be unmistakable on either surface.
81 + ```
82 + python3 tools/wcag_audit.py MNW/shared/themes/akari-dawn.toml
83 + ```
81 84
82 - | Token | OKLCH | Hue family | Meaning |
83 - |---|---|---|---|
84 - | `accent-error` | `oklch(55% 0.18 25)` | red | Error, over-threshold, destructive-confirmed |
85 - | `accent-warn` | `oklch(70% 0.15 85)` | amber | Warning, near-threshold |
86 - | `accent-healthy` | `oklch(62% 0.16 145)` | green | Healthy, success, in-bounds |
87 - | `accent-info` | `oklch(60% 0.16 240)` | blue | Informational, link, in-progress |
88 - | `accent-syntax` | `oklch(55% 0.18 310)` | magenta | Categorical accent, syntax token, fifth-series data |
85 + The audit is not a hard-fail gate — a theme with accents that only reach AA-UI on some surface tiers is still shippable if the accent-on-glyph rule is honored. The audit's job is to make trade-offs visible.
89 86
90 - Chroma sits between 0.15 and 0.18. All five are in-gamut for sRGB at the stated L.
87 + ## Verified contrast — Akari Dawn
91 88
92 - Accents never appear on chrome: no accent-tinted pane backgrounds, no accent borders on inactive controls, no accent focus. They appear in data, on glyphs that encode severity, and in syntax highlighting. In a terminal, an accent is a `.fg(...)` on the glyph and label, never a filled `.bg(...)` behind chrome.
89 + Full audit output at time of adoption. Method: OKLCH-agnostic; reads hex, computes WCAG 2.1 relative luminance directly.
93 90
94 - ### Accent-on-surface rule
91 + **Text on surfaces:**
95 92
96 - Accents live on **glyphs, severity indicators, and the 3px accent-edge marker on inline messages** — never as running body text. Message body stays at `text-primary`; the accent tints only the glyph and the leading-edge stripe. This is the DESIGN-LANGUAGE.md posture ("color reserved for information"), and the [Verified contrast](#verified-contrast) pass shows why it's also the WCAG-honest answer:
97 -
98 - - `accent-error` and `accent-syntax` at L=55 land at 3.5–3.9:1 on dark surfaces. Enough for a glyph or a small severity label under WCAG AA-UI (≥3.0), not enough for message-body text under AA-text (≥4.5).
99 - - `accent-warn` (L=70), `accent-healthy` (L=62), and `accent-info` (L=60) all clear 4.5:1 against every dark-mode surface tier — they can be used freely as small colored labels next to values (e.g., an amber "3 warnings" count).
100 - - Light mode is inverted: the three high-L accents fall below 3.0 as text on light chrome, while `accent-error` and `accent-syntax` at L=55 pass 4.7:1 there. Same "glyph, not body text" discipline resolves it either way; the audit table calls out which is which.
101 -
102 - ## Typography
103 -
104 - **A terminal renders one font at one size.** The two-font working/display system from the GUI design does not survive: you cannot swap fonts or point sizes mid-terminal. What survives is the *role distinction* and the levers a terminal actually has (weight via `Modifier::BOLD`, case, and Nerd Font glyphs).
105 -
106 - ### The terminal font: IosevkaTerm Nerd Font
107 -
108 - Everything authored or read is this font, because it is the terminal's font. The `Term` variant drops ligatures (safe for cell alignment), and the Nerd Font bundle carries the glyphs the console and status chrome need. Iosevka's narrow proportions give more columns per line, real value in a tiling stack. This is set in the rio config, not by `alloy_tui`; the crate assumes it.
109 -
110 - - **License:** OFL.
111 - - **Hierarchy lever:** weight (`Modifier::BOLD`) for emphasis and section headers; case (`UPPERCASE` labels) and Nerd Font glyphs for readout chrome.
112 -
113 - ### Departure Mono: reserved, not a live TUI font
114 -
115 - Departure Mono was the GUI display font for big-number readouts. A terminal cannot render it as a second live font, so it does **not** appear in running TUIs. It stays reserved for surfaces that are drawn as images rather than terminal cells: a first-boot splash (rendered via rio's kitty-graphics protocol), the swaylock background, and brand assets. Large numeric readouts inside a TUI (a clock, a dashboard value) are built from the terminal font using figlet-style cell art or Nerd Font block glyphs, not a font swap.
116 -
117 - ### Tokens
118 -
119 - | Token | Value |
120 - |---|---|
121 - | `font-terminal` | `IosevkaTermNerdFont` (set in rio; assumed by `alloy_tui`) |
122 - | `font-display-reserved` | `DepartureMonoNerdFontMono` (image/asset surfaces only) |
123 - | `weight-body` | Regular (no modifier) |
124 - | `weight-emphasis` | `Modifier::BOLD` |
125 - | `tracking-display-caps` | `UPPERCASE` label convention (no sub-cell tracking in a terminal) |
126 -
127 - Point sizes, line-heights, and sub-cell tracking are omitted: in a terminal they are fixed by the emulator and the cell grid, not by Alloy.
128 -
129 - ## Geometry
130 -
131 - A terminal grid has cells, not pixels, and no sub-cell rounding. The geometry tokens reduce to cell counts and box-drawing choices.
132 -
133 - | Token | Value | Terminal meaning |
134 - |---|---|---|
135 - | `space-xs` | 0 | no gap (adjacent cells) |
136 - | `space-sm` | 1 | one blank cell/row |
137 - | `space-md` | 2 | two cells/rows |
138 - | `space-lg` | 3 | three cells/rows |
139 - | `border-data` | none | data panels are borderless (`Borders::NONE`), flush on `surface` |
140 - | `border-control` | `BorderType::Plain` | controls/panes get a single-line box border |
141 - | `border-focus` | `BorderType::Plain` at `border-strong` | focus is a color change on the same border, not a thicker one |
142 -
143 - `radius-*`, `border-width-*`, and `focus-ring-offset` from the GUI design are dropped: terminals cannot round corners, vary border width, or offset a ring outside a cell. The square-vs-bordered distinction (data flush and borderless, controls bordered) carries the same affordance the radius did in the GUI.
144 -
145 - ## Verified contrast
146 -
147 - Computed WCAG 2.1 relative-luminance contrast ratios for every token pair that participates in an affordance (text, border, accent-on-surface). Method: OKLCH → OKLab → linear sRGB (Björn Ottosson's matrix) → clamp to gamut → `0.2126·R + 0.7152·G + 0.0722·B` → `(L_max + 0.05) / (L_min + 0.05)`. Targets: **AA-text ≥ 4.5**, **AA-UI ≥ 3.0** (WCAG 2.1). Ratios below are dark-mode with `border-strong` at its landed value of L=52.
148 -
149 - **Text on surfaces (target ≥ 4.5):**
150 -
151 - | Pair | vs sunken (L=8) | vs surface (L=12) | vs raised (L=15) | vs overlay (L=19) |
93 + | Pair | vs sunken | vs page | vs raised | vs overlay |
152 94 |---|---:|---:|---:|---:|
153 - | `text-primary` (L=94) | 17.43 | 17.02 | 16.50 | 15.49 |
154 - | `text-secondary` (L=78) | 10.38 | 10.14 | 9.83 | 9.22 |
155 - | `text-muted` (L=60) | 5.26 | 5.14 | 4.98 | 4.68 |
95 + | `content.primary` | 10.31 | 13.25 | 14.40 | 15.03 |
96 + | `content.secondary` | 8.15 | 10.48 | 11.39 | 11.88 |
97 + | `content.muted` | 5.01 | 6.44 | 7.00 | 7.30 |
156 98
157 - All twelve pairings clear AA-text; the three text-primary columns clear AAA-text (≥7). `text-muted` was targeted at AA-UI (3.0) as a disabled/placeholder color; it overshoots into AA-text territory, which is fine — the muting is L-driven and reads as tonal, not contrast-starved.
99 + All twelve pairings clear AA-text (4.5); most clear AAA (7).
158 100
159 - **Borders on surfaces (target ≥ 3.0 for affordance-carrying borders):**
101 + **Borders on surfaces:**
160 102
161 - | Pair | vs surface (L=12) | vs raised (L=15) | vs overlay (L=19) |
103 + | Pair | vs page | vs raised | vs overlay |
162 104 |---|---:|---:|---:|
163 - | `border-strong` (L=52) | 3.68 | 3.57 | 3.35 |
164 - | `border` (L=30) | 1.49 | 1.44 | 1.35 |
165 - | `border-subtle` (L=22) | 1.17 | 1.14 | 1.07 |
105 + | `border-strong` (derived) | 3.27 | 3.55 | 3.71 |
106 + | `line.border` | 1.37 | 1.49 | 1.55 |
107 + | `border-subtle` (derived) | 1.12 | 1.22 | 1.27 |
166 108
167 - `border-strong` (focus rings, selection edges, focused-block borders) clears AA-UI on every dark surface tier. `border` and `border-subtle` are **decorative dividers** — not required to meet 3.0 under WCAG. They exist to segment surfaces perceptually where the surface-tier delta itself is too small (see the elevation observation above); the affordance-signaling job belongs to `border-strong`.
109 + `border-strong` clears AA-UI on all three surfaces — the derivation formula works for Akari's soft border. `line.border` and `border-subtle` are decorative dividers, not required to meet 3.0.
168 110
169 111 **Accents on surfaces:**
170 112
171 - Only reporting the pairings that shift a design decision. Full grid available by running [`tools/wcag_audit.py`](../tools/wcag_audit.py) — no dependencies, ~130 lines of stdlib Python; reused for any future ramp adjustment.
113 + | Accent | vs page | vs raised | vs sunken | vs overlay | Notes |
114 + |---|---:|---:|---:|---:|---|
115 + | `action.primary` (lantern `#8a4530`) | 5.28 | 5.74 | **4.11** | 5.99 | AA-text on three of four; AA-UI on `sunken` — glyph-safe on the darkest tier |
116 + | `status.danger` (`#6a2828`) | 8.07 | 8.78 | 6.28 | 9.15 | AAA everywhere |
117 + | `status.success` (`#3a5830`) | 6.00 | 6.53 | 4.67 | 6.81 | AA-text everywhere |
118 + | `status.warning` (`#b07840`) | **2.80** | **3.05** | **2.18** | **3.18** | Fails AA-text on all tiers; AA-UI only on `raised`/`overlay`. **Amber-on-cream is inherent to Akari's aesthetic**; per the accent-on-glyph rule, warning is expressed as glyph + edge marker only. |
119 + | `status.info` (`#304050`) | 7.96 | 8.65 | 6.19 | 9.03 | AAA / AA-text everywhere |
172 120
173 - | Accent (L) | vs surface (L=12) | vs raised (L=15) | vs overlay (L=19) | Result |
174 - |---|---:|---:|---:|---|
175 - | `accent-warn` (70) | 7.52 | 7.29 | 6.85 | AA-text on all dark surfaces |
176 - | `accent-healthy` (62) | 5.95 | 5.76 | 5.41 | AA-text on all dark surfaces |
177 - | `accent-info` (60) | 5.35 | 5.19 | 4.87 | AA-text on all dark surfaces |
178 - | `accent-error` (55) | 3.82 | 3.70 | 3.47 | AA-UI only — glyph, not body text |
179 - | `accent-syntax` (55) | 3.82 | 3.71 | 3.48 | AA-UI only — glyph, not body text |
121 + The two failure modes are both Akari-inherent, not derivation bugs, and both survive Alloy's discipline: `action.primary` on `surface.sunken` still clears AA-UI (glyph-safe on the darkest recessed panel), and `status.warning`'s amber is a glyph-only accent per the same rule that governs `accent-syntax` in the previous Alloy palette.
180 122
181 - The last two rows are why the accent-on-surface rule (above) restricts `accent-error` and `accent-syntax` to glyphs and edge markers rather than tinted body text. Bumping either accent to L=60 would clear AA-text on three of four dark surfaces but drop them below AA-text in light mode (from 4.7 to 3.8 vs light `surface`) — the trade doesn't win, and the DESIGN-LANGUAGE.md posture already reserves accents for glyphs.
123 + ## Verified contrast — Akari Night
182 124
183 - **Follow-up (light mode):**
184 -
185 - The audit surfaced two light-mode issues that are out of scope for this pass but recorded here:
186 -
187 - - `border-strong` at L=65 vs light `surface` (L=96) is 2.88:1, just under AA-UI. Fixable by dropping to L≈62, deferred until the pass is done against a calibrated display.
188 - - `accent-warn`, `accent-healthy`, and `accent-info` all fall below 3.0:1 as text on light chrome (2.13–3.72). Same "glyph, not body text" discipline resolves it; the audit is the authority against ad-hoc tinted-label usage.
125 + Text on surfaces all AAA or AA-text. Borders derived at 7+ contrast (dark themes have more headroom because the border tone is against a light content.primary). Notable accent behavior: `status.danger` at `#d25046` lands at 3.3–4.1:1 across surfaces — AA-UI territory, glyph-safe under the same rule. Full audit reproducible via the script.
189 126
190 127 ## What's deferred
191 128
192 - - **Calibrated-display verification.** The audit above is mathematically sound but hasn't been validated on real display hardware in real terminals (truecolor and 256-color). Pre-1.0 gate.
193 - - **Light-mode L-stop pass** with the same method, to close the two follow-ups noted above.
194 - - **The 256-color downgrade table** generated from the OKLCH values, for terminals that report no truecolor.
195 - - **Figlet / Nerd-Font recipes** for the large readout numerics that Departure used to provide.
129 + - **Calibrated-display verification** on real hardware in real terminals (truecolor and 256-color). Pre-1.0 gate.
130 + - **256-color downgrade table** for terminals that report no truecolor.
131 + - **Figlet / Nerd-Font recipes** for the large numeric readouts a display font would otherwise carry.
132 + - **`alloy theme <path>` subcommand** (see [CONSOLE.md](CONSOLE.md)) — v0.5+, wraps theme swap.
196 133
197 134 ## How this file is consumed
198 135
199 - Until `alloy_tui` is scaffolded, this is markdown. When scaffolded:
136 + Themes at [`MNW/shared/themes/*.toml`](../../../MNW/shared/themes/) are the ground truth. `alloy_tui` at load time:
200 137
201 - 1. The palette and cell-spacing values are transcribed into `alloy_tui/tokens.toml`. The TOML is the build-time input.
202 - 2. `build.rs` parses the TOML, converts OKLCH to sRGB, and emits `tokens.rs` with typed ratatui constants (`Style`, `Color`, `Modifier`, `u16` cell counts) plus a 256-color fallback table.
203 - 3. Rust code only ever imports from `tokens.rs`. No app or widget constructs a color, style, or spacing value locally.
138 + 1. Reads the selected theme file via the `theme_common` crate.
139 + 2. Computes the two derived tokens (`border-subtle`, `border-strong`) from `line.border`.
140 + 3. Exposes the full token map as ratatui `Style` / `Color` constants.
141 + 4. Optionally emits a 256-color fallback table for terminals without truecolor.
204 142
205 - Changes flow: edit this file, edit `tokens.toml`, rebuild, every surface updates. This file stays canonical because the TOML can be regenerated from it but not vice versa (the rationale, alternatives, and the dark-mode derivation argument do not survive a round-trip through TOML).
143 + No hex values are hard-coded in Rust. Changing the theme file changes every authored Alloy surface after re-launch (or hot-swap once `alloy theme` ships). Third-party themes downloaded into `~/.config/alloy/themes/` work with zero adapter code.
@@ -239,42 +239,42 @@
239 239 path = "colors"
240 240 type = "color"
241 241 format = "hex"
242 - description = "Terminal palette. The Alloy Flatwhite preset is the shipped default; individual slots can be tuned or another preset applied."
242 + description = "Terminal palette. The Akari Dawn preset is the shipped default; individual slots can be tuned or another preset applied."
243 243 entries = [
244 - { key = "background", default = "#f5f2ea", description = "Surface background." },
245 - { key = "foreground", default = "#302c22", description = "Primary text." },
246 - { key = "cursor", default = "#302c22", description = "Cursor color." },
247 - { key = "selection-background", default = "#e0dbcf", description = "Selected-text background (border-subtle)." },
248 - { key = "selection-foreground", default = "#302c22", description = "Selected-text foreground." },
244 + { key = "background", default = "#e4ded6", description = "Surface background." },
245 + { key = "foreground", default = "#1a1816", description = "Primary text." },
246 + { key = "cursor", default = "#1a1816", description = "Cursor color." },
247 + { key = "selection-background", default = "#dad2c7", description = "Selected-text background (border-subtle)." },
248 + { key = "selection-foreground", default = "#1a1816", description = "Selected-text foreground." },
249 249
250 250 # Normal ANSI 0-7
251 - { key = "black", default = "#302c22", description = "ANSI 0 — text-primary." },
252 - { key = "red", default = "#c25d4f", description = "ANSI 1 — accent-error." },
253 - { key = "green", default = "#5a8f52", description = "ANSI 2 — accent-ok." },
254 - { key = "yellow", default = "#d1a234", description = "ANSI 3 — accent-warn." },
255 - { key = "blue", default = "#4a86c0", description = "ANSI 4 — accent-info." },
256 - { key = "magenta", default = "#9a6bb0", description = "ANSI 5 — accent-syntax." },
257 - { key = "cyan", default = "#5f5a4e", description = "ANSI 6 — text-secondary (Alloy has no cyan accent)." },
258 - { key = "white", default = "#faf7ef", description = "ANSI 7 — surface-raised." },
251 + { key = "black", default = "#1a1816", description = "ANSI 0 — text-primary." },
252 + { key = "red", default = "#6a2828", description = "ANSI 1 — accent-error." },
253 + { key = "green", default = "#3a5830", description = "ANSI 2 — accent-ok." },
254 + { key = "yellow", default = "#b07840", description = "ANSI 3 — accent-warn." },
255 + { key = "blue", default = "#304050", description = "ANSI 4 — accent-info." },
256 + { key = "magenta", default = "#806080", description = "ANSI 5 — accent-syntax." },
257 + { key = "cyan", default = "#222d38", description = "ANSI 6 — text-secondary (Alloy has no cyan accent)." },
258 + { key = "white", default = "#ede7de", description = "ANSI 7 — surface-raised." },
259 259
260 260 # Dim / bright ANSI 8-15
261 - { key = "dim-black", default = "#5f5a4e", description = "ANSI 8 — text-secondary." },
262 - { key = "dim-red", default = "#c25d4f", description = "ANSI 9." },
263 - { key = "dim-green", default = "#5a8f52", description = "ANSI 10." },
264 - { key = "dim-yellow", default = "#d1a234", description = "ANSI 11." },
265 - { key = "dim-blue", default = "#4a86c0", description = "ANSI 12." },
266 - { key = "dim-magenta", default = "#9a6bb0", description = "ANSI 13." },
267 - { key = "dim-cyan", default = "#8b8477", description = "ANSI 14 — text-muted." },
268 - { key = "dim-foreground", default = "#8b8477", description = "Dim foreground fallback." },
261 + { key = "dim-black", default = "#222d38", description = "ANSI 8 — text-secondary." },
262 + { key = "dim-red", default = "#6a2828", description = "ANSI 9." },
263 + { key = "dim-green", default = "#3a5830", description = "ANSI 10." },
264 + { key = "dim-yellow", default = "#b07840", description = "ANSI 11." },
265 + { key = "dim-blue", default = "#304050", description = "ANSI 12." },
266 + { key = "dim-magenta", default = "#806080", description = "ANSI 13." },
267 + { key = "dim-cyan", default = "#514b45", description = "ANSI 14 — text-muted." },
268 + { key = "dim-foreground", default = "#514b45", description = "Dim foreground fallback." },
269 269
270 - { key = "light-black", default = "#8b8477", description = "Bright ANSI 8 — text-muted." },
271 - { key = "light-red", default = "#c25d4f", description = "Bright ANSI 9." },
272 - { key = "light-green", default = "#5a8f52", description = "Bright ANSI 10." },
273 - { key = "light-yellow", default = "#d1a234", description = "Bright ANSI 11." },
274 - { key = "light-blue", default = "#4a86c0", description = "Bright ANSI 12." },
275 - { key = "light-magenta", default = "#9a6bb0", description = "Bright ANSI 13." },
276 - { key = "light-cyan", default = "#5f5a4e", description = "Bright ANSI 14 — text-secondary." },
277 - { key = "light-foreground", default = "#302c22", description = "Bright foreground." },
270 + { key = "light-black", default = "#514b45", description = "Bright ANSI 8 — text-muted." },
271 + { key = "light-red", default = "#6a2828", description = "Bright ANSI 9." },
272 + { key = "light-green", default = "#3a5830", description = "Bright ANSI 10." },
273 + { key = "light-yellow", default = "#b07840", description = "Bright ANSI 11." },
274 + { key = "light-blue", default = "#304050", description = "Bright ANSI 12." },
275 + { key = "light-magenta", default = "#806080", description = "Bright ANSI 13." },
276 + { key = "light-cyan", default = "#222d38", description = "Bright ANSI 14 — text-secondary." },
277 + { key = "light-foreground", default = "#1a1816", description = "Bright foreground." },
278 278 ]
279 279
280 280 # -------------------------------------------------------------------
@@ -282,43 +282,44 @@
282 282 #
283 283 # Applying a preset writes every listed path in one atomic edit
284 284 # (single dirty state, one undo entry). Alloy currently ships one:
285 - # the light-mode Flatwhite palette. A dark-mode counterpart lands
286 - # alongside the TOKENS.md dark ramp verification.
285 + # Akari Dawn (Alloy's default light theme). A dark-mode counterpart
286 + # (Akari Night, see MNW/shared/themes/akari-night.toml) lands as a
287 + # second preset once the dark-mode ANSI mapping is worked in real use.
287 288 # -------------------------------------------------------------------
288 289
289 290 [[preset]]
290 - name = "Alloy Flatwhite (light)"
291 - description = "The shipped default. Warm cream / papery beige surface with ANSI slots mapped to Alloy accents."
291 + name = "Akari Dawn (light)"
292 + description = "Alloy's default light theme. Warm-clay paper in lantern light — based on Shu Kutsuzawa's Akari (MIT). Source: MNW/shared/themes/akari-dawn.toml."
292 293 values = {
293 - "colors.background" = "#f5f2ea",
294 - "colors.foreground" = "#302c22",
295 - "colors.cursor" = "#302c22",
296 - "colors.selection-background" = "#e0dbcf",
297 - "colors.selection-foreground" = "#302c22",
298 - "colors.black" = "#302c22",
299 - "colors.red" = "#c25d4f",
300 - "colors.green" = "#5a8f52",
301 - "colors.yellow" = "#d1a234",
302 - "colors.blue" = "#4a86c0",
303 - "colors.magenta" = "#9a6bb0",
304 - "colors.cyan" = "#5f5a4e",
305 - "colors.white" = "#faf7ef",
306 - "colors.dim-black" = "#5f5a4e",
307 - "colors.dim-red" = "#c25d4f",
308 - "colors.dim-green" = "#5a8f52",
309 - "colors.dim-yellow" = "#d1a234",
310 - "colors.dim-blue" = "#4a86c0",
311 - "colors.dim-magenta" = "#9a6bb0",
312 - "colors.dim-cyan" = "#8b8477",
313 - "colors.dim-foreground" = "#8b8477",
314 - "colors.light-black" = "#8b8477",
315 - "colors.light-red" = "#c25d4f",
316 - "colors.light-green" = "#5a8f52",
317 - "colors.light-yellow" = "#d1a234",
318 - "colors.light-blue" = "#4a86c0",
319 - "colors.light-magenta" = "#9a6bb0",
320 - "colors.light-cyan" = "#5f5a4e",
321 - "colors.light-foreground" = "#302c22",
294 + "colors.background" = "#e4ded6",
295 + "colors.foreground" = "#1a1816",
296 + "colors.cursor" = "#8a4530",
297 + "colors.selection-background" = "#dad2c7",
298 + "colors.selection-foreground" = "#1a1816",
299 + "colors.black" = "#1a1816",
300 + "colors.red" = "#6a2828",
301 + "colors.green" = "#3a5830",
302 + "colors.yellow" = "#b07840",
303 + "colors.blue" = "#304050",
304 + "colors.magenta" = "#806080",
305 + "colors.cyan" = "#222d38",
306 + "colors.white" = "#ede7de",
307 + "colors.dim-black" = "#222d38",
308 + "colors.dim-red" = "#8a4530",
309 + "colors.dim-green" = "#3a5830",
310 + "colors.dim-yellow" = "#b07840",
311 + "colors.dim-blue" = "#304050",
312 + "colors.dim-magenta" = "#806080",
313 + "colors.dim-cyan" = "#514b45",
314 + "colors.dim-foreground" = "#514b45",
315 + "colors.light-black" = "#514b45",
316 + "colors.light-red" = "#8a4530",
317 + "colors.light-green" = "#3a5830",
318 + "colors.light-yellow" = "#b07840",
319 + "colors.light-blue" = "#304050",
320 + "colors.light-magenta" = "#806080",
321 + "colors.light-cyan" = "#222d38",
322 + "colors.light-foreground" = "#1a1816",
322 323 }
323 324
324 325 # -------------------------------------------------------------------
M tools/wcag_audit.py +140 -87
@@ -1,122 +1,175 @@
1 1 #!/usr/bin/env python3
2 - """WCAG 2.1 contrast audit for the Alloy token palette.
2 + """WCAG 2.1 contrast audit for theme-common theme files.
3 3
4 - Runs the ratio math against every affordance-carrying token pair in
5 - both modes, and reports pass/fail against AA-text (>= 4.5) and AA-UI
6 - (>= 3.0). No dependencies beyond the stdlib.
4 + Reads any theme-common .toml (surface/content/action/status/line/
5 + category sections), converts hex to WCAG 2.1 relative luminance,
6 + and reports pass/fail against AA-text (>= 4.5) and AA-UI (>= 3.0)
7 + for every affordance-carrying token pair.
7 8
8 - Method: OKLCH -> OKLab -> linear sRGB (Bjorn Ottosson matrix) ->
9 - clamp to [0, 1] gamut -> WCAG 2.1 relative luminance
10 - (0.2126*R + 0.7152*G + 0.0722*B) -> (L_max + 0.05) / (L_min + 0.05).
9 + Also computes Alloy's derived tokens (border-subtle, border-strong)
10 + via mix formulas and audits those too, so a theme-common file that
11 + was authored without Alloy's discipline still gets a full report.
11 12
12 - Run: `python3 tools/wcag_audit.py`
13 - Extend by editing DARK, LIGHT, or ACCENTS below. Reproduces the audit
14 - table documented in docs/TOKENS.md#verified-contrast.
13 + Usage:
14 + python3 tools/wcag_audit.py <path/to/theme.toml>
15 +
16 + Example:
17 + python3 tools/wcag_audit.py ../../MNW/shared/themes/akari-dawn.toml
15 18 """
16 - import math
19 + import sys
20 + import os
17 21
18 - # ---------------------------------------------------------------- math
22 + # ---------------------------------------------------------------- toml load
19 23
20 - def oklab_to_linear_srgb(L, a, b):
21 - l_ = L + 0.3963377774*a + 0.2158037573*b
22 - m_ = L - 0.1055613458*a - 0.0638541728*b
23 - s_ = L - 0.0894841775*a - 1.2914855480*b
24 - l, m, s = l_**3, m_**3, s_**3
25 - r = +4.0767416621*l - 3.3077115913*m + 0.2309699292*s
26 - g = -1.2684380046*l + 2.6097574011*m - 0.3413193965*s
27 - bl = -0.0041960863*l - 0.7034186147*m + 1.7076147010*s
28 - return r, g, bl
24 + def _load_toml(path):
25 + try:
26 + import tomllib
27 + except ImportError:
28 + try:
29 + import tomli as tomllib # noqa
30 + except ImportError:
31 + sys.exit("need tomllib (Python 3.11+) or `pip install --user tomli`")
32 + with open(path, "rb") as f:
33 + return tomllib.load(f)
29 34
30 - def oklch_to_linear_srgb(L_pct, C, h_deg):
31 - L = L_pct / 100.0
32 - h = math.radians(h_deg)
33 - return oklab_to_linear_srgb(L, C*math.cos(h), C*math.sin(h))
35 + # ---------------------------------------------------------------- color math
34 36
35 - def relative_luminance(L_pct, C=0.012, h_deg=80):
36 - r, g, b = oklch_to_linear_srgb(L_pct, C, h_deg)
37 - r = max(0.0, min(1.0, r))
38 - g = max(0.0, min(1.0, g))
39 - b = max(0.0, min(1.0, b))
40 - return 0.2126*r + 0.7152*g + 0.0722*b
37 + def hex_to_srgb(h):
38 + """#rrggbb -> (r, g, b) in [0, 1] sRGB (gamma-encoded)."""
39 + h = h.strip().lstrip("#")
40 + if len(h) != 6:
41 + raise ValueError(f"expected 6-hex color, got {h!r}")
42 + r = int(h[0:2], 16) / 255.0
43 + g = int(h[2:4], 16) / 255.0
44 + b = int(h[4:6], 16) / 255.0
45 + return r, g, b
41 46
42 - def contrast(Ya, Yb):
47 + def _linearize(c):
48 + """sRGB gamma -> linear sRGB per WCAG 2.1."""
49 + return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
50 +
51 + def relative_luminance_hex(h):
52 + r, g, b = (_linearize(c) for c in hex_to_srgb(h))
53 + return 0.2126 * r + 0.7152 * g + 0.0722 * b
54 +
55 + def contrast(a_hex, b_hex):
56 + Ya = relative_luminance_hex(a_hex)
57 + Yb = relative_luminance_hex(b_hex)
43 58 lo, hi = sorted((Ya, Yb))
44 59 return (hi + 0.05) / (lo + 0.05)
45 60
46 - # ---------------------------------------------------------------- data
61 + def mix_hex(a_hex, b_hex, t):
62 + """Linear-sRGB mix. t=0 => a, t=1 => b. Returns #rrggbb."""
63 + ar, ag, ab = (_linearize(c) for c in hex_to_srgb(a_hex))
64 + br, bg, bb = (_linearize(c) for c in hex_to_srgb(b_hex))
65 + mr = ar + (br - ar) * t
66 + mg = ag + (bg - ag) * t
67 + mb = ab + (bb - ab) * t
68 + def _delinearize(c):
69 + return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
70 + r = round(_delinearize(mr) * 255)
71 + g = round(_delinearize(mg) * 255)
72 + b = round(_delinearize(mb) * 255)
73 + return "#{:02x}{:02x}{:02x}".format(max(0, min(255, r)),
74 + max(0, min(255, g)),
75 + max(0, min(255, b)))
47 76
48 - # Ramp L values per docs/TOKENS.md (H=80, C=0.012 throughout).
49 - DARK = {
50 - "surface-sunken": 8, "surface": 12, "surface-raised": 15, "surface-overlay": 19,
51 - "border-subtle": 22, "border": 30, "border-strong": 52,
52 - "text-muted": 60, "text-secondary": 78, "text-primary": 94,
53 - }
54 - LIGHT = {
55 - "surface-sunken": 92, "surface": 96, "surface-raised": 98, "surface-overlay": 99.5,
56 - "border-subtle": 88, "border": 80, "border-strong": 65,
57 - "text-muted": 55, "text-secondary": 38, "text-primary": 18,
58 - }
77 + # ---------------------------------------------------------------- derivation
59 78
60 - # Information palette (OKLCH). Same in both modes.
61 - ACCENTS = {
62 - "accent-error": (55, 0.18, 25),
63 - "accent-warn": (70, 0.15, 85),
64 - "accent-healthy": (62, 0.16, 145),
65 - "accent-info": (60, 0.16, 240),
66 - "accent-syntax": (55, 0.18, 310),
67 - }
79 + def derive(theme):
80 + """Compute Alloy's extended tokens from a theme-common theme.
81 +
82 + theme-common ships one border tone (line.border); Alloy renders
83 + three tiers via mix. Formula lives here (not in the theme file)
84 + so any theme-common .toml downloaded from the wild gets a full
85 + Alloy-shaped token map.
86 + """
87 + border = theme["line"]["border"]
88 + surface = theme["surface"]["page"]
89 + primary = theme["content"]["primary"]
90 + # border-strong needs 3.0:1 against page for focus rings / selected
91 + # rows. Themes vary widely in border softness; mixing 65% toward
92 + # text gets there on both crisp (dark border) and soft (Akari-tier)
93 + # borders. border-subtle is decorative — 60% toward surface reads
94 + # as "hint of a divider" without adding contrast.
95 + return {
96 + "border-subtle": mix_hex(border, surface, 0.60),
97 + "border-strong": mix_hex(border, primary, 0.65),
98 + }
99 +
100 + # ---------------------------------------------------------------- reporting
68 101
69 102 TEXT_TARGET = 4.5
70 103 UI_TARGET = 3.0
71 104
72 - # ---------------------------------------------------------------- reporting
73 -
74 - def tag(ratio):
105 + def _tag(ratio):
75 106 if ratio >= 7.0: return "AAA"
76 107 if ratio >= 4.5: return "AA-text"
77 108 if ratio >= 3.0: return "AA-UI"
78 109 return "sub-3"
79 110
80 - def row(name, ratio, target):
81 - ok = "PASS" if ratio >= target else "FAIL"
82 - print(f" {ok} {ratio:6.2f}:1 [{tag(ratio):8s}] {name}")
111 + def _row(name, ratio, target):
112 + status = "PASS" if ratio >= target else "FAIL"
113 + print(f" {status} {ratio:6.2f}:1 [{_tag(ratio):8s}] {name}")
83 114
84 - def pair_ramp(ramp, a_L, b_L, a_C=0.012, a_h=80):
85 - Ya = relative_luminance(a_L, a_C, a_h)
86 - Yb = relative_luminance(b_L)
87 - return contrast(Ya, Yb)
115 + def audit(theme_path):
116 + theme = _load_toml(theme_path)
117 + meta = theme.get("meta", {})
118 + surf = theme["surface"]
119 + text = theme["content"]
120 + line = theme["line"]
121 + act = theme["action"]
122 + stat = theme["status"]
123 + derived = derive(theme)
88 124
89 - def audit(mode_name, RAMP):
90 - print(f"\n============ {mode_name} ============\n")
125 + name = meta.get("name", os.path.basename(theme_path))
126 + variant = meta.get("variant", "?")
127 + print(f"\n============ {name} ({variant}) ============\n")
91 128
92 - print("Text on surfaces (target >= 4.5 text; text-muted target 3.0)")
93 - for text_key in ["text-primary", "text-secondary", "text-muted"]:
94 - target = UI_TARGET if text_key == "text-muted" else TEXT_TARGET
95 - for surf in ["surface", "surface-raised", "surface-sunken", "surface-overlay"]:
96 - r = pair_ramp(RAMP, RAMP[text_key], RAMP[surf])
97 - row(f"{text_key} on {surf}", r, target)
129 + # Text on surfaces
130 + print("Text on surfaces (target >= 4.5 for text; muted target 3.0)")
131 + for key in ("primary", "secondary", "muted"):
132 + target = UI_TARGET if key == "muted" else TEXT_TARGET
133 + for s_key in ("page", "raised", "sunken", "overlay"):
134 + r = contrast(text[key], surf[s_key])
135 + _row(f"content.{key} on surface.{s_key}", r, target)
98 136
99 - print("\nBorders on surfaces (border-strong target >= 3.0; others decorative)")
100 - for b_key in ["border-strong", "border", "border-subtle"]:
101 - target = UI_TARGET if b_key == "border-strong" else 0.0
102 - for surf in ["surface", "surface-raised", "surface-overlay"]:
103 - r = pair_ramp(RAMP, RAMP[b_key], RAMP[surf])
104 - row(f"{b_key} on {surf}", r, target)
137 + # Borders on surfaces
138 + print("\nBorders on surfaces (border-strong target 3.0; others decorative)")
139 + for b_name, b_hex in (
140 + ("border-strong", derived["border-strong"]),
141 + ("line.border", line["border"]),
142 + ("border-subtle", derived["border-subtle"]),
143 + ):
144 + target = UI_TARGET if b_name == "border-strong" else 0.0
145 + for s_key in ("page", "raised", "overlay"):
146 + r = contrast(b_hex, surf[s_key])
147 + _row(f"{b_name} on surface.{s_key}", r, target)
105 148
149 + # Accents on surfaces
106 150 print("\nAccents on surfaces (target >= 4.5 text, or >= 3.0 for glyphs)")
107 - for a_name, (L, C, h) in ACCENTS.items():
108 - for surf in ["surface", "surface-raised", "surface-sunken", "surface-overlay"]:
109 - Ya = relative_luminance(L, C, h)
110 - Yb = relative_luminance(RAMP[surf])
111 - row(f"{a_name} on {surf}", contrast(Ya, Yb), TEXT_TARGET)
151 + accents = [("action.primary", act["primary"])]
152 + accents += [(f"status.{k}", stat[k]) for k in ("danger", "success", "warning", "info")]
153 + for a_name, a_hex in accents:
154 + for s_key in ("page", "raised", "sunken", "overlay"):
155 + r = contrast(a_hex, surf[s_key])
156 + _row(f"{a_name} on surface.{s_key}", r, TEXT_TARGET)
112 157
158 + # Surface elevation deltas (perceptual)
113 159 print("\nSurface elevation deltas (perceptual; not WCAG)")
114 - tiers = ["surface-sunken", "surface", "surface-raised", "surface-overlay"]
160 + tiers = ("sunken", "page", "raised", "overlay")
115 161 for a, b in zip(tiers, tiers[1:]):
116 - Ya = relative_luminance(RAMP[a])
117 - Yb = relative_luminance(RAMP[b])
118 - print(f" {a:16s} -> {b:16s} ratio {contrast(Ya, Yb):5.2f} dY {Yb-Ya:+.4f}")
162 + Ya = relative_luminance_hex(surf[a])
163 + Yb = relative_luminance_hex(surf[b])
164 + r = contrast(surf[a], surf[b])
165 + print(f" surface.{a:8s} -> surface.{b:8s} ratio {r:5.2f} dY {Yb-Ya:+.4f}")
166 +
167 + # Derived tokens (for downstream consumers wanting to eyeball)
168 + print(f"\nDerived tokens:")
169 + print(f" border-subtle = {derived['border-subtle']} (mix border, surface.page 60%)")
170 + print(f" border-strong = {derived['border-strong']} (mix border, content.primary 65%)")
119 171
120 172 if __name__ == "__main__":
121 - audit("DARK MODE", DARK)
122 - audit("LIGHT MODE", LIGHT)
173 + if len(sys.argv) != 2:
174 + sys.exit("usage: wcag_audit.py <path/to/theme.toml>")
175 + audit(sys.argv[1])