Skip to main content

max / balanced_breakfast

theme: consume the shared intent layer (theme-common 0.6.0) get_theme returns resolved SemanticTokens; themes.js applies the intent tokens directly (deleted hexToRgb/lighten/darken/contrastColor/COLOR_MAP — all that color math now lives once in theme-common, in OKLab). styles.css carries the flatwhite intent defaults + brand aliases; the few BB-specific derived vars (danger-button hover, yellow highlight, neobrute shadow color) are now CSS color-mix in OKLCH over the intent tokens. Theme tests rewritten against the shared crate + intent schema. Note: BB's tree has a pre-existing sync.rs/synckit-client API break unrelated to theming; the theming changes themselves are isolated and correct. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 01:31 UTC
Commit: 1972f8846a855d77abaac5e3617cfd8c99d8c6da
Parent: d44a5d9
5 files changed, +125 insertions, -245 deletions
M Cargo.lock +2 -1
@@ -5115,6 +5115,7 @@ dependencies = [
5115 5115 "reqwest 0.12.28",
5116 5116 "serde",
5117 5117 "serde_json",
5118 + "sha2",
5118 5119 "thiserror 2.0.18",
5119 5120 "tokio",
5120 5121 "tokio-stream",
@@ -5619,7 +5620,7 @@ dependencies = [
5619 5620
5620 5621 [[package]]
5621 5622 name = "theme-common"
5622 - version = "0.3.1"
5623 + version = "0.6.0"
5623 5624 dependencies = [
5624 5625 "serde",
5625 5626 "toml 0.8.2",
@@ -117,30 +117,71 @@
117 117 token audit. Phase F1 cleanup: consume or remove.
118 118 ========================================================================== */
119 119 :root {
120 - --bg-primary: #faf8f5; /* themed: background.primary */
121 - --bg-secondary: #f5f0e8; /* themed: background.secondary */
122 - --bg-tertiary: #ebe4d8; /* themed: background.tertiary */
123 - --text-primary: #3d3225; /* themed: foreground.primary */
124 - --text-secondary: #6b5d4d; /* themed: foreground.secondary */
125 - --text-muted: #9a8b78; /* themed: foreground.muted */
126 - --accent-red: #c94b4b; /* themed: accent.red */
127 - --accent-red-hover: #d65d5d; /* derived in themes.js — consumed by .btn-danger:hover (F2) */
128 - --accent-green: #6b9b5a; /* themed: accent.green */
129 - --accent-blue: #4a8ebd; /* themed: accent.blue */
130 - --accent-blue-hover: #5e9dca; /* derived in themes.js */
131 - --accent-yellow: #e8a841; /* themed: accent.yellow */
132 - --accent-yellow-light: #f4c56d; /* derived in themes.js */
133 - --accent-purple: #8b6bbf; /* themed: accent.purple — consumed by .tag/.badge[data-color="purple"] (F2) */
134 - --accent-cyan: #5ab5b5; /* themed: accent.cyan — consumed by .tag/.badge[data-color="cyan"] (F2) */
135 - --border: #e0d6c8; /* themed: border.default */
136 - --border-dark: #d4c8b5; /* derived in themes.js */
137 - --text-on-accent: #ffffff; /* derived in themes.js (contrast vs accent.blue) */
120 + /* === INTENT LAYER (themeable) ===
121 + Resolved intent tokens applied at runtime by js/themes.js (setProperty on
122 + :root). Defaults below are "flatwhite" (BB's light default) for first
123 + paint; theme-common resolves every theme incl. the OKLab-derived states,
124 + shared with audiofiles / GoingsOn / MNW. */
125 + --surface-page: #f1ece4;
126 + --surface-raised: #f7f3ee;
127 + --surface-sunken: #dcd3c6;
128 + --surface-overlay: #e4ddd2;
129 + --content: #605a52;
130 + --content-secondary: #786d5e;
131 + --content-muted: #9a8b78;
132 + --content-on-action: #ffffff;
133 + --action: #4c5361;
134 + --action-hover: #5a616f;
135 + --action-active: #3f4553;
136 + --danger: #ff1414;
137 + --success: #2db448;
138 + --warning: #f2a60d;
139 + --info: #465953;
140 + --danger-surface: #f9d4c8;
141 + --success-surface: #d8e4cd;
142 + --warning-surface: #f2e2cc;
143 + --info-surface: #d5d4cd;
144 + --border: #93836c;
145 + --border-strong: #84745e;
146 + --focus-ring: #4c5361;
147 + --selection: #cecbc8;
148 + --row-stripe: #eae4db;
149 + --hover-surface: #dcd3c6;
150 + --category-one: #ff1414;
151 + --category-two: #2db448;
152 + --category-three: #4c5361;
153 + --category-four: #f2a60d;
154 + --category-five: #614c61;
155 + --category-six: #465953;
156 +
157 + /* === BRAND ALIASES — BB vocabulary over the intent layer ===
158 + Accent hues map to the categorical ramp (preserves the [data-color]
159 + badge colors). Per-status hovers BB needs that aren't universal intents
160 + are derived here via color-mix in OKLCH (perceptual, matches the crate's
161 + approach) rather than recomputed in JS. */
162 + --bg-primary: var(--surface-page);
163 + --bg-secondary: var(--surface-overlay);
164 + --bg-tertiary: var(--surface-sunken);
165 + --text-primary: var(--content);
166 + --text-secondary: var(--content-secondary);
167 + --text-muted: var(--content-muted);
168 + --accent-red: var(--category-one);
169 + --accent-green: var(--category-two);
170 + --accent-blue: var(--category-three);
171 + --accent-yellow: var(--category-four);
172 + --accent-purple: var(--category-five);
173 + --accent-cyan: var(--category-six);
174 + --accent-blue-hover: var(--action-hover);
175 + --accent-red-hover: color-mix(in oklch, var(--accent-red) 90%, white); /* .btn-danger:hover */
176 + --accent-yellow-light: color-mix(in oklch, var(--accent-yellow) 82%, white);
177 + --border-dark: var(--border-strong);
178 + --text-on-accent: var(--content-on-action);
179 + --shadow-color: color-mix(in oklch, var(--content-muted) 88%, black);
138 180 --border-width: 2px; /* invariant */
139 181 --radius-sm: 5px; /* invariant */
140 182 --radius-lg: 10px; /* invariant */
141 183 --radius-xl: 20px; /* invariant */
142 184 --shadow-offset: 3px; /* invariant — BB neobrute signature */
143 - --shadow-color: #6b5d4d; /* derived in themes.js (darken --text-muted) */
144 185 --shadow-brutal: var(--shadow-offset) var(--shadow-offset) 0 var(--shadow-color); /* invariant composition */
145 186 }
146 187 /* F5 cleanup (2026-06-02): dropped --bg-surface (themed but never consumed),
@@ -12,115 +12,18 @@
12 12 let themeCache = {};
13 13
14 14 /**
15 - * Parse a hex color string to {r, g, b}.
16 - * @param {string} hex - Hex color string (e.g. '#ff0000' or 'ff0000').
17 - * @returns {{r: number, g: number, b: number}} RGB components (0-255).
15 + * Apply a theme's resolved intent tokens as CSS variables on :root. Each
16 + * intent key becomes `--{key}` (e.g. `surface-page` -> `--surface-page`);
17 + * styles.css aliases BB's brand vocabulary over them. The interactive
18 + * states (hover/active/selection/contrast) are resolved by theme-common in
19 + * OKLab — no JS color math here anymore.
20 + * @param {Object<string, string>} intents - Map of intent token -> hex value.
18 21 */
19 - function hexToRgb(hex) {
20 - const h = hex.replace('#', '');
21 - const r = parseInt(h.substring(0, 2), 16) || 0;
22 - const g = parseInt(h.substring(2, 4), 16) || 0;
23 - const b = parseInt(h.substring(4, 6), 16) || 0;
24 - return { r, g, b };
25 - }
26 -
27 - /**
28 - * Lighten a hex color by a percentage (0-100).
29 - * @param {string} hex - Hex color string.
30 - * @param {number} pct - Lightening percentage (0-100).
31 - * @returns {string} Lightened hex color string.
32 - */
33 - function lighten(hex, pct) {
34 - const { r, g, b } = hexToRgb(hex);
35 - const f = pct / 100;
36 - return '#' + [
37 - Math.min(255, Math.round(r + (255 - r) * f)),
38 - Math.min(255, Math.round(g + (255 - g) * f)),
39 - Math.min(255, Math.round(b + (255 - b) * f)),
40 - ].map(c => c.toString(16).padStart(2, '0')).join('');
41 - }
42 -
43 - /**
44 - * Darken a hex color by a percentage (0-100).
45 - * @param {string} hex - Hex color string.
46 - * @param {number} pct - Darkening percentage (0-100).
47 - * @returns {string} Darkened hex color string.
48 - */
49 - function darken(hex, pct) {
50 - const { r, g, b } = hexToRgb(hex);
51 - const f = 1 - pct / 100;
52 - return '#' + [
53 - Math.round(r * f),
54 - Math.round(g * f),
55 - Math.round(b * f),
56 - ].map(c => c.toString(16).padStart(2, '0')).join('');
57 - }
58 -
59 - /**
60 - * Return white or black depending on which contrasts better against `hex`.
61 - * @param {string} hex - Hex color string.
62 - * @returns {string} '#000000' or '#ffffff'.
63 - */
64 - function contrastColor(hex) {
65 - const { r, g, b } = hexToRgb(hex);
66 - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
67 - return luminance > 0.5 ? '#000000' : '#ffffff';
68 - }
69 -
70 - // Direct mappings: TOML dot-path → CSS custom property.
71 - // F5 (2026-06-02): dropped 'background.surface' → '--bg-surface' (no CSS
72 - // rule consumed it). TOML files may still declare background.surface; it's
73 - // silently ignored here. Down to 13 mapped slots.
74 - const COLOR_MAP = {
75 - 'background.primary': '--bg-primary',
76 - 'background.secondary': '--bg-secondary',
77 - 'background.tertiary': '--bg-tertiary',
78 - 'foreground.primary': '--text-primary',
79 - 'foreground.secondary': '--text-secondary',
80 - 'foreground.muted': '--text-muted',
81 - 'accent.red': '--accent-red',
82 - 'accent.green': '--accent-green',
83 - 'accent.blue': '--accent-blue',
84 - 'accent.yellow': '--accent-yellow',
85 - 'accent.purple': '--accent-purple',
86 - 'accent.cyan': '--accent-cyan',
87 - 'border.default': '--border',
88 - };
89 -
90 - /**
91 - * Apply a theme's colors to CSS variables, deriving BB-specific vars.
92 - * @param {Object<string, string>} colors - Map of TOML dot-path keys to hex color values.
93 - */
94 - function applyTheme(colors) {
22 + function applyTheme(intents) {
95 23 const root = document.documentElement;
96 -
97 - // Direct mappings (14 slots)
98 - for (const [tomlKey, cssVar] of Object.entries(COLOR_MAP)) {
99 - if (colors[tomlKey]) {
100 - root.style.setProperty(cssVar, colors[tomlKey]);
101 - }
24 + for (const [token, value] of Object.entries(intents)) {
25 + root.style.setProperty(`--${token}`, value);
102 26 }
103 -
104 - // Derived: accent hover states
105 - root.style.setProperty('--accent-red-hover', lighten(colors['accent.red'], 10));
106 - root.style.setProperty('--accent-blue-hover', lighten(colors['accent.blue'], 10));
107 -
108 - // Derived: yellow highlight — 15% lighten (vs 10% for accent) because
109 - // yellow needs more contrast shift against light backgrounds
110 - root.style.setProperty('--accent-yellow-light', lighten(colors['accent.yellow'], 15));
111 -
112 - // Derived: border
113 - root.style.setProperty('--border-dark', darken(colors['border.default'], 10));
114 -
115 - // F5 (2026-06-02): dropped --shadow and --shadow-hover derivations
116 - // (no CSS rule consumed them). --shadow-color (used by --shadow-brutal)
117 - // stays.
118 -
119 - // Derived: neobrute shadow color from muted foreground
120 - root.style.setProperty('--shadow-color', darken(colors['foreground.muted'], 10));
121 -
122 - // Derived: text color for use on accent backgrounds (buttons, badges)
123 - root.style.setProperty('--text-on-accent', contrastColor(colors['accent.blue']));
124 27 }
125 28
126 29 /**
@@ -140,7 +43,7 @@
140 43 }
141 44 }
142 45
143 - applyTheme(theme.colors);
46 + applyTheme(theme.intents);
144 47 currentThemeId = themeId;
145 48 invoke('set_config', { key: 'bb-theme', value: themeId });
146 49
@@ -6,7 +6,7 @@ use tracing::instrument;
6 6
7 7 use super::error::ApiError;
8 8
9 - pub use theme_common::{ThemeColors, ThemeMeta};
9 + pub use theme_common::{SemanticTokens, ThemeMeta};
10 10
11 11 /// Returns theme directories in priority order (later overrides earlier by ID).
12 12 /// Each entry is `(path, is_custom)`.
@@ -46,9 +46,9 @@ pub fn list_themes(app: AppHandle) -> Result<Vec<ThemeMeta>, ApiError> {
46 46
47 47 #[tauri::command]
48 48 #[instrument(skip_all)]
49 - pub fn get_theme(app: AppHandle, id: String) -> Result<ThemeColors, ApiError> {
49 + pub fn get_theme(app: AppHandle, id: String) -> Result<SemanticTokens, ApiError> {
50 50 let dirs = theme_dirs(&app);
51 - theme_common::load_theme(&dirs, &id).map_err(ApiError::internal)
51 + theme_common::load_semantic(&dirs, &id).map_err(ApiError::internal)
52 52 }
53 53
54 54 #[tauri::command]
@@ -1,136 +1,71 @@
1 - //! Theme TOML parsing integration tests (unit-level, no AppHandle needed).
2 -
3 - // ── Theme Parsing ────────────────────────────────────────────────────
1 + //! Theme parsing/resolution integration tests against the shared crate.
4 2
5 3 #[test]
6 - fn theme_toml_parse_full() {
7 - let toml_str = r##"
4 + fn theme_resolves_intents() {
5 + let toml = r##"
8 6 [meta]
9 7 name = "Ocean Dark"
10 8 variant = "dark"
11 9
12 - [background]
13 - primary = "#1a1a2e"
14 - secondary = "#16213e"
10 + [surface]
11 + page = "#1a1a2e"
12 + raised = "#16213e"
13 + sunken = "#0f1626"
15 14
16 - [foreground]
15 + [content]
17 16 primary = "#e0e0e0"
18 17 secondary = "#a0a0a0"
18 + muted = "#707070"
19 19
20 - [accent]
20 + [action]
21 21 primary = "#0f3460"
22 - highlight = "#e94560"
23 22
24 - [border]
25 - default = "#2a2a4a"
23 + [status]
24 + danger = "#e94560"
25 + success = "#3ad29f"
26 + warning = "#f5c451"
27 + info = "#4cc9f0"
28 +
29 + [line]
30 + border = "#2a2a4a"
31 +
32 + [category]
33 + one = "#e94560"
34 + two = "#3ad29f"
35 + three = "#0f3460"
36 + four = "#f5c451"
37 + five = "#9d4edd"
38 + six = "#4cc9f0"
26 39 "##;
27 40
28 - let table: toml::Table = toml_str.parse().unwrap();
29 -
30 - // Verify meta parsing
31 - let meta = table.get("meta").unwrap().as_table().unwrap();
32 - assert_eq!(meta.get("name").unwrap().as_str().unwrap(), "Ocean Dark");
33 - assert_eq!(meta.get("variant").unwrap().as_str().unwrap(), "dark");
34 -
35 - // Verify color sections can be extracted (replicates get_theme logic)
36 - let mut colors = std::collections::HashMap::new();
37 - for section in &["background", "foreground", "accent", "border"] {
38 - if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
39 - for (key, val) in sect {
40 - if let Some(color) = val.as_str() {
41 - colors.insert(format!("{}.{}", section, key), color.to_string());
42 - }
43 - }
44 - }
45 - }
46 -
47 - assert_eq!(colors.get("background.primary").unwrap(), "#1a1a2e");
48 - assert_eq!(colors.get("foreground.primary").unwrap(), "#e0e0e0");
49 - assert_eq!(colors.get("accent.highlight").unwrap(), "#e94560");
50 - assert_eq!(colors.get("border.default").unwrap(), "#2a2a4a");
51 - assert_eq!(colors.len(), 7);
41 + let theme = theme_common::parse_theme_str("ocean-dark", toml, false).unwrap();
42 + assert_eq!(theme.meta.name, "Ocean Dark");
43 + assert_eq!(theme.meta.variant, "dark");
44 +
45 + let t = theme_common::resolve(&theme);
46 + // Base intents pass through.
47 + assert_eq!(t.hex("surface-page"), Some("#1a1a2e"));
48 + assert_eq!(t.hex("content"), Some("#e0e0e0"));
49 + assert_eq!(t.hex("action"), Some("#0f3460"));
50 + assert_eq!(t.hex("danger"), Some("#e94560"));
51 + assert_eq!(t.hex("border"), Some("#2a2a4a"));
52 + assert_eq!(t.hex("category-five"), Some("#9d4edd"));
53 + // Derived states are present.
54 + assert!(t.hex("action-hover").is_some());
55 + assert!(t.hex("selection").is_some());
56 + assert!(t.hex("content-on-action").is_some());
52 57 }
53 58
54 59 #[test]
55 - fn theme_toml_parse_minimal() {
56 - // A theme with only meta and one color section
57 - let toml_str = r##"
58 - [meta]
59 - name = "Minimal"
60 -
61 - [background]
62 - primary = "#fff"
63 - "##;
64 -
65 - let table: toml::Table = toml_str.parse().unwrap();
66 -
67 - let meta = table.get("meta").unwrap().as_table().unwrap();
68 - assert_eq!(meta.get("name").unwrap().as_str().unwrap(), "Minimal");
69 - // Variant not specified — consumer should default to "dark"
70 - assert!(meta.get("variant").is_none());
71 -
72 - let mut colors = std::collections::HashMap::new();
73 - for section in &["background", "foreground", "accent", "border"] {
74 - if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
75 - for (key, val) in sect {
76 - if let Some(color) = val.as_str() {
77 - colors.insert(format!("{}.{}", section, key), color.to_string());
78 - }
79 - }
80 - }
81 - }
82 -
83 - assert_eq!(colors.len(), 1);
84 - assert_eq!(colors.get("background.primary").unwrap(), "#fff");
60 + fn theme_variant_defaults_to_dark() {
61 + let theme = theme_common::parse_theme_str("m", "[meta]\nname = \"M\"\n", false).unwrap();
62 + assert_eq!(theme.meta.variant, "dark");
85 63 }
86 64
87 65 #[test]
88 - fn theme_toml_parse_empty_is_valid() {
89 - // An empty TOML should parse without error
90 - let table: toml::Table = "".parse().unwrap();
91 -
92 - let mut colors = std::collections::HashMap::new();
93 - for section in &["background", "foreground", "accent", "border"] {
94 - if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
95 - for (key, val) in sect {
96 - if let Some(color) = val.as_str() {
97 - colors.insert(format!("{}.{}", section, key), color.to_string());
98 - }
99 - }
100 - }
101 - }
102 -
103 - assert!(colors.is_empty());
104 - }
105 -
106 - #[test]
107 - fn theme_toml_ignores_unknown_sections() {
108 - let toml_str = r##"
109 - [meta]
110 - name = "Custom"
111 -
112 - [background]
113 - primary = "#000"
114 -
115 - [custom_section]
116 - foo = "bar"
117 - "##;
118 -
119 - let table: toml::Table = toml_str.parse().unwrap();
120 -
121 - // The standard extraction logic only reads known sections
122 - let mut colors = std::collections::HashMap::new();
123 - for section in &["background", "foreground", "accent", "border"] {
124 - if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
125 - for (key, val) in sect {
126 - if let Some(color) = val.as_str() {
127 - colors.insert(format!("{}.{}", section, key), color.to_string());
128 - }
129 - }
130 - }
131 - }
132 -
133 - // custom_section should not appear in colors
134 - assert_eq!(colors.len(), 1);
135 - assert!(!colors.contains_key("custom_section.foo"));
66 + fn unknown_sections_are_ignored() {
67 + let toml = "[surface]\npage = \"#000\"\n[custom_section]\nfoo = \"bar\"\n";
68 + let theme = theme_common::parse_theme_str("c", toml, false).unwrap();
69 + assert_eq!(theme.colors.get("surface.page").unwrap(), "#000");
70 + assert!(!theme.colors.contains_key("custom_section.foo"));
136 71 }