//! Tier 0 creator theming.
//!
//! The platform ships a set of built-in palettes (`shared/themes/*.toml`, the
//! same theme-common format the desktop apps emit and consume). A creator may
//! pick one for their public profile and for each project; items inherit their
//! parent project's choice. `None` everywhere means the platform default,
//! [`DEFAULT_THEME_ID`].
//!
//! Themes are embedded at compile time so production needs no themes directory
//! on disk. Each theme is resolved to its intent tokens and rendered once via
//! [`theme_common::intent_css_vars`] and cached; page handlers inject the
//! rendered string into the page `
`, where it overrides the default
//! intent `:root` from `static/style.css` (the brand aliases derive from there,
//! so the whole page re-themes). This is the single TOML -> CSS mapping shared
//! with GoingsOn and audiofiles.
use std::collections::BTreeMap;
use std::sync::LazyLock;
use include_dir::{Dir, include_dir};
use theme_common::{ThemeMeta, intent_css_vars, parse_theme_str, resolve};
/// The bundled themes, embedded from the shared crate at compile time.
static THEMES_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../shared/themes");
/// The platform default theme id — the stock parchment look. Reproduces the
/// historical `:root` exactly, so an unset (`None`) choice renders unchanged.
pub const DEFAULT_THEME_ID: &str = "makenotwork";
/// A built-in theme: its metadata (for the picker) and its pre-rendered
/// intent-layer CSS (for `` injection).
pub struct ThemeEntry {
pub meta: ThemeMeta,
/// `:root { … }` block of resolved intent tokens, ready to inline.
pub css: String,
}
/// Registry of built-in themes keyed by id, sorted by id for a stable picker.
static REGISTRY: LazyLock> = LazyLock::new(|| {
let mut map = BTreeMap::new();
for file in THEMES_DIR.files() {
let path = file.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Some(content) = file.contents_utf8() else {
continue;
};
match parse_theme_str(id, content, false) {
Ok(theme) => {
let tokens = resolve(&theme);
let css = intent_css_vars(&tokens);
map.insert(id.to_string(), ThemeEntry { meta: tokens.meta, css });
}
Err(e) => {
// A malformed bundled theme is a build-content bug, not a
// runtime input — log and skip rather than abort startup.
tracing::error!(theme = id, error = %e, "skipping unparseable bundled theme");
}
}
}
map
});
/// Whether `id` names a known built-in theme. Use to validate a creator's
/// chosen id before persisting it.
pub fn is_valid_theme(id: &str) -> bool {
REGISTRY.contains_key(id)
}
/// The rendered primitive-layer CSS for a creator's chosen theme.
///
/// Falls back to the default theme when the choice is `None` or names a theme
/// that no longer exists (e.g. a bundled theme was removed after a creator
/// picked it). Returns `""` only if even the default is missing, which would be
/// a build-content bug; the page then renders with the stylesheet's own `:root`.
pub fn theme_css(id: Option<&str>) -> &'static str {
let chosen = id
.filter(|i| REGISTRY.contains_key(*i))
.unwrap_or(DEFAULT_THEME_ID);
REGISTRY
.get(chosen)
.or_else(|| REGISTRY.get(DEFAULT_THEME_ID))
.map(|e| e.css.as_str())
.unwrap_or("")
}
/// All built-in themes, sorted by display name, for rendering a theme picker.
/// The currently-selected id (or the default) is the caller's concern.
pub fn list_themes() -> Vec<&'static ThemeMeta> {
let mut themes: Vec<&ThemeMeta> = REGISTRY.values().map(|e| &e.meta).collect();
themes.sort_by(|a, b| a.name.cmp(&b.name));
themes
}
/// Validate and normalize a submitted theme id.
///
/// Trims, treats absent/empty as "clear to the platform default" (`Ok(None)`),
/// accepts a known built-in (`Ok(Some(id))`), and rejects any other non-empty
/// value (`Err(the_offending_id)`). Callers map the `Err` to a validation error.
pub fn normalize_theme_id(raw: Option<&str>) -> Result