runtime + host: the theme engine (theme documents, choice catalog, page runtime, fonts; native theme state)

The site-independent theme engine moves from wayBetter for X into the framework:
- runtime/themeSpec.ts: theme document types, the schema's defaults (BASE_*), sanitizing,
  import, overrides, ensureContrast, font roles/stacks, font choices, backgrounds.
- runtime/themeChoice.ts: ThemeChoice and createThemeCatalog(builtins) (normalize, base,
  resolve, palette, all), choiceForId, serializeChoice.
- runtime/themeRuntime.ts: installThemeRuntime(win, site) (live CSS over the site's rules,
  --wb-* vars, site color mode, first-frame snapshot), collectRules, resolvedFonts.
- runtime/fonts.ts: bundled fonts and owner-downloaded Google Fonts from bytes.
- themeApi exports hexToRgb, mix and MONO_FAMILIES (one copy: themeTokens and themeSpec use them).
- host theme.rs: ThemeChoice/ThemeScopes over a site's ThemeSite (envelope checks against
  theme.schema.json, which theme a tab shows, page color, delivery script).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-29 13:07:59 +07:00
parent 0c107b27ff
commit 01f2ec0417
9 changed files with 1542 additions and 14 deletions

View file

@ -0,0 +1,410 @@
//! The owner's theme state as native code keeps it: the theme choice (a theme id, an accent, the
//! panel's per-group overrides and the owner's imported theme documents; `x-theme.v2.json` style
//! app data) and which theme each site tab shows ([`ThemeScopes`]: a pane's own theme, else its
//! account profile's, else the global one). The same rules run in the shell
//! (`waybetter/packages/runtime/src/themeChoice.ts`, `shell/src/frame/themeScopes.ts`).
//!
//! Theme documents are declarative JSON (`theme.schema.json` in the `waybetter` package) that the page
//! runtime sanitizes field by field (`runtime/src/themeSpec.ts`: hex colors, clamped numbers, fixed
//! enums, font names matching a strict pattern, `data:image/*` backgrounds). Here only the envelope is
//! checked against the schema's own rules (ids, names, sizes, override groups), so nothing unbounded
//! is persisted or evaluated in every site tab. What differs per site is its built-in themes
//! ([`ThemeSite`]).
use crate::site::SiteSpec;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
/// A site's built-in themes. The first preset is the site's own look (the default choice).
#[derive(Clone, Copy, Debug)]
pub struct ThemeSite {
/// Built-in theme ids the page runtime knows (the site's `BUILTIN_THEMES`), its own look first.
pub presets: &'static [&'static str],
/// The page color (`colors.page`, else `colors.bg`) of every built-in theme with colors of its
/// own: a tab webview's native background before the page paints. The site's own look has none.
pub page_colors: &'static [(&'static str, &'static str)],
}
impl ThemeSite {
/// The site's own look (`presets[0]`).
pub fn own(&self) -> &'static str {
self.presets.first().copied().unwrap_or("site")
}
}
/// At most this many imported/user themes are kept (`MAX_USER_THEMES` in themeChoice.ts).
pub const MAX_THEMES: usize = 24;
/// One theme document (a background image is a `data:` URL inside it).
pub const MAX_THEME_BYTES: usize = 1_200_000;
/// The whole persisted theme state, all themes included.
pub const MAX_TOTAL_BYTES: usize = 4_000_000;
/// Theme names: 1-60 characters (`theme.schema.json` `name`).
pub const MAX_NAME_CHARS: usize = 60;
/// Override groups the panel may change on top of a theme.
pub const OVERRIDE_KEYS: [&str; 7] = ["font", "shape", "layout", "background", "effects", "colors", "css"];
/// The theme applied to the site's tabs: a theme id (built-in or one of `themes`), an optional
/// accent, per-group overrides from the panel and the owner's imported theme documents.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ThemeChoice {
pub preset: String,
#[serde(default)]
pub accent: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub overrides: Map<String, Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub themes: Vec<Value>,
}
/// A theme id: a lowercase slug of 1-40 characters (`theme.schema.json` `id`).
pub fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 40 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// A six-digit `#rrggbb` color.
pub fn valid_hex(value: &str) -> bool {
value.len() == 7 && value.starts_with('#') && value.as_bytes()[1..].iter().all(u8::is_ascii_hexdigit)
}
fn doc_id(theme: &Value) -> Option<&str> {
theme.get("id").and_then(Value::as_str)
}
impl ThemeChoice {
/// The site's own look, nothing customized.
pub fn default_for(site: &ThemeSite) -> Self {
Self { preset: site.own().into(), accent: None, overrides: Map::new(), themes: Vec::new() }
}
pub fn validate(&self, site: &ThemeSite) -> Result<(), String> {
if let Some(accent) = &self.accent {
if !valid_hex(accent) {
return Err("VALIDATION: accent must use six-digit hex".into());
}
}
if self.themes.len() > MAX_THEMES {
return Err("VALIDATION: too many themes".into());
}
let mut ids: Vec<&str> = Vec::new();
for theme in &self.themes {
let object = theme.as_object().ok_or("VALIDATION: a theme must be an object")?;
let id = object.get("id").and_then(Value::as_str).ok_or("VALIDATION: a theme needs an id")?;
if !valid_id(id) || site.presets.contains(&id) || ids.contains(&id) {
return Err("VALIDATION: theme ids are unique lowercase slugs".into());
}
let name = object.get("name").and_then(Value::as_str).ok_or("VALIDATION: a theme needs a name")?;
if name.trim().is_empty() || name.chars().count() > MAX_NAME_CHARS {
return Err("VALIDATION: theme names are 1-60 characters".into());
}
if serde_json::to_vec(theme).map(|bytes| bytes.len()).unwrap_or(usize::MAX) > MAX_THEME_BYTES {
return Err("VALIDATION: theme is too large".into());
}
ids.push(id);
}
if !site.presets.contains(&self.preset.as_str()) && !ids.contains(&self.preset.as_str()) {
return Err("VALIDATION: unknown theme preset".into());
}
for (key, value) in &self.overrides {
if !OVERRIDE_KEYS.contains(&key.as_str()) || !value.is_object() {
return Err("VALIDATION: unknown theme override".into());
}
}
if serde_json::to_vec(self).map(|bytes| bytes.len()).unwrap_or(usize::MAX) > MAX_TOTAL_BYTES {
return Err("VALIDATION: theme data is too large".into());
}
Ok(())
}
/// True when the site keeps its own look (nothing to apply). Imported themes are kept in the
/// file even while the site's own look is selected, so this only looks at what is applied.
pub fn is_default(&self, site: &ThemeSite) -> bool {
self.preset == site.own() && self.accent.is_none() && self.overrides.is_empty()
}
/// Nothing worth persisting: the site's own look and no imported themes.
pub fn is_empty(&self, site: &ThemeSite) -> bool {
self.is_default(site) && self.themes.is_empty()
}
/// Whether `id` names a theme this state knows (built-in or imported).
pub fn knows(&self, site: &ThemeSite, id: &str) -> bool {
site.presets.contains(&id) || self.themes.iter().any(|theme| doc_id(theme) == Some(id))
}
/// The state a tab gets when it shows theme `id`: this state itself for the global theme, else
/// that theme document without the global customizations. Only the document shown is sent.
pub fn for_theme(&self, site: &ThemeSite, id: &str) -> ThemeChoice {
let base = if id == self.preset || !self.knows(site, id) {
self.clone()
} else {
ThemeChoice { preset: id.into(), accent: None, overrides: Map::new(), themes: self.themes.clone() }
};
let preset = base.preset.clone();
let themes = base.themes.into_iter().filter(|theme| doc_id(theme) == Some(preset.as_str())).collect();
ThemeChoice { themes, ..base }
}
/// Google Fonts families (`font.roles.*` with `"source": "google"`) the shown theme uses in the
/// site's page, from its document and the global overrides. Names are checked again when read from disk.
pub fn google_fonts(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let doc = self.themes.iter().find(|theme| doc_id(theme) == Some(self.preset.as_str()));
let mut collect = |font: Option<&Value>| {
let Some(roles) = font.and_then(|font| font.get("roles")).and_then(Value::as_object) else { return };
for (role, spec) in roles {
if role == "chrome" || spec.get("source").and_then(Value::as_str) != Some("google") {
continue;
}
if let Some(family) = spec.get("family").and_then(|family| family.get(0)).and_then(Value::as_str) {
if crate::gfonts::valid_family(family) && !out.iter().any(|seen| seen == family) && out.len() < 8 {
out.push(family.to_owned());
}
}
}
};
collect(doc.and_then(|doc| doc.get("font")));
collect(self.overrides.get("font"));
out
}
/// The color the shown theme paints the site's page with, as RGB: the tab webview's native
/// background, so a new tab, a navigation or a pane growing during a drag never shows the
/// WebView's white before the page paints. `None` for the site's own look (the site picks it).
pub fn page_color(&self, site: &ThemeSite) -> Option<(u8, u8, u8)> {
let colors_of = |value: Option<&Value>| {
let colors = value?.get("colors")?;
["page", "bg"].iter().find_map(|key| colors.get(*key).and_then(Value::as_str).filter(|hex| valid_hex(hex)).map(str::to_owned))
};
let doc = self.themes.iter().find(|theme| doc_id(theme) == Some(self.preset.as_str()));
let base = match doc {
Some(doc) => colors_of(Some(doc)),
None => site.page_colors.iter().find(|(id, _)| *id == self.preset).map(|(_, hex)| (*hex).to_owned()),
}?;
// Color overrides apply only on top of a theme with colors of its own (like the runtime).
let hex = colors_of(Some(&Value::Object(self.overrides.clone()))).unwrap_or(base);
let channel = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok();
Some((channel(1)?, channel(3)?, channel(5)?))
}
/// Hands the theme to the page runtime (`<global>.theme.apply`), or leaves it for the runtime to
/// pick up at start (`__btTheme`); only in the top frame of the site's own origin.
pub fn script(&self, site: &ThemeSite, spec: &SiteSpec) -> Result<String, String> {
self.validate(site)?;
let json = serde_json::to_string(self).map_err(|_| "THEME: serialization failed")?;
Ok(format!(
"(()=>{{if(window.top!==window||location.origin!=='{origin}')return;const t={json};const BT=window.{global};if(BT&&BT.theme)BT.theme.apply(t);else window.__btTheme=t;}})();",
origin = spec.primary_origin(),
global = spec.runtime_global,
))
}
}
/// At most this many account profiles and panes carry a theme of their own.
pub const MAX_PROFILE_THEMES: usize = 32;
pub const MAX_PANE_THEMES: usize = 64;
/// Which theme each site tab gets: a pane's own theme, else its account profile's, else the global
/// one. `chrome` says whether wayBetter's own window follows the theme (`"theme"`) or keeps its own
/// style (`"own"`); only the shell reads it.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ThemeScopes {
#[serde(default = "chrome_default")]
pub chrome: String,
#[serde(default)]
pub profiles: BTreeMap<String, String>,
#[serde(default)]
pub panes: BTreeMap<String, String>,
}
impl Default for ThemeScopes {
fn default() -> Self {
Self { chrome: chrome_default(), profiles: BTreeMap::new(), panes: BTreeMap::new() }
}
}
fn chrome_default() -> String {
"theme".into()
}
impl ThemeScopes {
pub fn validate(&self) -> Result<(), String> {
if self.chrome != "theme" && self.chrome != "own" {
return Err("VALIDATION: chrome must be theme or own".into());
}
if self.profiles.len() > MAX_PROFILE_THEMES || self.panes.len() > MAX_PANE_THEMES {
return Err("VALIDATION: too many theme assignments".into());
}
for (profile, theme) in &self.profiles {
if !crate::profiles::valid_profile_id(profile) || !valid_id(theme) {
return Err("VALIDATION: invalid account theme".into());
}
}
for (tab, theme) in &self.panes {
if !crate::policy::valid_tab_id(tab) || !valid_id(theme) {
return Err("VALIDATION: invalid pane theme".into());
}
}
Ok(())
}
/// Nothing worth persisting.
pub fn is_default(&self) -> bool {
self.chrome == "theme" && self.profiles.is_empty() && self.panes.is_empty()
}
/// The theme id tab `tab` (in account `profile`) shows; unknown ids fall through to the next level.
pub fn theme_id<'a>(&'a self, site: &ThemeSite, global: &'a ThemeChoice, tab: &str, profile: Option<&str>) -> &'a str {
if let Some(id) = self.panes.get(tab).filter(|id| global.knows(site, id)) {
return id;
}
if let Some(id) = profile.and_then(|profile| self.profiles.get(profile)).filter(|id| global.knows(site, id)) {
return id;
}
&global.preset
}
/// The theme state for one site tab.
pub fn for_tab(&self, site: &ThemeSite, global: &ThemeChoice, tab: &str, profile: Option<&str>) -> ThemeChoice {
global.for_theme(site, self.theme_id(site, global, tab, profile))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
static SITE: ThemeSite = ThemeSite {
presets: &["site", "paper", "nord", "night-ink"],
page_colors: &[("paper", "#e9e2d3"), ("nord", "#232831"), ("night-ink", "#05060a")],
};
fn with(value: Value) -> Result<ThemeChoice, String> {
let theme: ThemeChoice = serde_json::from_value(value).map_err(|error| error.to_string())?;
theme.validate(&SITE).map(|()| theme)
}
#[test]
fn page_color_follows_the_shown_theme() {
let preset = |id: &str| ThemeChoice { preset: id.into(), ..ThemeChoice::default_for(&SITE) };
assert_eq!(preset("site").page_color(&SITE), None, "the site's own look: the site picks its palette");
assert_eq!(preset("nord").page_color(&SITE), Some((0x23, 0x28, 0x31)));
let overridden = ThemeChoice { overrides: json!({ "colors": { "page": "#102030" } }).as_object().unwrap().clone(), ..preset("nord") };
assert_eq!(overridden.page_color(&SITE), Some((0x10, 0x20, 0x30)));
let own_with_override = ThemeChoice { overrides: json!({ "colors": { "page": "#102030" } }).as_object().unwrap().clone(), ..preset("site") };
assert_eq!(own_with_override.page_color(&SITE), None, "the site's own look ignores color overrides");
let imported = ThemeChoice {
preset: "mine".into(),
themes: vec![json!({ "id": "mine", "name": "Mine", "colors": { "bg": "#abcdef", "page": "nope" } })],
..ThemeChoice::default_for(&SITE)
};
assert_eq!(imported.page_color(&SITE), Some((0xab, 0xcd, 0xef)), "an invalid page color falls back to bg");
}
#[test]
fn user_themes_and_overrides_are_bounded() {
let ok = with(json!({ "preset": "mine", "themes": [{ "id": "mine", "name": "Mine", "colors": null }], "overrides": { "font": { "scale": 1.1 } } })).unwrap();
assert!(!ok.is_default(&SITE));
let script = ok.script(&SITE, &crate::site::testing::SITE).unwrap();
assert!(script.contains(r#""preset":"mine""#));
assert!(script.starts_with("(()=>{if(window.top!==window||location.origin!=='https://x.com')return;"), "{script}");
assert!(script.contains("const BT=window.BetterTwttr;"));
assert!(with(json!({ "preset": "other", "themes": [{ "id": "mine", "name": "Mine" }] })).is_err(), "the selected theme must exist");
for id in ["Mine", "a b", "", "site", "night-ink", "</script>"] {
assert!(with(json!({ "preset": "site", "themes": [{ "id": id, "name": "n" }] })).is_err(), "accepted {id}");
}
assert!(with(json!({ "preset": "site", "themes": [{ "id": "a", "name": "A" }, { "id": "a", "name": "B" }] })).is_err());
assert!(with(json!({ "preset": "site", "themes": [{ "id": "a", "name": "" }] })).is_err());
assert!(with(json!({ "preset": "site", "themes": ["css"] })).is_err());
assert!(with(json!({ "preset": "site", "overrides": { "script": {} } })).is_err());
assert!(with(json!({ "preset": "site", "overrides": { "css": { "text": "a { color: red }" } } })).is_ok(), "theme CSS override");
assert!(with(json!({ "preset": "site", "overrides": { "css": "a {}" } })).is_err(), "theme CSS must be an object");
assert!(with(json!({ "preset": "site", "overrides": { "font": "Comic Sans" } })).is_err());
let huge = "a".repeat(MAX_THEME_BYTES);
assert!(with(json!({ "preset": "site", "themes": [{ "id": "a", "name": "A", "css": huge }] })).is_err());
let many: Vec<Value> = (0..=MAX_THEMES).map(|i| json!({ "id": format!("t{i}"), "name": "T" })).collect();
assert!(with(json!({ "preset": "site", "themes": many })).is_err());
}
#[test]
fn tabs_get_pane_then_account_then_global_theme() {
let global = with(json!({ "preset": "mine", "accent": "#ff0000", "overrides": { "font": { "scale": 1.1 } },
"themes": [{ "id": "mine", "name": "Mine" }, { "id": "other", "name": "Other" }] })).unwrap();
let scopes: ThemeScopes = serde_json::from_value(json!({ "profiles": { "p1": "paper" }, "panes": { "t1": "other", "t2": "gone" } })).unwrap();
scopes.validate().unwrap();
assert_eq!(scopes.chrome, "theme");
assert_eq!(scopes.theme_id(&SITE, &global, "t1", Some("p1")), "other");
assert_eq!(scopes.theme_id(&SITE, &global, "t2", Some("p1")), "paper");
assert_eq!(scopes.theme_id(&SITE, &global, "t2", Some("default")), "mine");
assert_eq!(scopes.theme_id(&SITE, &global, "t3", None), "mine");
// The global theme keeps its customizations; another theme is sent bare and alone.
let same = scopes.for_tab(&SITE, &global, "t9", None);
assert_eq!((same.accent.as_deref(), same.overrides.len(), same.themes.len()), (Some("#ff0000"), 1, 1));
let other = scopes.for_tab(&SITE, &global, "t1", None);
assert_eq!((other.preset.as_str(), other.accent.as_deref(), other.overrides.len(), other.themes.len()), ("other", None, 0, 1));
other.validate(&SITE).unwrap();
let builtin = scopes.for_tab(&SITE, &global, "t5", Some("p1"));
assert_eq!((builtin.preset.as_str(), builtin.themes.len()), ("paper", 0));
builtin.validate(&SITE).unwrap();
}
#[test]
fn scopes_are_bounded_and_checked() {
let parse = |value: Value| serde_json::from_value::<ThemeScopes>(value).map_err(|e| e.to_string()).and_then(|s| s.validate().map(|()| s));
assert!(parse(json!({})).unwrap().is_default());
assert!(parse(json!({ "chrome": "own" })).is_ok());
assert!(parse(json!({ "chrome": "rainbow" })).is_err());
assert!(parse(json!({ "panes": { "t1": "Night Ink" } })).is_err(), "theme ids are slugs");
assert!(parse(json!({ "panes": { "../t": "paper" } })).is_err(), "tab ids are checked");
assert!(parse(json!({ "profiles": { "work": "paper" } })).is_err(), "profile ids are checked");
assert!(parse(json!({ "script": {} })).is_err(), "unknown fields");
let many: Map<String, Value> = (0..=MAX_PANE_THEMES).map(|i| (format!("t{i}"), json!("paper"))).collect();
assert!(parse(json!({ "panes": many })).is_err());
}
#[test]
fn google_fonts_come_from_the_shown_theme_only() {
let global = with(json!({ "preset": "mine", "overrides": { "font": { "roles": { "numbers": { "family": ["Space Mono"], "source": "google" } } } },
"themes": [
{ "id": "mine", "name": "Mine", "font": { "roles": {
"interface": { "family": ["Inter", "sans-serif"], "source": "google" },
"post": { "family": ["Georgia"] },
"chrome": { "family": ["Fraunces"], "source": "google" },
"heading": { "family": ["../../evil"], "source": "google" } } } },
{ "id": "other", "name": "Other", "font": { "roles": { "post": { "family": ["Lora"], "source": "google" } } } }
] })).unwrap();
// Chrome fonts are the shell's; invalid names are dropped; overrides count.
assert_eq!(global.google_fonts(), vec!["Inter".to_string(), "Space Mono".to_string()]);
assert_eq!(global.for_theme(&SITE, "other").google_fonts(), vec!["Lora".to_string()]);
assert!(global.for_theme(&SITE, "paper").google_fonts().is_empty());
}
#[test]
fn default_and_empty() {
let own = ThemeChoice::default_for(&SITE);
assert!(own.is_default(&SITE) && own.is_empty(&SITE));
assert_eq!(own.preset, "site");
let kept = with(json!({ "preset": "site", "themes": [{ "id": "a", "name": "A" }] })).unwrap();
assert!(kept.is_default(&SITE) && !kept.is_empty(&SITE));
}
/// The envelope checks are the theme file's JSON Schema (`waybetter/theme.schema.json`).
#[test]
fn envelope_rules_are_the_schema_rules() {
let schema: Value = serde_json::from_str(include_str!("../../../packages/waybetter/theme.schema.json")).unwrap();
let properties = schema["properties"].as_object().unwrap();
assert_eq!(schema["properties"]["id"]["pattern"], "^[a-z0-9-]{1,40}$");
assert_eq!(schema["properties"]["name"]["maxLength"], MAX_NAME_CHARS);
for group in OVERRIDE_KEYS {
assert!(properties.contains_key(group), "{group} is a theme document group");
}
assert_eq!(schema["required"], json!(["name"]));
let long = "a".repeat(41);
for (id, ok) in [("a", true), ("night-ink-2", true), ("A", false), ("a_b", false), (long.as_str(), false)] {
assert_eq!(valid_id(id), ok, "{id}");
}
}
}