Skip to main content

max / makeover

theme-common 0.5.0: intent-based ("human design") schema + MNW adoption Themes are now authored by intent/role, not hue. Sections: [surface] page/raised/sunken/overlay, [content] primary/secondary/muted, [action] primary, [status] danger/success/warning/info, [line] border, [category] one..six (distinct decorative colors for tags/badges/charts). The crate gains the single resolver every product shares: resolve() reads the authored intents and computes the derived interactive states (action hover/active, content-on-action contrast, selection, row-stripe, status surfaces, border-strong) using the exact math the apps each re-implemented (lighten/darken/contrast/lerp), and emits them as intent CSS vars (intent_css_vars) or RGB tuples (SemanticTokens::rgb) for native consumers. - All 29 themes migrated to the intent schema (deterministic, color-preserving). - MNW: theming.rs emits the intent layer; style.css :root carries the makenotwork intent defaults + brand aliases (--detail/--highlight/--surface* over the intent tokens) so 300+ call sites stay valid; app-side literals (brown --warning, health/diff/stripe/tints/shadows) unchanged. Fixed a pre-existing dangling --bg-page ref (-> --surface-page). GoingsOn, Balanced Breakfast, and audiofiles adopt the shared resolver in follow-up commits (their repos). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 01:02 UTC
Commit: 9118c4ae1c5fb572f3d713bb81923b3e62975365
Parent: defb69b
4 files changed, +279 insertions, -115 deletions
M Cargo.lock +1 -1
@@ -273,7 +273,7 @@ dependencies = [
273 273
274 274 [[package]]
275 275 name = "theme-common"
276 - version = "0.4.0"
276 + version = "0.5.0"
277 277 dependencies = [
278 278 "serde",
279 279 "tempfile",
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "theme-common"
3 - version = "0.4.0"
3 + version = "0.5.0"
4 4 edition = "2024"
5 5
6 6 [dependencies]
@@ -0,0 +1,8 @@
1 + fn main() {
2 + let id = std::env::args().nth(1).unwrap_or_else(|| "makenotwork".into());
3 + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("themes");
4 + let t = theme_common::load_semantic(&[(dir, false)], &id).unwrap();
5 + for (k, v) in &t.intents {
6 + println!(" --{k}: {v};");
7 + }
8 + }
M src/lib.rs +269 -113
@@ -1,32 +1,46 @@
1 - //! Shared theme loading logic for TOML-based theme files.
1 + //! Shared theme loading + intent resolution for TOML-based theme files.
2 2 //!
3 - //! Used by GoingsOn, Balanced Breakfast (Tauri apps), and audiofiles (egui).
4 - //! audiofiles embeds themes at compile time but uses `ThemeMeta`, `parse_meta`,
5 - //! and `extract_colors` from this crate.
3 + //! Used by GoingsOn, Balanced Breakfast (Tauri apps), audiofiles (egui), and the
4 + //! MNW web server. Themes are authored by **intent** ("human design"): colors are
5 + //! declared by role (surface / content / action / status / line / category), not
6 + //! by hue. This crate is the single place that resolves an authored theme into a
7 + //! full set of intent tokens — including the derived interactive states
8 + //! (hover/active/selection/row-stripe/contrast) that each app used to recompute
9 + //! itself — and emits them as CSS variables or RGB tuples.
6 10 //!
7 - //! Theme files are TOML with this structure:
11 + //! Theme file shape:
8 12 //! ```text
9 13 //! [meta]
10 - //! name = "Theme Name"
11 - //! variant = "dark" # or "light"
14 + //! name = "Nord"
15 + //! variant = "dark" # or "light"
12 16 //!
13 - //! [background]
14 - //! primary = "#1e1e2e"
17 + //! [surface] # container backgrounds by role/elevation
18 + //! page = "#2e3440"; raised = "#3b4252"; sunken = "#434c5e"; overlay = "#3b4252"
15 19 //!
16 - //! [foreground]
17 - //! primary = "#cdd6f4"
20 + //! [content] # text/ink by emphasis
21 + //! primary = "#d8dee9"; secondary = "#e5e9f0"; muted = "#616e88"
18 22 //!
19 - //! [accent]
20 - //! primary = "#89b4fa"
23 + //! [action] # interactive / brand color
24 + //! primary = "#81a1c1"
21 25 //!
22 - //! [border]
23 - //! primary = "#45475a"
26 + //! [status] # state semantics
27 + //! danger = "#bf616a"; success = "#a3be8c"; warning = "#ebcb8b"; info = "#88c0d0"
28 + //!
29 + //! [line]
30 + //! border = "#4c566a"
31 + //!
32 + //! [category] # distinct decorative colors for tags/badges/charts
33 + //! one = "#bf616a"; two = "#a3be8c"; three = "#81a1c1"
34 + //! four = "#ebcb8b"; five = "#b48ead"; six = "#88c0d0"
24 35 //! ```
25 36
26 37 use serde::Serialize;
27 - use std::collections::HashMap;
38 + use std::collections::{BTreeMap, HashMap};
28 39 use std::path::{Path, PathBuf};
29 40
41 + /// The color sections an authored theme may declare.
42 + pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"];
43 +
30 44 /// Theme metadata parsed from the `[meta]` section.
31 45 #[derive(Debug, Clone, Serialize)]
32 46 #[serde(rename_all = "camelCase")]
@@ -37,7 +51,8 @@ pub struct ThemeMeta {
37 51 pub is_custom: bool,
38 52 }
39 53
40 - /// A fully loaded theme: metadata plus flattened color map.
54 + /// A loaded theme: metadata plus the authored colors, flattened to dotted keys
55 + /// (e.g. `"surface.page"`, `"status.danger"`, `"category.one"`).
41 56 #[derive(Debug, Serialize)]
42 57 #[serde(rename_all = "camelCase")]
43 58 pub struct ThemeColors {
@@ -45,6 +60,210 @@ pub struct ThemeColors {
45 60 pub colors: HashMap<String, String>,
46 61 }
47 62
63 + // ============================================================================
64 + // Color math (single source of truth for the four derivation ops)
65 + // ============================================================================
66 +
67 + /// An sRGB color. Hex round-trips losslessly.
68 + #[derive(Clone, Copy, Debug, PartialEq, Eq)]
69 + pub struct Rgb {
70 + pub r: u8,
71 + pub g: u8,
72 + pub b: u8,
73 + }
74 +
75 + impl Rgb {
76 + /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise.
77 + pub fn from_hex(s: &str) -> Option<Rgb> {
78 + let h = s.strip_prefix('#')?;
79 + let (r, g, b) = match h.len() {
80 + 6 => (
81 + u8::from_str_radix(&h[0..2], 16).ok()?,
82 + u8::from_str_radix(&h[2..4], 16).ok()?,
83 + u8::from_str_radix(&h[4..6], 16).ok()?,
84 + ),
85 + 3 => {
86 + let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17);
87 + (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?)
88 + }
89 + _ => return None,
90 + };
91 + Some(Rgb { r, g, b })
92 + }
93 +
94 + /// Lowercase `#rrggbb`.
95 + pub fn to_hex(self) -> String {
96 + format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
97 + }
98 +
99 + pub fn tuple(self) -> (u8, u8, u8) {
100 + (self.r, self.g, self.b)
101 + }
102 + }
103 +
104 + /// ITU-R BT.601 relative luminance in [0,1].
105 + fn luminance(c: Rgb) -> f32 {
106 + (0.299 * c.r as f32 + 0.587 * c.g as f32 + 0.114 * c.b as f32) / 255.0
107 + }
108 +
109 + /// Black or white, whichever contrasts with `bg`. Matches BB `contrastColor`
110 + /// and AF `contrast_color` (luminance > 0.5 → black).
111 + pub fn contrast_color(bg: Rgb) -> Rgb {
112 + if luminance(bg) > 0.5 {
113 + Rgb { r: 0, g: 0, b: 0 }
114 + } else {
115 + Rgb { r: 255, g: 255, b: 255 }
116 + }
117 + }
118 +
119 + /// Lighten each channel toward white by `pct` percent. Matches BB `lighten`.
120 + pub fn lighten(c: Rgb, pct: f32) -> Rgb {
121 + let f = |ch: u8| (ch as f32 + (255.0 - ch as f32) * (pct / 100.0)).round().clamp(0.0, 255.0) as u8;
122 + Rgb { r: f(c.r), g: f(c.g), b: f(c.b) }
123 + }
124 +
125 + /// Darken each channel toward black by `pct` percent. Matches BB `darken`.
126 + pub fn darken(c: Rgb, pct: f32) -> Rgb {
127 + let f = |ch: u8| (ch as f32 * (1.0 - pct / 100.0)).round().clamp(0.0, 255.0) as u8;
128 + Rgb { r: f(c.r), g: f(c.g), b: f(c.b) }
129 + }
130 +
131 + /// Linear blend from `a` to `b` by `t` in [0,1], per channel. Matches AF `lerp_color`.
132 + pub fn lerp(a: Rgb, b: Rgb, t: f32) -> Rgb {
133 + let f = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round().clamp(0.0, 255.0) as u8;
134 + Rgb { r: f(a.r, b.r), g: f(a.g, b.g), b: f(a.b, b.b) }
135 + }
136 +
137 + // ============================================================================
138 + // Intent resolution
139 + // ============================================================================
140 +
141 + /// Authored base intents: (TOML dotted source key, canonical token key).
142 + /// These are read straight from the theme; the token key is the CSS-var stem
143 + /// (`--{token}`) and the `rgb()` lookup key.
144 + pub const BASE_INTENTS: &[(&str, &str)] = &[
145 + ("surface.page", "surface-page"),
146 + ("surface.raised", "surface-raised"),
147 + ("surface.sunken", "surface-sunken"),
148 + ("surface.overlay", "surface-overlay"),
149 + ("content.primary", "content"),
150 + ("content.secondary", "content-secondary"),
151 + ("content.muted", "content-muted"),
152 + ("action.primary", "action"),
153 + ("status.danger", "danger"),
154 + ("status.success", "success"),
155 + ("status.warning", "warning"),
156 + ("status.info", "info"),
157 + ("line.border", "border"),
158 + ("category.one", "category-one"),
159 + ("category.two", "category-two"),
160 + ("category.three", "category-three"),
161 + ("category.four", "category-four"),
162 + ("category.five", "category-five"),
163 + ("category.six", "category-six"),
164 + ];
165 +
166 + /// A fully resolved intent layer: every token key → concrete `#rrggbb`.
167 + /// Includes both authored base intents and the computed derived intents.
168 + #[derive(Debug, Clone, Serialize)]
169 + #[serde(rename_all = "camelCase")]
170 + pub struct SemanticTokens {
171 + pub meta: ThemeMeta,
172 + /// token-key → resolved hex. Stable, deterministic ordering.
173 + pub intents: BTreeMap<String, String>,
174 + }
175 +
176 + impl SemanticTokens {
177 + /// Resolved hex for a token key, if present.
178 + pub fn hex(&self, key: &str) -> Option<&str> {
179 + self.intents.get(key).map(String::as_str)
180 + }
181 +
182 + /// Resolved RGB tuple for a token key (for egui / native consumers).
183 + pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
184 + self.intents.get(key).and_then(|h| Rgb::from_hex(h)).map(Rgb::tuple)
185 + }
186 + }
187 +
188 + /// Resolve an authored theme into the full intent token set.
189 + ///
190 + /// 1. Copy each present base intent from the authored colors.
191 + /// 2. Compute the derived interactive states from the base intents, using the
192 + /// same math the apps used to apply individually (so output is identical).
193 + /// Each derived token is emitted only when its source intents exist, mirroring
194 + /// the skip-missing behavior of the rest of the crate.
195 + pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
196 + let mut intents: BTreeMap<String, String> = BTreeMap::new();
197 +
198 + // 1. Base intents (authored).
199 + for (src, token) in BASE_INTENTS {
200 + if let Some(v) = theme.colors.get(*src) {
201 + intents.insert((*token).to_string(), v.clone());
202 + }
203 + }
204 +
205 + // Helper: parse an already-resolved token to Rgb.
206 + let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
207 +
208 + // 2. Derived intents.
209 + let mut derived: Vec<(String, Rgb)> = Vec::new();
210 + if let Some(action) = get(&intents, "action") {
211 + derived.push(("action-hover".into(), lighten(action, 10.0)));
212 + derived.push(("action-active".into(), darken(action, 10.0)));
213 + derived.push(("content-on-action".into(), contrast_color(action)));
214 + derived.push(("focus-ring".into(), action));
215 + if let Some(page) = get(&intents, "surface-page") {
216 + derived.push(("selection".into(), lerp(page, action, 0.3)));
217 + }
218 + }
219 + if let Some(page) = get(&intents, "surface-page") {
220 + if let Some(overlay) = get(&intents, "surface-overlay") {
221 + derived.push(("row-stripe".into(), lerp(page, overlay, 0.3)));
222 + }
223 + for status in ["danger", "success", "warning", "info"] {
224 + if let Some(c) = get(&intents, status) {
225 + derived.push((format!("{status}-surface"), lerp(page, c, 0.12)));
226 + }
227 + }
228 + }
229 + if let Some(sunken) = get(&intents, "surface-sunken") {
230 + derived.push(("hover-surface".into(), sunken));
231 + }
232 + if let Some(border) = get(&intents, "border") {
233 + derived.push(("border-strong".into(), darken(border, 10.0)));
234 + }
235 +
236 + for (token, rgb) in derived {
237 + intents.insert(token, rgb.to_hex());
238 + }
239 +
240 + SemanticTokens { meta: theme.meta.clone(), intents }
241 + }
242 +
243 + /// Emit the resolved intent layer as CSS declarations (no selector), one
244 + /// ` --token: #hex;` line each, in deterministic (BTreeMap) order.
245 + pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
246 + let mut out = String::new();
247 + for (token, hex) in &tokens.intents {
248 + out.push_str(" --");
249 + out.push_str(token);
250 + out.push_str(": ");
251 + out.push_str(hex);
252 + out.push_str(";\n");
253 + }
254 + out
255 + }
256 +
257 + /// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
258 + /// CSS mapping every web surface injects.
259 + pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
260 + format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
261 + }
262 +
263 + // ============================================================================
264 + // Loading / parsing
265 + // ============================================================================
266 +
48 267 /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
49 268 pub fn validate_theme_id(id: &str) -> Result<(), String> {
50 269 if !id
@@ -56,7 +275,7 @@ pub fn validate_theme_id(id: &str) -> Result<(), String> {
56 275 Ok(())
57 276 }
58 277
59 - /// Parse the `[meta]` section from a TOML table into `ThemeMeta`.
278 + /// Parse the `[meta]` section into `ThemeMeta`.
60 279 ///
61 280 /// Falls back to the file ID as the name and `"dark"` as the variant.
62 281 pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
@@ -72,19 +291,14 @@ pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
72 291 .unwrap_or("dark")
73 292 .to_string();
74 293
75 - ThemeMeta {
76 - id: id.to_string(),
77 - name,
78 - variant,
79 - is_custom,
80 - }
294 + ThemeMeta { id: id.to_string(), name, variant, is_custom }
81 295 }
82 296
83 - /// Extract color sections (background, foreground, accent, border) from a TOML
84 - /// table into a flat `HashMap` with keys like `"background.primary"`.
297 + /// Extract the intent color sections into a flat `HashMap` with dotted keys
298 + /// like `"surface.page"`, `"status.danger"`, `"category.one"`.
85 299 pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
86 300 let mut colors = HashMap::new();
87 - for section in &["background", "foreground", "accent", "border"] {
301 + for section in COLOR_SECTIONS {
88 302 if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
89 303 for (key, val) in sect {
90 304 if let Some(color) = val.as_str() {
@@ -161,11 +375,7 @@ pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, b
161 375 }
162 376
163 377 /// Parse a complete theme (metadata + colors) from raw TOML content, with no
164 - /// filesystem access.
165 - ///
166 - /// For callers that embed themes at compile time (e.g. the MNW server bundles
167 - /// `shared/themes/*.toml` into the binary) rather than reading a directory.
168 - /// Keeps TOML parsing inside this crate so consumers need only `theme_common`.
378 + /// filesystem access. For callers that embed themes at compile time.
169 379 pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
170 380 validate_theme_id(id)?;
171 381 let table: toml::Table = content
@@ -196,11 +406,15 @@ pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, Str
196 406 Ok(ThemeColors { meta, colors })
197 407 }
198 408
409 + /// Load a theme and resolve it to the full intent token set in one step.
410 + pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
411 + Ok(resolve(&load_theme(dirs, id)?))
412 + }
413 +
199 414 /// Import a theme TOML file into the custom themes directory.
200 415 ///
201 - /// Validates that the file is parseable TOML with the expected color sections,
202 - /// then copies it to `custom_dir/{id}.toml` where `id` is the file stem.
203 - /// Creates `custom_dir` if it doesn't exist. Returns the theme metadata.
416 + /// Validates that the file is parseable TOML with at least one intent color
417 + /// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
204 418 pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
205 419 let content = std::fs::read_to_string(source_path)
206 420 .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
@@ -209,12 +423,14 @@ pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta,
209 423 .parse()
210 424 .map_err(|e| format!("Invalid TOML: {}", e))?;
211 425
212 - // Verify it has at least one color section
213 - let has_colors = ["background", "foreground", "accent", "border"]
426 + let has_colors = COLOR_SECTIONS
214 427 .iter()
215 428 .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
216 429 if !has_colors {
217 - return Err("Theme file must have at least one color section (background, foreground, accent, or border)".to_string());
430 + return Err(format!(
431 + "Theme file must have at least one color section ({})",
432 + COLOR_SECTIONS.join(", ")
433 + ));
218 434 }
219 435
220 436 let id = source_path
@@ -237,8 +453,7 @@ pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta,
237 453 /// Delete a custom theme by ID.
238 454 ///
239 455 /// Only operates on `custom_dir` — bundled themes are not deletable through
240 - /// this entry point. Returns `Err` if the ID is invalid, the file does not
241 - /// exist, or the underlying remove fails.
456 + /// this entry point.
242 457 pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
243 458 validate_theme_id(id)?;
244 459
@@ -251,31 +466,32 @@ pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
251 466 .map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
252 467 }
253 468
254 - /// A four-color palette for preview chips, swatches, etc.
255 - ///
256 - /// Smaller than `ThemeColors`: only the `primary` value from each of the four
257 - /// canonical sections, so callers rendering a list of theme thumbnails don't
258 - /// need to allocate a full `HashMap` per row.
469 + /// A four-color preview for theme thumbnails: the representative swatch from
470 + /// each of the principal roles.
259 471 #[derive(Debug, Clone, Serialize)]
260 472 #[serde(rename_all = "camelCase")]
261 473 pub struct ThemePreview {
262 474 pub meta: ThemeMeta,
475 + /// Page background (`surface.page`).
263 476 pub background: Option<String>,
477 + /// Body text (`content.primary`).
264 478 pub foreground: Option<String>,
479 + /// Brand/interactive color (`action.primary`).
265 480 pub accent: Option<String>,
481 + /// Divider/outline color (`line.border`).
266 482 pub border: Option<String>,
267 483 }
268 484
269 - fn primary_color(table: &toml::Table, section: &str) -> Option<String> {
485 + fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
270 486 table
271 487 .get(section)
272 488 .and_then(|s| s.as_table())
273 - .and_then(|s| s.get("primary"))
489 + .and_then(|s| s.get(key))
274 490 .and_then(|v| v.as_str())
275 491 .map(|s| s.to_string())
276 492 }
277 493
278 - /// Load just the primary colors for a theme — for UI previews / thumbnails.
494 + /// Load just the preview swatches for a theme — for UI thumbnails.
279 495 pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
280 496 validate_theme_id(id)?;
281 497
@@ -291,17 +507,14 @@ pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePre
291 507
292 508 Ok(ThemePreview {
293 509 meta: parse_meta(id, &table, is_custom),
294 - background: primary_color(&table, "background"),
295 - foreground: primary_color(&table, "foreground"),
296 - accent: primary_color(&table, "accent"),
297 - border: primary_color(&table, "border"),
510 + background: color_at(&table, "surface", "page"),
511 + foreground: color_at(&table, "content", "primary"),
512 + accent: color_at(&table, "action", "primary"),
513 + border: color_at(&table, "line", "border"),
298 514 })
299 515 }
300 516
301 517 /// Export a theme to a user-chosen path.
302 - ///
303 - /// Finds the theme by ID in the given directories and copies the TOML file
304 - /// to `dest_path`.
305 518 pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
306 519 validate_theme_id(id)?;
307 520
@@ -314,67 +527,10 @@ pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Res
314 527 Ok(())
315 528 }
316 529
317 - /// The canonical TOML-key -> CSS-custom-property mapping for the primitive layer.
318 - ///
319 - /// This is the single source of truth for how a theme-common palette becomes CSS
320 - /// variables. Every web surface (MNW server-rendered pages, and eventually
321 - /// GoingsOn — which today hand-maintains an equivalent `COLOR_MAP` in JS) emits
322 - /// the *same* variable names from this table, so adding a token is one edit here.
323 - ///
324 - /// The 14 primitive tokens: 4 backgrounds, 3 foregrounds, 6 accents, 1 border.
325 - /// Apps derive their semantic roles (`--surface`, `--danger`, diff/health, …)
326 - /// from these in their own base CSS; the crate stays a pure palette.
327 - pub const PRIMITIVE_VARS: &[(&str, &str)] = &[
328 - ("background.primary", "--bg-primary"),
329 - ("background.secondary", "--bg-secondary"),
330 - ("background.tertiary", "--bg-tertiary"),
331 - ("background.surface", "--bg-surface"),
332 - ("foreground.primary", "--fg-primary"),
333 - ("foreground.secondary", "--fg-secondary"),
334 - ("foreground.muted", "--fg-muted"),
335 - ("accent.red", "--accent-red"),
336 - ("accent.green", "--accent-green"),
337 - ("accent.blue", "--accent-blue"),
338 - ("accent.yellow", "--accent-yellow"),
339 - ("accent.purple", "--accent-purple"),
340 - ("accent.cyan", "--accent-cyan"),
341 - ("border.default", "--border-default"),
342 - ];
343 -
344 - /// Emit the primitive layer as CSS declarations (no selector wrapper).
345 - ///
346 - /// One ` --name: value;` line per token that the theme actually defines, in the
347 - /// canonical [`PRIMITIVE_VARS`] order. Missing tokens are skipped rather than
348 - /// emitted empty, mirroring how the desktop apps apply only present keys. The
349 - /// caller chooses the selector — use [`to_css_vars`] for a ready `:root` block,
350 - /// or wrap these declarations in any scope (e.g. a creator canvas class).
351 - pub fn to_css_declarations(theme: &ThemeColors) -> String {
352 - let mut out = String::new();
353 - for (toml_key, css_var) in PRIMITIVE_VARS {
354 - if let Some(value) = theme.colors.get(*toml_key) {
355 - out.push_str(" ");
356 - out.push_str(css_var);
357 - out.push_str(": ");
358 - out.push_str(value);
359 - out.push_str(";\n");
360 - }
361 - }
362 - out
363 - }
364 -
365 - /// Emit the primitive layer as a CSS `:root { … }` block.
366 - ///
367 - /// This is the single TOML -> CSS mapping for the web. Inject the result into a
368 - /// page `<head>` (or serve it as a stylesheet) to apply a theme's primitive
369 - /// palette; the app's semantic layer derives from these variables.
370 - pub fn to_css_vars(theme: &ThemeColors) -> String {
371 - format!(":root {{\n{}}}\n", to_css_declarations(theme))
372 - }
373 -
374 530 /// Construct a dev fallback theme directory path.
375 531 ///
376 532 /// Given a `CARGO_MANIFEST_DIR`, walks up `levels` parent directories and
377 - /// appends `"themes"`. Returns the path if the directory exists.
533 + /// appends `MNW/shared/themes`. Returns the path if the directory exists.
378 534 pub fn dev_themes_dir(manifest_dir: &Path, levels: usize) -> Option<PathBuf> {
379 535 let mut path = manifest_dir.to_path_buf();
380 536 for _ in 0..levels {
@@ -393,6 +549,8 @@ mod tests {
393 549 use super::*;
394 550 use std::fs;
395 551
Lines truncated