| 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 |
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 |
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 |
|
}
|