Skip to main content

max / makeover

server: Tier 0 creator theming + fold in Run 18 storage work Unified theming groundwork (shared with AF/GO via theme-common): - theme-common 0.4.0: to_css_vars/to_css_declarations/PRIMITIVE_VARS — the single TOML->CSS mapping for the primitive layer — plus parse_theme_str. - shared/themes/mnw-default.toml: today's parchment look as a portable 14-token palette. - style.css: split :root into a primitive layer (theme-common var names) and a semantic layer derived from it; existing brand tokens kept as derived aliases (lossless, no call-site churn). - Tier 0: creators pick a built-in theme per profile/project (items inherit the parent project). Migration 138 adds nullable theme_id to users+projects; src/theming.rs embeds the bundled themes at compile time and caches their rendered CSS; theme_css is injected into profile/project/item <head>; pickers in the profile and project settings tabs; PUT /api/users/me/theme and PUT /api/projects/{id}/theme (validated, project bumps cache_generation). Also folds in the outstanding Run 18 storage/upload lifecycle changes present in the working tree (S3DeleteAuthority threading, orphan-queue confirms). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 00:07 UTC
Commit: defb69b7ae94d540a30bd50f897bb0d07fb5d313
Parent: 7f2fd9a
3 files changed, +157 insertions, -2 deletions
M Cargo.lock +1 -1
@@ -273,7 +273,7 @@ dependencies = [
273 273
274 274 [[package]]
275 275 name = "theme-common"
276 - version = "0.3.1"
276 + version = "0.4.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.3.1"
3 + version = "0.4.0"
4 4 edition = "2024"
5 5
6 6 [dependencies]
M src/lib.rs +155
@@ -160,6 +160,22 @@ pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, b
160 160 None
161 161 }
162 162
163 + /// 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`.
169 + pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
170 + validate_theme_id(id)?;
171 + let table: toml::Table = content
172 + .parse()
173 + .map_err(|e| format!("Failed to parse theme '{}': {}", id, e))?;
174 + let meta = parse_meta(id, &table, is_custom);
175 + let colors = extract_colors(&table);
176 + Ok(ThemeColors { meta, colors })
177 + }
178 +
163 179 /// Load a complete theme (metadata + colors) by ID from the given directories.
164 180 pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
165 181 validate_theme_id(id)?;
@@ -298,6 +314,63 @@ pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Res
298 314 Ok(())
299 315 }
300 316
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 +
301 374 /// Construct a dev fallback theme directory path.
302 375 ///
303 376 /// Given a `CARGO_MANIFEST_DIR`, walks up `levels` parent directories and
@@ -751,4 +824,86 @@ primary = "#444"
751 824
752 825 assert!(export_theme(&dirs, "nonexistent", &dest).is_err());
753 826 }
827 +
828 + fn theme_with(colors: &[(&str, &str)]) -> ThemeColors {
829 + ThemeColors {
830 + meta: ThemeMeta {
831 + id: "t".into(),
832 + name: "T".into(),
833 + variant: "dark".into(),
834 + is_custom: false,
835 + },
836 + colors: colors
837 + .iter()
838 + .map(|(k, v)| (k.to_string(), v.to_string()))
839 + .collect(),
840 + }
841 + }
842 +
843 + #[test]
844 + fn to_css_declarations_maps_all_primitives() {
845 + let theme = theme_with(&[
846 + ("background.primary", "#111111"),
847 + ("background.secondary", "#222222"),
848 + ("background.tertiary", "#333333"),
849 + ("background.surface", "#444444"),
850 + ("foreground.primary", "#eeeeee"),
851 + ("foreground.secondary", "#dddddd"),
852 + ("foreground.muted", "#aaaaaa"),
853 + ("accent.red", "#ff0000"),
854 + ("accent.green", "#00ff00"),
855 + ("accent.blue", "#0000ff"),
856 + ("accent.yellow", "#ffff00"),
857 + ("accent.purple", "#ff00ff"),
858 + ("accent.cyan", "#00ffff"),
859 + ("border.default", "#555555"),
860 + ]);
861 + let css = to_css_declarations(&theme);
862 + assert!(css.contains(" --bg-primary: #111111;\n"));
863 + assert!(css.contains(" --bg-surface: #444444;\n"));
864 + assert!(css.contains(" --fg-muted: #aaaaaa;\n"));
865 + assert!(css.contains(" --accent-purple: #ff00ff;\n"));
866 + assert!(css.contains(" --border-default: #555555;\n"));
867 + // One line per primitive token, all 14 present.
868 + assert_eq!(css.lines().count(), PRIMITIVE_VARS.len());
869 + }
870 +
871 + #[test]
872 + fn to_css_declarations_skips_missing_tokens() {
873 + let theme = theme_with(&[
874 + ("background.primary", "#111111"),
875 + ("accent.blue", "#0000ff"),
876 + ]);
877 + let css = to_css_declarations(&theme);
878 + assert_eq!(css, " --bg-primary: #111111;\n --accent-blue: #0000ff;\n");
879 + assert!(!css.contains("--bg-surface"));
880 + }
881 +
882 + #[test]
883 + fn to_css_declarations_preserves_canonical_order() {
884 + // Even when the map iterates arbitrarily, output follows PRIMITIVE_VARS.
885 + let theme = theme_with(&[
886 + ("accent.blue", "#0000ff"),
887 + ("background.primary", "#111111"),
888 + ]);
889 + let css = to_css_declarations(&theme);
890 + let bg = css.find("--bg-primary").unwrap();
891 + let accent = css.find("--accent-blue").unwrap();
892 + assert!(bg < accent, "background must precede accent per canonical order");
893 + }
894 +
895 + #[test]
896 + fn to_css_vars_wraps_in_root_block() {
897 + let theme = theme_with(&[("background.primary", "#111111")]);
898 + let css = to_css_vars(&theme);
899 + assert!(css.starts_with(":root {\n"));
900 + assert!(css.contains(" --bg-primary: #111111;\n"));
901 + assert!(css.trim_end().ends_with('}'));
902 + }
903 +
904 + #[test]
905 + fn to_css_vars_empty_theme_is_empty_root() {
906 + let theme = theme_with(&[]);
907 + assert_eq!(to_css_vars(&theme), ":root {\n}\n");
908 + }
754 909 }