Icon packs are the owner's pick: the theme choice carries it, Appearance offers it

- ThemeChoice gains an optional icons field (absent: follow the theme's pack; site: the site's
  own icons; else a pack id), validated natively as a slug (waybetter-host theme.rs, with a
  test) and kept by per-theme choices; variants is an override group natively too.
- Appearance: an Icons row (Follow the theme / the site's own icons / the site's packs) when the
  site lists packs; English words in the catalog.
- The chrome's glyphs follow the owner's pick as well as the theme's suggestion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 04:19:55 +07:00
parent e88339bda6
commit 8a0ab6d3a1
8 changed files with 50 additions and 12 deletions

View file

@ -42,7 +42,7 @@ 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"];
pub const OVERRIDE_KEYS: [&str; 8] = ["font", "shape", "layout", "background", "effects", "colors", "css", "variants"];
/// 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.
@ -56,6 +56,10 @@ pub struct ThemeChoice {
pub overrides: Map<String, Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub themes: Vec<Value>,
/// The owner's icon pack: absent follows the theme's suggestion, `site` keeps the site's own icons,
/// otherwise a pack id (theme engine v2). A slug; an unknown pack falls back to the site's icons in the page.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icons: Option<String>,
}
/// A theme id: a lowercase slug of 1-40 characters (`theme.schema.json` `id`).
@ -75,7 +79,7 @@ fn doc_id(theme: &Value) -> Option<&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() }
Self { preset: site.own().into(), accent: None, overrides: Map::new(), themes: Vec::new(), icons: None }
}
pub fn validate(&self, site: &ThemeSite) -> Result<(), String> {
@ -84,6 +88,11 @@ impl ThemeChoice {
return Err("VALIDATION: accent must use six-digit hex".into());
}
}
if let Some(icons) = &self.icons {
if !valid_id(icons) {
return Err("VALIDATION: an icon pack id is a lowercase slug".into());
}
}
if self.themes.len() > MAX_THEMES {
return Err("VALIDATION: too many themes".into());
}
@ -120,7 +129,7 @@ impl ThemeChoice {
/// 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()
self.preset == site.own() && self.accent.is_none() && self.overrides.is_empty() && self.icons.is_none()
}
/// Nothing worth persisting: the site's own look and no imported themes.
@ -139,7 +148,7 @@ impl 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() }
ThemeChoice { preset: id.into(), accent: None, overrides: Map::new(), themes: self.themes.clone(), icons: self.icons.clone() }
};
let preset = base.preset.clone();
let themes = base.themes.into_iter().filter(|theme| doc_id(theme) == Some(preset.as_str())).collect();
@ -304,6 +313,16 @@ mod tests {
assert_eq!(imported.page_color(&SITE), Some((0xab, 0xcd, 0xef)), "an invalid page color falls back to bg");
}
#[test]
fn icon_choice_is_a_slug_and_counts_as_a_choice() {
let picked = with(json!({ "preset": "site", "icons": "wb-pixel" })).unwrap();
assert_eq!(picked.icons.as_deref(), Some("wb-pixel"));
assert!(!picked.is_default(&SITE), "an icon pick is applied even on the site's own look");
assert!(with(json!({ "preset": "site", "icons": "Bad Pack!" })).is_err());
assert!(with(json!({ "preset": "site", "overrides": { "variants": { "nav": "top" } } })).is_ok(), "layout variants are an override group");
assert_eq!(picked.for_theme(&SITE, "nord").icons.as_deref(), Some("wb-pixel"), "the icon pick is global");
}
#[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();