//! Tier 0 creator theming.
//!
//! The platform ships a set of built-in palettes (the themes `makeover`
//! embeds, the same set the desktop apps 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
//! [`makeover::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 makeover::{
ThemeMeta, ThemeSelection, embedded_themes, intent_css_vars, parse_theme_str, resolve,
};
/// 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 (id, content) in embedded_themes() {
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 is not in the registry (a bundled theme 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_or("", |e| e.css.as_str())
}
/// 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