Skip to main content

max / makeover

3.2.0: emit every theme in one sheet, keyed by a root attribute A sheet carrying one theme encodes the choice in which sheet is linked, so changing a pin means re-linking the document. An htmx navigation swaps the body and leaves the head alone, so a pinned theme landed at the next launch and GoingsOn's picker had to apologise for it in a hint. all_themes_css emits an ambient-following default, then one :root[data-theme="<id>"] block per installed theme. Choosing is setting an attribute: no reload, no second request, and a picker can preview by writing it and undo by writing the old one. The attribute selector outranks the bare :root of the default, including inside a media query, so the pin wins on specificity rather than on ordering. Its own call rather than a wider intent_css_vars because the bundle is not free: 27 KB of custom properties for the shipped set, 5 KB gzipped, against the one block a server-rendered page injects per response. MNW ships a single theme and keeps paying for a single theme. ThemeDefaults::names_high_contrast comes with it. for_variant answers every mode by falling back to dark, which is right for resolving a selection and wrong for a prefers-contrast block: that would answer the preference with a theme that does not honour it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-30 14:22 UTC
Commit: 15f1841f998708884dbb8eacbe1bc6af0e23eeaf
Parent: 61ad51c
4 files changed, +270 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "3.1.0"
3 + version = "3.2.0"
4 4 edition = "2024"
5 5 description = "Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast."
6 6 license = "MIT"
M README.md +25
@@ -97,6 +97,27 @@
97 97 `intent_css_vars()` renders a resolved theme as a `:root { … }` block for web
98 98 consumers; native consumers read RGB tuples off the same resolved tokens.
99 99
100 + ### Switching themes without a reload
101 +
102 + A sheet that carries one theme encodes the choice in which sheet is linked, so
103 + changing the pin means re-linking the document. An htmx navigation swaps the
104 + body and leaves the head alone, so the change lands at the next launch.
105 +
106 + `all_themes_css(dirs, defaults)` emits every installed theme into one sheet:
107 + an ambient-following default in `:root`, behind `prefers-color-scheme` and
108 + `prefers-contrast`, then one `:root[data-theme="<id>"]` block per theme.
109 + Choosing a theme is setting `THEME_ATTRIBUTE` on the root element. The
110 + attribute selector outranks the bare `:root` on specificity, so a pin beats the
111 + default without `!important`, and an attribute naming no theme (`"system"`, or
112 + none at all) falls back to following the OS. A picker can preview a theme by
113 + writing the attribute and undo by writing the old one, with no request either
114 + way.
115 +
116 + It is a separate call because the bundle is not free: the shipped set is 27 KB
117 + of custom properties (5 KB gzipped) against the one block a server-rendered
118 + page injects per response. A surface that ships a single theme keeps paying for
119 + a single theme.
120 +
100 121 ### Terminals without truecolor
101 122
102 123 `ANSI_16`, `ANSI_256` and `ANSI_240` are the palettes a terminal addresses by
@@ -143,6 +164,9 @@
143 164 | `parse_theme_str(id, source, is_custom)` | Parse a theme from a string, for use with `embedded_themes()` |
144 165 | `resolve(theme)` | Resolve authored intents into the full token set, deriving interactive states |
145 166 | `intent_css_vars(tokens)` | Render resolved tokens as a `:root { … }` CSS block |
167 + | `keyed_intent_css_vars(id, tokens)` | The same block keyed by `THEME_ATTRIBUTE`, as `:root[data-theme="<id>"]` |
168 + | `all_themes_css(dirs, defaults)` | Every installed theme in one sheet: an ambient default plus a keyed block each |
169 + | `THEME_ATTRIBUTE` | The root attribute the keyed blocks answer to, so a frontend and its sheet cannot disagree |
146 170 | `ansi_intent(index, variant)` | Which authored intent paints ANSI slot 0-15, for a program filling a terminal palette |
147 171
148 172 ## Choosing a theme
@@ -158,6 +182,7 @@
158 182 | `ThemeSelection::as_str()` | The string to persist, whatever the store is |
159 183 | `ThemeSelection::resolve(ambient, defaults, available)` | Turn a selection into a theme ID that exists |
160 184 | `ThemeDefaults::new(light, dark)` | The app's own fallbacks, one per ambient mode |
185 + | `ThemeDefaults::names_high_contrast()` | Whether one was named, which a per-mode emitter asks before writing a `prefers-contrast` block |
161 186 | `ThemeDirs` | Build the search path with the tiers named |
162 187
163 188 The store stays the app's: `localStorage`, a config table, a TOML file. What is
M src/lib.rs +230
@@ -952,6 +952,108 @@
952 952 format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
953 953 }
954 954
955 + // ============================================================================
956 + // Every theme in one sheet, keyed by a root attribute.
957 + //
958 + // The block above serves one theme: a consumer resolves the chosen id, renders
959 + // `:root`, and links the result. Changing the pin then means rendering a new
960 + // sheet and getting the document to re-link it, which an htmx navigation does
961 + // not do -- so a pinned change landed at the next launch and the screen had to
962 + // apologise for it in a hint.
963 + //
964 + // The fix is to stop encoding the choice in *which* sheet is linked. One sheet
965 + // carries every theme, each behind `:root[data-theme="<id>"]`, and choosing is
966 + // setting an attribute. No reload, no second request, and the picker can
967 + // preview a theme by writing the attribute and undo by writing the old one.
968 + //
969 + // It is a separate emitter rather than a wider `intent_css_vars` because the
970 + // bundle is not free: 31 themes of custom properties, against the one block a
971 + // server-rendered page injects per response. MNW ships a single theme and must
972 + // keep paying for a single theme, so this is opt-in by being its own call.
973 + // ============================================================================
974 +
975 + /// The root attribute [`all_themes_css`] keys its blocks on.
976 + ///
977 + /// Stated here so a consumer's frontend and its stylesheet cannot disagree
978 + /// about the spelling; a picker writes this attribute on `document
979 + /// .documentElement` and nothing else has to change.
980 + pub const THEME_ATTRIBUTE: &str = "data-theme";
981 +
982 + /// Emit one theme's intent layer keyed by [`THEME_ATTRIBUTE`], as
983 + /// `:root[data-theme="<id>"] { … }`.
984 + ///
985 + /// The attribute selector outranks the bare `:root` of [`intent_css_vars`],
986 + /// including one inside a media query, so a sheet may carry an
987 + /// ambient-following default and let a pin override it without `!important`
988 + /// and without ordering games.
989 + pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String {
990 + format!(
991 + ":root[{THEME_ATTRIBUTE}=\"{id}\"] {{\n{}}}\n",
992 + intent_css_declarations(tokens)
993 + )
994 + }
995 +
996 + /// Every theme in `dirs` as one stylesheet: an ambient-following default, then
997 + /// a keyed block per theme.
998 + ///
999 + /// The sheet a consumer links once and never re-links. Setting
1000 + /// [`THEME_ATTRIBUTE`] on the root element pins a theme; removing it, or
1001 + /// setting it to anything that names no theme (`"system"`, say), falls back to
1002 + /// the default blocks, which follow the OS through `prefers-color-scheme` and
1003 + /// `prefers-contrast`. Those are the same three ambient modes
1004 + /// [`ThemeSelection::resolve`] answers, so a sheet and a Rust-side resolution
1005 + /// of the same selection agree.
1006 + ///
1007 + /// `defaults` names the app's own fallbacks. A high-contrast default is only
1008 + /// emitted when [`ThemeDefaults::high_contrast`] named one: falling back to the
1009 + /// dark theme is right for a resolution and wrong for a media query, where it
1010 + /// would answer `prefers-contrast: more` with a theme that is not one.
1011 + ///
1012 + /// Themes that fail to load are skipped rather than failing the sheet: a
1013 + /// consumer's custom directory is user-writable, and one unparseable file
1014 + /// there should cost that file's block and nothing else.
1015 + ///
1016 + /// Blocks are ordered by id so the output is byte-stable, which is what lets a
1017 + /// caller cache it or compare two builds.
1018 + pub fn all_themes_css(dirs: &[(PathBuf, bool)], defaults: &ThemeDefaults) -> String {
1019 + let available = list_themes_from_dirs(dirs);
1020 + let mut out = String::new();
1021 +
1022 + let mut default_block = |variant: Variant, query: Option<&str>| {
1023 + let id = ThemeSelection::Follow.resolve(variant, defaults, &available);
1024 + let Ok(tokens) = load_semantic(dirs, &id) else {
1025 + return;
1026 + };
1027 + match query {
1028 + None => out.push_str(&intent_css_vars(&tokens)),
1029 + Some(query) => {
1030 + out.push_str("\n@media (");
1031 + out.push_str(query);
1032 + out.push_str(") {\n");
1033 + out.push_str(&intent_css_vars(&tokens));
1034 + out.push_str("}\n");
1035 + }
1036 + }
1037 + };
1038 +
1039 + default_block(Variant::Light, None);
1040 + default_block(Variant::Dark, Some("prefers-color-scheme: dark"));
1041 + if defaults.names_high_contrast() {
1042 + default_block(Variant::HighContrast, Some("prefers-contrast: more"));
1043 + }
1044 +
1045 + let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
1046 + ids.sort_unstable();
1047 + for id in ids {
1048 + if let Ok(tokens) = load_semantic(dirs, id) {
1049 + out.push('\n');
1050 + out.push_str(&keyed_intent_css_vars(id, &tokens));
1051 + }
1052 + }
1053 +
1054 + out
1055 + }
1056 +
955 1057 // ============================================================================
956 1058 // Typography — layer 1 of the house font model.
957 1059 //
@@ -1693,6 +1795,20 @@
1693 1795 self
1694 1796 }
1695 1797
1798 + /// Whether a high-contrast default was named.
1799 + ///
1800 + /// [`for_variant`] answers for every mode by falling back to the dark
1801 + /// theme, which is right for resolving a selection and wrong for emitting
1802 + /// a `prefers-contrast: more` block: that block would then answer the
1803 + /// preference with a theme that does not honour it. A caller that renders
1804 + /// per ambient mode asks this first.
1805 + ///
1806 + /// [`for_variant`]: ThemeDefaults::for_variant
1807 + #[must_use]
1808 + pub const fn names_high_contrast(&self) -> bool {
1809 + self.high_contrast.is_some()
1810 + }
1811 +
1696 1812 #[must_use]
1697 1813 pub fn for_variant(&self, variant: Variant) -> &str {
1698 1814 match variant {
@@ -3307,6 +3423,120 @@
3307 3423 assert!(css.trim_end().ends_with('}'));
3308 3424 }
3309 3425
3426 + // ---- every theme in one sheet ----
3427 +
3428 + /// The shipped themes, as the search path a consumer hands the emitter.
3429 + fn shipped() -> Vec<(PathBuf, bool)> {
3430 + vec![(
3431 + bundled_themes_dir().expect("makeover ships its themes"),
3432 + false,
3433 + )]
3434 + }
3435 +
3436 + #[test]
3437 + fn a_keyed_block_carries_the_same_declarations_as_a_root_one() {
3438 + let tokens = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
3439 + let keyed = keyed_intent_css_vars("nord", &tokens);
3440 + assert!(
3441 + keyed.starts_with(":root[data-theme=\"nord\"] {\n"),
3442 + "{keyed}"
3443 + );
3444 + assert_eq!(
3445 + keyed.replace(":root[data-theme=\"nord\"]", ":root"),
3446 + intent_css_vars(&tokens),
3447 + "the two emitters differ only in the selector"
3448 + );
3449 + }
3450 +
3451 + #[test]
3452 + fn every_installed_theme_gets_a_block_and_they_are_in_id_order() {
3453 + let dirs = shipped();
3454 + let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
3455 +
3456 + let keys: Vec<&str> = css
3457 + .match_indices(":root[data-theme=\"")
3458 + .map(|(at, prefix)| {
3459 + let rest = &css[at + prefix.len()..];
3460 + &rest[..rest.find('"').unwrap()]
3461 + })
3462 + .collect();
3463 +
3464 + let mut expected: Vec<String> = list_themes_from_dirs(&dirs)
3465 + .into_iter()
3466 + .map(|meta| meta.id)
3467 + .collect();
3468 + expected.sort();
3469 + assert_eq!(keys, expected, "one block per theme, ordered by id");
3470 + assert!(
3471 + keys.len() > 20,
3472 + "the shipped set is the whole picker: {keys:?}"
3473 + );
3474 + }
3475 +
3476 + /// The property the whole sheet exists for: a pin is an attribute, and it
3477 + /// beats the ambient default without `!important` or ordering games.
3478 + #[test]
3479 + fn the_default_follows_the_system_and_a_pin_outranks_it() {
3480 + let css = all_themes_css(
3481 + &shipped(),
3482 + &ThemeDefaults::new("goingson", "catppuccin-mocha"),
3483 + );
3484 +
3485 + assert!(css.starts_with(":root {\n"), "the light default is first");
3486 + assert!(css.contains("@media (prefers-color-scheme: dark) {\n:root {\n"));
3487 +
3488 + // Specificity, not order: (0,1,0) for the default against (0,2,0) for
3489 + // a keyed block. Asserted as the fact that the keyed blocks follow the
3490 + // defaults, which is the ordering that would matter if they tied.
3491 + let dark = css.find("prefers-color-scheme").unwrap();
3492 + let first_key = css.find(":root[data-theme=").unwrap();
3493 + assert!(dark < first_key, "defaults, then the keyed blocks");
3494 + }
3495 +
3496 + /// `for_variant` answers every mode by falling back to dark, so emitting a
3497 + /// `prefers-contrast` block unconditionally would answer the preference
3498 + /// with a theme that does not honour it.
3499 + #[test]
3500 + fn a_high_contrast_block_appears_only_when_one_was_named() {
3501 + let dirs = shipped();
3502 + let plain = ThemeDefaults::new("goingson", "catppuccin-mocha");
3503 + assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast"));
3504 +
3505 + let named = plain.clone().high_contrast("high-contrast");
3506 + let css = all_themes_css(&dirs, &named);
3507 + assert!(
3508 + css.contains("@media (prefers-contrast: more) {\n:root {\n"),
3509 + "{css}"
3510 + );
3511 + }
3512 +
3513 + /// A consumer's custom directory is user-writable, so one bad file there
3514 + /// costs its own block and nothing else.
3515 + #[test]
3516 + fn an_unloadable_theme_is_skipped_rather_than_failing_the_sheet() {
3517 + let custom = tempfile::tempdir().unwrap();
3518 + fs::write(custom.path().join("broken.toml"), "this is not = = toml").unwrap();
3519 + fs::write(
3520 + custom.path().join("mine.toml"),
3521 + "[meta]\nname = \"Mine\"\nvariant = \"dark\"\n[surface]\npage = \"#101010\"\n",
3522 + )
3523 + .unwrap();
3524 +
3525 + let mut dirs = shipped();
3526 + dirs.push((custom.path().to_path_buf(), true));
3527 + let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
3528 +
3529 + assert!(
3530 + css.contains(":root[data-theme=\"mine\"] {"),
3531 + "a custom theme is switchable too"
3532 + );
3533 + assert!(!css.contains("data-theme=\"broken\""), "{css}");
3534 + assert!(
3535 + css.contains(":root[data-theme=\"nord\"] {"),
3536 + "the rest of the sheet survives"
3537 + );
3538 + }
3539 +
3310 3540 // ---- typography ----
3311 3541
3312 3542 #[test]
@@ -1,0 +1,14 @@
1 + //! Print the switchable sheet: every shipped theme keyed by `data-theme`.
2 + //!
3 + //! `cargo run --example all_themes_sheet | head -40` is the quickest look at
4 + //! what a consumer links, and `| wc -c` is what it costs them.
5 +
6 + fn main() {
7 + let dirs = vec![(
8 + makeover::bundled_themes_dir().expect("run me from a checkout"),
9 + false,
10 + )];
11 + let defaults =
12 + makeover::ThemeDefaults::new("goingson", "catppuccin-mocha").high_contrast("high-contrast");
13 + print!("{}", makeover::all_themes_css(&dirs, &defaults));
14 + }