collective

Browse / github.com/storytold/craft-libs / patch

Add craft-theme: suite theme IDs, core roles, resolution and contrast checks

Add craft-theme: suite theme IDs, core roles, resolution and contrast checks First step of the suite-theme pilot that skylartaylor outlined on storytold/pdfcraft#1090 (comments of 10-12 and 10-15): choose a theme once, every Craft app follows, with an explicit per-app override. Pilot apps: PdfCraft, PhotoCraft and the ArtCraft Launcher. This PR adds the shared contract only. Storage, the shared selection and change detection follow in `craft-theme-store`; the app adapters follow in each app. ## What it adds - **`craft-theme`** (new crate, no egui, no I/O, builds for wasm32): - `ThemeId`: stable string IDs; `craft.` is reserved for built-ins. - `SuiteTheme`: a li

Status
open
Score
0.00
Adopters
0 other operators
Author
aiongg (imported from pull request #397, open)
Base
6a66b95cbc2c4d4b732630113eb9a37263658c58
Size
+1920 −4 in 23 files
Review
pending
Shared
2026-10-28
Id
514ee5b833151b1372cc61fe

How it went

No other operator has reported using it yet.

Diff

raw · .github/workflows/theme.yml Cargo.lock Cargo.toml crates/theme/Cargo.toml crates/theme/README.md crates/theme/src/builtins/artcraft.rs crates/theme/src/builtins/graphite.rs crates/theme/src/builtins/high_contrast.rs crates/theme/src/builtins/mod.rs crates/theme/src/color.rs crates/theme/src/contrast.rs crates/theme/src/derive.rs crates/theme/src/id.rs crates/theme/src/lib.rs crates/theme/src/resolve.rs crates/theme/src/roles.rs crates/theme/src/theme.rs crates/theme/tests/contract.rs crates/ui/Cargo.toml crates/ui/README.md crates/ui/src/theme/mod.rs docs/suite-themes.md docs/theme-infrastructure.md

diff --git a/.github/workflows/theme.yml b/.github/workflows/theme.ymlnew file mode 100644--- /dev/null+++ b/.github/workflows/theme.yml@@ -0,0 +1,34 @@+name: Suite themes++on:+  workflow_dispatch:+  push:+    branches: [main]+    paths: ['crates/theme/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/theme.yml']+  pull_request:+    paths: ['crates/theme/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/theme.yml']++permissions:+  contents: read++jobs:+  theme:+    timeout-minutes: 15+    name: Theme lint, tests, WASM+    runs-on: ubuntu-latest+    steps:+      - uses: actions/checkout@v7+        with:+          # The token isn't needed after checkout; cargo runs build scripts and proc macros from+          # dependencies, so don't leave it in .git/config for that third-party code to read.+          persist-credentials: false+      - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable+        with:+          components: rustfmt, clippy+          targets: wasm32-unknown-unknown+      - run: cargo fmt -p craft-theme --check+      - run: cargo clippy -p craft-theme --all-targets --all-features --locked -- -D warnings+      - run: cargo clippy -p craft-theme --all-targets --locked -- -D warnings+      - run: cargo test -p craft-theme --all-features --locked+      - run: cargo check -p craft-theme --all-features --locked --target wasm32-unknown-unknown+      - run: cargo check -p craft-ui --lib --lockeddiff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1014,12 +1014,22 @@ dependencies = [  "unicode-segmentation", ] +[[package]]+name = "craft-theme"+version = "0.1.0"+dependencies = [+ "proptest",+ "serde",+ "serde_json",+]+ [[package]] name = "craft-ui" version = "0.1.0" dependencies = [  "craft-fonts-build",  "craft-i18n",+ "craft-theme",  "eframe",  "egui",  "egui_extras",diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -40,6 +40,7 @@ craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } craft-text = { path = "crates/text" }+craft-theme = { path = "crates/theme" } craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-window = { path = "crates/window" }diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.tomlnew file mode 100644--- /dev/null+++ b/crates/theme/Cargo.toml@@ -0,0 +1,23 @@+[package]+name = "craft-theme"+description = "The Craft apps' suite-theme contract: theme identity, core colour roles, derived states, contrast checks, built-in themes and resolution. No egui, no I/O."+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true+repository.workspace = true++[features]+default = []+# Serialize and Deserialize for the data types: colours as "#rrggbb[aa]", ids as strings.+serde = ["dep:serde"]++[dependencies]+serde = { workspace = true, optional = true }++[dev-dependencies]+proptest = { workspace = true }+serde_json = { workspace = true }++[lints]+workspace = truediff --git a/crates/theme/README.md b/crates/theme/README.mdnew file mode 100644--- /dev/null+++ b/crates/theme/README.md@@ -0,0 +1,44 @@+# craft-theme++The Craft suite's theme contract: theme identity, the 13 core colour roles of a light and a dark+variant, derived interaction states, contrast checks on the colour pairs apps paint, the built-in+themes and resolution of a selection against the OS's light/dark answer.++Pure data and functions: no egui, no I/O, no threads, and it builds for wasm. Theme files, the+suite folder and change watching belong to `craft-theme-store`; `craft_ui::theme` converts colours+for egui and installs styles. [docs/suite-themes.md](../../docs/suite-themes.md) describes the+contract and how an app adopts it.++```rust+use craft_theme::{AppChoice, AppDefaults, Mode, Resolution, ThemeId, Variant, lookup_builtin, resolve};++// What this app shows while it follows the suite and nobody has picked a suite theme.+let defaults = AppDefaults { theme: ThemeId::suite_default(), mode: Mode::Dark };+let choice = AppChoice::FollowSuite; // from the app's own preferences+let suite = None; // from craft-theme-store: no selection.json yet+let sel = choice.effective(suite, &defaults);+match resolve(&sel, Some(Variant::Dark), Variant::Light, &lookup_builtin) {+    Resolution::Theme(r) => { /* map r.roles and r.derived onto the app's tokens */ }+    Resolution::AppDefault { variant, .. } => { /* the app's own palette in `variant` */ }+}+```++| Item | What it is |+|---|---|+| `Rgba` | sRGB with unmultiplied alpha; `parse_hex`, `to_hex`, `lerp_gamma`, `over` |+| `ThemeId`, `APP_DEFAULT`, `SUITE_DEFAULT` | ids `[a-z0-9][a-z0-9._-]{1,63}`; `craft.` is reserved for built-ins, and user ids can't start with a Windows device name |+| `Role`, `CoreRoles`, `Variant`, `Mode` | the 13 roles of one variant; light/dark; System/Light/Dark |+| `SuiteTheme::from_maps` | a user theme from role maps; missing roles come from Graphite and are listed |+| `builtins()`, `builtin`, `lookup_builtin` | ArtCraft, Graphite, High contrast |+| `Selection`, `AppChoice`, `AppDefaults` | a theme and mode; an app's "follow suite" or own choice; what a following app shows before a suite theme is picked (no `Default` for any of them) |+| `resolve`, `Resolution`, `Resolved`, `Fallback` | pure resolution; unknown ids give the app default, never another theme |+| `derive`, `Derived` | hover, pressed and the other derived states, guarded to keep their contrast pairs |+| `contrast` | `ratio`, `painted_pairs`, advisory `diagnose`, `check` for app-painted pairs |++Feature `serde` adds `Serialize`/`Deserialize` to the data types: colours as `"#rrggbb[aa]"`, ids+as strings, enums in snake_case.++ArtCraft's colours come from PhotoCraft's Pro Light and Pro Medium themes, Graphite's from+PdfCraft's Light gray and Dark gray, and High contrast follows DesignCraft's high-contrast+brightness. Checking contrast on the pairs apps actually paint follows+[pdfcraft#1090](https://github.com/storytold/pdfcraft/pull/1090).diff --git a/crates/theme/src/builtins/artcraft.rs b/crates/theme/src/builtins/artcraft.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/builtins/artcraft.rs@@ -0,0 +1,40 @@+//! ArtCraft, the suite's default theme: PhotoCraft's Pro Light and Pro Medium palettes+//! (`crates/ui-egui/src/theme.rs` at dacecbfea1).++use super::hex;+use crate::CoreRoles;++pub const ID: &str = crate::SUITE_DEFAULT;+pub const NAME: &str = "ArtCraft";++pub const LIGHT: CoreRoles = CoreRoles {+    background: hex(0xE2E2E2),+    surface: hex(0xF0F0F0),+    raised: hex(0xFFFFFF),+    text: hex(0x141414),+    text_muted: hex(0x404040),+    line: hex(0xD6D6D6),+    accent: hex(0x1062C4),+    on_accent: hex(0xFFFFFF),+    selection: hex(0xCCCCCC),+    focus: hex(0x1062C4),+    good: hex(0x4E712E),+    warn: hex(0x99780C),+    bad: hex(0xC42B1C),+};++pub const DARK: CoreRoles = CoreRoles {+    background: hex(0x424242),+    surface: hex(0x535353),+    raised: hex(0x454545),+    text: hex(0xEEEEEE),+    text_muted: hex(0xD4D4D4),+    line: hex(0x3E3E3E),+    accent: hex(0x78ADF6),+    on_accent: hex(0x141414),+    selection: hex(0x6B6B6B),+    focus: hex(0x78ADF6),+    good: hex(0x85DC6A),+    warn: hex(0xE8B046),+    bad: hex(0xFF8080),+};diff --git a/crates/theme/src/builtins/graphite.rs b/crates/theme/src/builtins/graphite.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/builtins/graphite.rs@@ -0,0 +1,48 @@+//! Graphite: PdfCraft's Light gray and Dark gray palettes, with the launcher's status colours.++use super::hex;+use crate::{CoreRoles, Variant};++pub const ID: &str = "craft.graphite";+pub const NAME: &str = "Graphite";++pub const LIGHT: CoreRoles = CoreRoles {+    background: hex(0xF1F1F3),+    surface: hex(0xFFFFFF),+    raised: hex(0xFFFFFF),+    text: hex(0x222226),+    text_muted: hex(0x5E5E66),+    line: hex(0xDADADE),+    accent: hex(0x1B63E0),+    on_accent: hex(0xFFFFFF),+    selection: hex(0xE6EEFD),+    focus: hex(0x1B63E0),+    good: hex(0x0A7563),+    warn: hex(0x8A5800),+    bad: hex(0xB3261E),+};++pub const DARK: CoreRoles = CoreRoles {+    background: hex(0x19191C),+    surface: hex(0x26262A),+    raised: hex(0x2E2E33),+    text: hex(0xECECEF),+    text_muted: hex(0xAEAEB6),+    line: hex(0x3C3C43),+    accent: hex(0x4B8BF5),+    // White on this accent is 3.33:1.+    on_accent: hex(0x19191C),+    selection: hex(0x233A63),+    focus: hex(0x4B8BF5),+    good: hex(0x3FD6B8),+    warn: hex(0xFFC14D),+    bad: hex(0xFF6259),+};++/// Fills the roles a user theme leaves out.+pub const fn roles(v: Variant) -> CoreRoles {+    match v {+        Variant::Light => LIGHT,+        Variant::Dark => DARK,+    }+}diff --git a/crates/theme/src/builtins/high_contrast.rs b/crates/theme/src/builtins/high_contrast.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/builtins/high_contrast.rs@@ -0,0 +1,41 @@+//! High contrast: black on white with a dark blue accent, and white on black with a yellow+//! accent after DesignCraft's high-contrast brightness. Text pairs reach 7:1 (WCAG AAA); the+//! lowest muted pair is dark text_muted on selection at 5.64:1.++use super::hex;+use crate::CoreRoles;++pub const ID: &str = "craft.high-contrast";+pub const NAME: &str = "High contrast";++pub const LIGHT: CoreRoles = CoreRoles {+    background: hex(0xF0F0F0),+    surface: hex(0xFFFFFF),+    raised: hex(0xFFFFFF),+    text: hex(0x000000),+    text_muted: hex(0x3A3A3A),+    line: hex(0x000000),+    accent: hex(0x0040B0),+    on_accent: hex(0xFFFFFF),+    selection: hex(0xC7DBFF),+    focus: hex(0x0040B0),+    good: hex(0x005C33),+    warn: hex(0x6B4400),+    bad: hex(0xA50000),+};++pub const DARK: CoreRoles = CoreRoles {+    background: hex(0x1A1A1A),+    surface: hex(0x000000),+    raised: hex(0x000000),+    text: hex(0xFFFFFF),+    text_muted: hex(0xE6E6E6),+    line: hex(0xFFFFFF),+    accent: hex(0xFFEB3B),+    on_accent: hex(0x000000),+    selection: hex(0x5C5C00),+    focus: hex(0xFFEB3B),+    good: hex(0x6EF2A4),+    warn: hex(0xFFB84D),+    bad: hex(0xFF8F8F),+};diff --git a/crates/theme/src/builtins/mod.rs b/crates/theme/src/builtins/mod.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/builtins/mod.rs@@ -0,0 +1,41 @@+//! The built-in themes. `craft.app-default` is not one of them: it stands for each app's own+//! palette, and the store and settings UI list it.++use std::sync::LazyLock;++use crate::{CoreRoles, Origin, Rgba, SuiteTheme, ThemeId};++pub(crate) mod artcraft;+pub(crate) mod graphite;+pub(crate) mod high_contrast;++const fn hex(rgb: u32) -> Rgba {+    Rgba::from_rgb_u32(rgb)+}++fn theme(id: &'static str, name: &str, light: CoreRoles, dark: CoreRoles) -> SuiteTheme {+    SuiteTheme { id: ThemeId::from_static(id), name: name.to_owned(), version: 1, light: Some(light), dark: Some(dark), origin: Origin::BuiltIn }+}++static BUILTINS: LazyLock<[SuiteTheme; 3]> = LazyLock::new(|| {+    [+        theme(artcraft::ID, artcraft::NAME, artcraft::LIGHT, artcraft::DARK),+        theme(graphite::ID, graphite::NAME, graphite::LIGHT, graphite::DARK),+        theme(high_contrast::ID, high_contrast::NAME, high_contrast::LIGHT, high_contrast::DARK),+    ]+});++/// ArtCraft, Graphite and High contrast, in the order a theme picker lists them.+pub fn builtins() -> &'static [SuiteTheme] {+    &*BUILTINS+}++pub fn builtin(id: &ThemeId) -> Option<&'static SuiteTheme> {+    builtins().iter().find(|t| t.id == *id)+}++/// A `lookup` for [`crate::resolve`] that knows only the built-ins (apps without a theme store,+/// such as web builds, and tests).+pub fn lookup_builtin(id: &ThemeId) -> Option<SuiteTheme> {+    builtin(id).cloned()+}diff --git a/crates/theme/src/color.rs b/crates/theme/src/color.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/color.rs@@ -0,0 +1,158 @@+use std::fmt;++/// An sRGB colour with unmultiplied alpha.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub struct Rgba(pub [u8; 4]);++impl Rgba {+    pub const BLACK: Rgba = Rgba::rgb(0, 0, 0);+    pub const WHITE: Rgba = Rgba::rgb(0xFF, 0xFF, 0xFF);++    pub const fn rgb(r: u8, g: u8, b: u8) -> Rgba {+        Rgba([r, g, b, 0xFF])+    }++    /// An opaque colour from `0xRRGGBB`; higher bits are ignored.+    pub const fn from_rgb_u32(rgb: u32) -> Rgba {+        let [_, r, g, b] = rgb.to_be_bytes();+        Rgba::rgb(r, g, b)+    }++    pub const fn r(self) -> u8 {+        self.0[0]+    }++    pub const fn g(self) -> u8 {+        self.0[1]+    }++    pub const fn b(self) -> u8 {+        self.0[2]+    }++    pub const fn a(self) -> u8 {+        self.0[3]+    }++    pub const fn is_opaque(self) -> bool {+        self.0[3] == 0xFF+    }++    /// `#rrggbb` or `#rrggbbaa`, either case; the `#` is optional.+    pub fn parse_hex(s: &str) -> Option<Rgba> {+        let digits = s.strip_prefix('#').unwrap_or(s);+        if !matches!(digits.len(), 6 | 8) || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {+            return None;+        }+        let mut out = [0xFF; 4];+        for (slot, i) in out.iter_mut().zip((0..digits.len()).step_by(2)) {+            *slot = u8::from_str_radix(digits.get(i..i + 2)?, 16).ok()?;+        }+        Some(Rgba(out))+    }++    /// `#rrggbb` for an opaque colour, `#rrggbbaa` otherwise.+    pub fn to_hex(self) -> String {+        let [r, g, b, a] = self.0;+        if a == 0xFF { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") }+    }++    /// Per-channel interpolation of the encoded values (alpha included), `t` clamped to 0..=1.+    pub fn lerp_gamma(self, to: Rgba, t: f32) -> Rgba {+        let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };+        let mut out = self.0;+        for (o, (&a, &b)) in out.iter_mut().zip(self.0.iter().zip(to.0.iter())) {+            *o = channel(f32::from(a) + (f32::from(b) - f32::from(a)) * t);+        }+        Rgba(out)+    }++    /// Like [`Rgba::lerp_gamma`] with `-t`: the same distance away from `from`, clamped per channel.+    pub(crate) fn step_away(self, from: Rgba, t: f32) -> Rgba {+        let mut out = self.0;+        for (o, (&a, &b)) in out.iter_mut().zip(self.0.iter().zip(from.0.iter())) {+            *o = channel(f32::from(a) - (f32::from(b) - f32::from(a)) * t);+        }+        out[3] = self.0[3];+        Rgba(out)+    }++    /// This colour painted over `bg` (source-over, mixed in encoded sRGB as egui does).+    pub fn over(self, bg: Rgba) -> Rgba {+        let fa = f32::from(self.a()) / 255.0;+        let ba = f32::from(bg.a()) / 255.0;+        let out_a = fa + ba * (1.0 - fa);+        if out_a <= 0.0 {+            return Rgba([0, 0, 0, 0]);+        }+        let mut out = [0; 4];+        for (o, (&f, &b)) in out.iter_mut().zip(self.0.iter().zip(bg.0.iter())).take(3) {+            *o = channel((f32::from(f) * fa + f32::from(b) * ba * (1.0 - fa)) / out_a);+        }+        out[3] = channel(out_a * 255.0);+        Rgba(out)+    }++    /// WCAG 2 relative luminance of the colour's RGB, ignoring alpha.+    pub(crate) fn luminance(self) -> f32 {+        fn linear(c: u8) -> f32 {+            let c = f32::from(c) / 255.0;+            if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }+        }+        0.2126 * linear(self.r()) + 0.7152 * linear(self.g()) + 0.0722 * linear(self.b())+    }+}++fn channel(v: f32) -> u8 {+    // `as` saturates and maps NaN to 0.+    (v + 0.5).floor().clamp(0.0, 255.0) as u8+}++impl fmt::Display for Rgba {+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+        f.write_str(&self.to_hex())+    }+}++#[cfg(feature = "serde")]+impl serde::Serialize for Rgba {+    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {+        s.serialize_str(&self.to_hex())+    }+}++#[cfg(feature = "serde")]+impl<'de> serde::Deserialize<'de> for Rgba {+    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {+        let s = <std::borrow::Cow<'de, str>>::deserialize(d)?;+        Rgba::parse_hex(&s).ok_or_else(|| serde::de::Error::custom(format!("not a #rrggbb or #rrggbbaa colour: {s:?}")))+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn hex_round_trip_and_rejects() {+        assert_eq!(Rgba::parse_hex("#121316"), Some(Rgba::from_rgb_u32(0x121316)));+        assert_eq!(Rgba::parse_hex("2D81FF80"), Some(Rgba([0x2D, 0x81, 0xFF, 0x80])));+        assert_eq!(Rgba([0x2D, 0x81, 0xFF, 0x80]).to_hex(), "#2d81ff80");+        assert_eq!(Rgba::from_rgb_u32(0xABCDEF).to_hex(), "#abcdef");+        for bad in ["", "#", "#12345", "#1234567", "#12345g", "#+12345", "#ééé", "#123456789"] {+            assert_eq!(Rgba::parse_hex(bad), None, "{bad}");+        }+    }++    #[test]+    fn mixing() {+        let a = Rgba::rgb(0, 100, 200);+        let b = Rgba::rgb(100, 200, 0);+        assert_eq!(a.lerp_gamma(b, 0.5), Rgba::rgb(50, 150, 100));+        assert_eq!(a.lerp_gamma(b, 2.0), b);+        assert_eq!(a.lerp_gamma(b, f32::NAN), a);+        assert_eq!(Rgba::rgb(100, 100, 100).step_away(Rgba::rgb(200, 0, 100), 0.5), Rgba::rgb(50, 150, 100));+        assert_eq!(Rgba([255, 255, 255, 128]).over(Rgba::BLACK), Rgba::rgb(128, 128, 128));+        assert_eq!(Rgba([0, 0, 0, 0]).over(Rgba([0, 0, 0, 0])), Rgba([0, 0, 0, 0]));+    }+}diff --git a/crates/theme/src/contrast.rs b/crates/theme/src/contrast.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/contrast.rs@@ -0,0 +1,190 @@+//! WCAG 2 contrast on the colour pairs apps actually paint.+//!+//! Diagnostics are advisory: nothing here changes a colour or refuses a theme. Built-in themes+//! have none (a test enforces it); user themes show theirs and stay usable.++use std::sync::LazyLock;++use Paint::{Derived as D, Role as R};+use Role::{Accent, Background, Bad, Focus, Good, OnAccent, Raised, Selection, Surface, Text, TextMuted, Warn};++use crate::{CoreRoles, Derived, Rgba, Role, SuiteTheme, Variant, derive};++/// The contrast ratio of `fg` painted on `bg`, 1.0 to 21.0.+///+/// A translucent `fg` is composited over `bg` first; `bg` counts as opaque.+pub fn ratio(fg: Rgba, bg: Rgba) -> f32 {+    let bg = Rgba([bg.r(), bg.g(), bg.b(), 0xFF]);+    let a = fg.over(bg).luminance();+    let b = bg.luminance();+    (a.max(b) + 0.05) / (a.min(b) + 0.05)+}++/// The minimum ratio a pair needs.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub enum Need {+    /// 4.5:1, text (WCAG 2.2 AA).+    Text,+    /// 3.5:1, secondary text on the hover, pressed and selected state backgrounds, which it+    /// sits on only while the pointer or the selection is there.+    MutedText,+    /// 3:1, focus rings, control edges, status marks and other graphics.+    NonText,+}++impl Need {+    pub fn min_ratio(self) -> f32 {+        match self {+            Need::Text => 4.5,+            Need::MutedText => 3.5,+            Need::NonText => 3.0,+        }+    }++    pub fn is_met(self, ratio: f32) -> bool {+        ratio >= self.min_ratio()+    }+}++/// A colour in a resolved theme: a core role or a [`Derived`] field by name.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub enum Paint {+    Role(Role),+    Derived(&'static str),+}++impl Paint {+    pub fn color(self, roles: &CoreRoles, derived: &Derived) -> Option<Rgba> {+        match self {+            Paint::Role(r) => Some(roles.get(r)),+            Paint::Derived(name) => derived.get(name),+        }+    }+}++#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub struct Pair {+    pub fg: Paint,+    pub bg: Paint,+    pub need: Need,+}++/// A painted pair below its requirement.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct Diagnostic {+    pub variant: Variant,+    pub fg: Paint,+    pub bg: Paint,+    pub ratio: f32,+    pub need: Need,+}++type Row = (&'static [Paint], &'static [Paint], Need);++/// The table in docs/suite-themes.md: every foreground of a row on every background of it.+const ROWS: &[Row] = &[+    (&[R(Text)], &[R(Background), R(Surface), R(Raised), D("hover"), D("pressed"), R(Selection), D("selected_hover")], Need::Text),+    (&[R(TextMuted)], &[R(Background), R(Surface), R(Raised)], Need::Text),+    (&[R(TextMuted)], &[D("hover"), D("pressed"), R(Selection), D("selected_hover")], Need::MutedText),+    (&[D("text_faint")], &[R(Background), R(Surface), R(Raised)], Need::Text),+    (&[R(OnAccent)], &[R(Accent), D("accent_hover"), D("accent_pressed")], Need::Text),+    (&[D("accent_text")], &[R(Background), R(Surface), R(Raised)], Need::Text),+    (&[R(Focus), D("control_edge"), R(Accent)], &[R(Surface), R(Raised), R(Background)], Need::NonText),+    (&[R(Good), R(Warn), R(Bad)], &[R(Background), R(Surface)], Need::NonText),+];++static PAINTED: LazyLock<Vec<Pair>> = LazyLock::new(|| {+    ROWS.iter().flat_map(|&(fgs, bgs, need)| fgs.iter().flat_map(move |&fg| bgs.iter().map(move |&bg| Pair { fg, bg, need }))).collect()+});++/// Every pair the suite's apps paint from the core roles and derived states, with its need.+///+/// `line` is a decorative divider and has no requirement; edges that must be seen use+/// `control_edge`.+pub fn painted_pairs() -> &'static [Pair] {+    &PAINTED+}++/// Every painted pair of every variant of `t` that misses its need, with derived states from+/// [`derive`].+pub fn diagnose(t: &SuiteTheme) -> Vec<Diagnostic> {+    t.variants().flat_map(|(v, roles)| diagnose_variant(v, roles, &derive(roles, v))).collect()+}++/// [`diagnose`] for one variant, with derived states an app may have overwritten.+pub fn diagnose_variant(variant: Variant, roles: &CoreRoles, derived: &Derived) -> Vec<Diagnostic> {+    PAINTED+        .iter()+        .filter_map(|p| {+            let ratio = ratio(p.fg.color(roles, derived)?, p.bg.color(roles, derived)?);+            (!p.need.is_met(ratio)).then_some(Diagnostic { variant, fg: p.fg, bg: p.bg, ratio, need: p.need })+        })+        .collect()+}++/// Checks pairs an app paints outside the table: `(label, fg, bg, need)`. Returns the failing+/// labels with their ratios.+pub fn check(pairs: &[(&'static str, Rgba, Rgba, Need)]) -> Vec<(&'static str, f32)> {+    pairs+        .iter()+        .filter_map(|&(label, fg, bg, need)| {+            let r = ratio(fg, bg);+            (!need.is_met(r)).then_some((label, r))+        })+        .collect()+}++/// The lowest `ratio / need` over the painted pairs that have `name` on one side and a core role+/// on the other; at least 1.0 means all of them pass. Used to guard derived states.+pub(crate) fn margin(name: &str, candidate: Rgba, roles: &CoreRoles) -> f32 {+    PAINTED+        .iter()+        .filter_map(|p| {+            let (fg, bg) = match (p.fg, p.bg) {+                (Paint::Derived(n), Paint::Role(r)) if n == name => (candidate, roles.get(r)),+                (Paint::Role(r), Paint::Derived(n)) if n == name => (roles.get(r), candidate),+                _ => return None,+            };+            Some(ratio(fg, bg) / p.need.min_ratio())+        })+        .fold(f32::INFINITY, f32::min)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn known_ratios() {+        assert!((ratio(Rgba::BLACK, Rgba::WHITE) - 21.0).abs() < 1e-3);+        assert!((ratio(Rgba::WHITE, Rgba::BLACK) - 21.0).abs() < 1e-3);+        assert!((ratio(Rgba::from_rgb_u32(0x777777), Rgba::WHITE) - 4.48).abs() < 0.01);+        // Half-transparent white on black lands on mid grey.+        let mid = ratio(Rgba([255, 255, 255, 128]), Rgba::BLACK);+        assert!((mid - ratio(Rgba::from_rgb_u32(0x808080), Rgba::BLACK)).abs() < 1e-3);+        // White on the plan's example accent.+        assert!((ratio(Rgba::WHITE, Rgba::from_rgb_u32(0x4B8BF5)) - 3.33).abs() < 0.01);+    }++    #[test]+    fn check_reports_only_failures() {+        let failing = check(&[+            ("ok", Rgba::BLACK, Rgba::WHITE, Need::Text),+            ("faint", Rgba::from_rgb_u32(0xAAAAAA), Rgba::WHITE, Need::Text),+            ("edge", Rgba::from_rgb_u32(0x949494), Rgba::WHITE, Need::NonText),+        ]);+        assert_eq!(failing.len(), 1);+        assert_eq!(failing[0].0, "faint");+    }++    #[test]+    fn table_names_only_real_derived_fields() {+        for p in painted_pairs() {+            for side in [p.fg, p.bg] {+                if let Paint::Derived(name) = side {+                    assert!(Derived::KEYS.contains(&name), "{name}");+                }+            }+        }+    }+}diff --git a/crates/theme/src/derive.rs b/crates/theme/src/derive.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/derive.rs@@ -0,0 +1,126 @@+use crate::{CoreRoles, Rgba, Variant, contrast};++/// Interaction and secondary colours computed from the core roles. Apps may overwrite any field.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub struct Derived {+    /// A control under the pointer.+    pub hover: Rgba,+    /// A control held down.+    pub pressed: Rgba,+    pub selected_hover: Rgba,+    pub accent_hover: Rgba,+    pub accent_pressed: Rgba,+    /// Links and accent-coloured labels: readable as text.+    pub accent_text: Rgba,+    /// A tinted fill such as a highlighted card or badge background.+    pub accent_soft: Rgba,+    /// Tertiary labels, still at text contrast.+    pub text_faint: Rgba,+    /// Edges of fields and buttons: visible at 3:1.+    pub control_edge: Rgba,+}++impl Derived {+    /// The field names, as [`contrast::Paint::Derived`] uses them.+    pub const KEYS: [&'static str; 9] =+        ["hover", "pressed", "selected_hover", "accent_hover", "accent_pressed", "accent_text", "accent_soft", "text_faint", "control_edge"];++    pub fn get(&self, name: &str) -> Option<Rgba> {+        Some(match name {+            "hover" => self.hover,+            "pressed" => self.pressed,+            "selected_hover" => self.selected_hover,+            "accent_hover" => self.accent_hover,+            "accent_pressed" => self.accent_pressed,+            "accent_text" => self.accent_text,+            "accent_soft" => self.accent_soft,+            "text_faint" => self.text_faint,+            "control_edge" => self.control_edge,+            _ => return None,+        })+    }+}++const STEP: f32 = 0.02;++/// Derived states for one variant, mixed in encoded sRGB (as PdfCraft's pressed states are).+///+/// Each state keeps every painted pair it takes part in (`contrast::painted_pairs`) at its+/// requirement where the core roles allow it: a state that would lose contrast by moving toward+/// `text` moves away from it instead, and searches stop before a failing step.+pub fn derive(roles: &CoreRoles, v: Variant) -> Derived {+    let (hover, pressed) = match v {+        Variant::Light => (0.06, 0.12),+        Variant::Dark => (0.08, 0.14),+    };+    Derived {+        hover: guarded_mix("hover", roles.surface, roles.text, hover, roles),+        pressed: guarded_mix("pressed", roles.surface, roles.text, pressed, roles),+        selected_hover: guarded_mix("selected_hover", roles.selection, roles.text, 0.08, roles),+        accent_hover: accent_state(roles, 5),+        accent_pressed: accent_state(roles, 11),+        accent_text: first_passing("accent_text", roles.accent, roles.text, roles),+        accent_soft: roles.surface.lerp_gamma(roles.accent, 0.14),+        text_faint: furthest_passing("text_faint", roles.text_muted, roles.surface, roles),+        control_edge: first_passing("control_edge", roles.line, roles.text, roles),+    }+}++/// `base` moved `t` toward `text`, or the same amount away when that keeps the painted pairs+/// and toward doesn't. With neither passing, the one with more contrast.+fn guarded_mix(name: &str, base: Rgba, text: Rgba, t: f32, roles: &CoreRoles) -> Rgba {+    let toward = base.lerp_gamma(text, t);+    let toward_margin = contrast::margin(name, toward, roles);+    if toward_margin >= 1.0 {+        return toward;+    }+    let away = base.step_away(text, t);+    if contrast::margin(name, away, roles) > toward_margin { away } else { toward }+}++/// The accent moved `steps` × 2% away from `on_accent`'s luminance, stopping before a step that+/// would take `on_accent` below 4.5:1 (or lower its contrast, if the accent already misses that).+fn accent_state(roles: &CoreRoles, steps: u8) -> Rgba {+    let target = if roles.on_accent.luminance() > roles.accent.luminance() { Rgba::BLACK } else { Rgba::WHITE };+    let mut current = roles.accent;+    let mut current_ratio = contrast::ratio(roles.on_accent, current);+    for k in 1..=steps {+        let next = roles.accent.lerp_gamma(target, STEP * f32::from(k));+        let r = contrast::ratio(roles.on_accent, next);+        if r < contrast::Need::Text.min_ratio() && r < current_ratio {+            break;+        }+        (current, current_ratio) = (next, r);+    }+    current+}++/// The first 2% step from `from` toward `to` whose painted pairs all pass; with none passing,+/// the step with the most contrast.+fn first_passing(name: &str, from: Rgba, to: Rgba, roles: &CoreRoles) -> Rgba {+    let mut best = (from, f32::NEG_INFINITY);+    for k in 0..=50u8 {+        let c = from.lerp_gamma(to, STEP * f32::from(k));+        let m = contrast::margin(name, c, roles);+        if m >= 1.0 {+            return c;+        }+        if m > best.1 {+            best = (c, m);+        }+    }+    best.0+}++/// `from` moved toward `to` in 2% steps while its painted pairs all still pass.+fn furthest_passing(name: &str, from: Rgba, to: Rgba, roles: &CoreRoles) -> Rgba {+    let mut current = from;+    for k in 1..=50u8 {+        let c = from.lerp_gamma(to, STEP * f32::from(k));+        if contrast::margin(name, c, roles) < 1.0 {+            break;+        }+        current = c;+    }+    current+}diff --git a/crates/theme/src/id.rs b/crates/theme/src/id.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/id.rs@@ -0,0 +1,181 @@+use std::{fmt, sync::Arc};++/// The id of the theme that stands for each app's own palette.+pub const APP_DEFAULT: &str = "craft.app-default";+/// The suite's default theme: the id apps normally put in their [`crate::AppDefaults`].+pub const SUITE_DEFAULT: &str = "craft.artcraft";+/// Ids with this prefix belong to the built-in themes and [`APP_DEFAULT`].+pub const RESERVED_PREFIX: &str = "craft.";++const MAX_LEN: usize = 64;++/// A theme's stable identity: `[a-z0-9][a-z0-9._-]{1,63}`.+///+/// Renaming a theme keeps its id. Selections and per-app choices refer to themes only by id.+#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]+pub struct ThemeId(Arc<str>);++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum IdError {+    /// Shorter than 2 or longer than 64 bytes.+    Length,+    /// The first character is not `a-z` or `0-9`.+    Start,+    /// A character outside `a-z 0-9 . _ -`.+    Char(char),+    /// `craft.` ids are the built-ins'; a user or imported theme can't take one.+    Reserved,+    /// The first dot-separated segment is a Windows device name (`con`, `nul`, `aux`, `prn`,+    /// `com1`-`com9`, `lpt1`-`lpt9`), which can't be a file name there.+    DeviceName,+}++/// Windows refuses these as file names, with any extension.+const DEVICE_NAMES: [&str; 22] = [+    "con", "nul", "aux", "prn", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5",+    "lpt6", "lpt7", "lpt8", "lpt9",+];++impl ThemeId {+    /// Checks the form only; [`IdError::Reserved`] comes from [`ThemeId::parse_user`].+    pub fn parse(s: &str) -> Result<Self, IdError> {+        if !(2..=MAX_LEN).contains(&s.len()) {+            return Err(IdError::Length);+        }+        let mut chars = s.chars();+        if !chars.next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) {+            return Err(IdError::Start);+        }+        if let Some(c) = chars.find(|&c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))) {+            return Err(IdError::Char(c));+        }+        Ok(ThemeId(Arc::from(s)))+    }++    /// [`ThemeId::parse`], refusing the reserved `craft.` prefix and Windows device names: for+    /// themes users create or import, whose ids become file names.+    pub fn parse_user(s: &str) -> Result<Self, IdError> {+        let id = Self::parse(s)?;+        let first = id.0.split('.').next().unwrap_or_default();+        if id.is_builtin() {+            Err(IdError::Reserved)+        } else if DEVICE_NAMES.iter().any(|d| first.eq_ignore_ascii_case(d)) {+            Err(IdError::DeviceName)+        } else {+            Ok(id)+        }+    }++    /// For the crate's own constants, which are valid by construction (a test checks each).+    pub(crate) fn from_static(s: &'static str) -> Self {+        ThemeId(Arc::from(s))+    }++    pub fn app_default() -> Self {+        Self::from_static(APP_DEFAULT)+    }++    pub fn suite_default() -> Self {+        Self::from_static(SUITE_DEFAULT)+    }++    pub fn as_str(&self) -> &str {+        &self.0+    }++    /// True for every id in the reserved `craft.` namespace, including [`APP_DEFAULT`].+    pub fn is_builtin(&self) -> bool {+        self.0.starts_with(RESERVED_PREFIX)+    }++    pub fn is_app_default(&self) -> bool {+        &*self.0 == APP_DEFAULT+    }+}++impl fmt::Debug for ThemeId {+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+        fmt::Debug::fmt(&*self.0, f)+    }+}++impl fmt::Display for ThemeId {+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+        f.write_str(&self.0)+    }+}++impl fmt::Display for IdError {+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+        match self {+            IdError::Length => write!(f, "a theme id has 2 to {MAX_LEN} characters"),+            IdError::Start => f.write_str("a theme id starts with a-z or 0-9"),+            IdError::Char(c) => write!(f, "a theme id can't contain {c:?}; use a-z, 0-9, '.', '_' or '-'"),+            IdError::Reserved => write!(f, "theme ids starting with {RESERVED_PREFIX:?} are reserved for built-in themes"),+            IdError::DeviceName => f.write_str("a theme id can't start with a Windows device name such as \"con\" or \"lpt1\""),+        }+    }+}++impl std::error::Error for IdError {}++#[cfg(feature = "serde")]+impl serde::Serialize for ThemeId {+    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {+        s.serialize_str(&self.0)+    }+}++#[cfg(feature = "serde")]+impl<'de> serde::Deserialize<'de> for ThemeId {+    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {+        let s = <std::borrow::Cow<'de, str>>::deserialize(d)?;+        ThemeId::parse(&s).map_err(serde::de::Error::custom)+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn id_forms() {+        for ok in ["ab", "0x", "my-theme", "a.b_c-d", "studio.night-2", &"a".repeat(64)] {+            assert_eq!(ThemeId::parse(ok).map(|id| id.as_str().to_owned()), Ok(ok.to_owned()), "{ok}");+        }+        assert_eq!(ThemeId::parse(""), Err(IdError::Length));+        assert_eq!(ThemeId::parse("a"), Err(IdError::Length));+        assert_eq!(ThemeId::parse(&"a".repeat(65)), Err(IdError::Length));+        assert_eq!(ThemeId::parse("-ab"), Err(IdError::Start));+        assert_eq!(ThemeId::parse(".ab"), Err(IdError::Start));+        assert_eq!(ThemeId::parse("Ab"), Err(IdError::Start));+        assert_eq!(ThemeId::parse("aB"), Err(IdError::Char('B')));+        assert_eq!(ThemeId::parse("a b"), Err(IdError::Char(' ')));+        assert_eq!(ThemeId::parse("a/b"), Err(IdError::Char('/')));+        assert_eq!(ThemeId::parse("aé"), Err(IdError::Char('é')));+    }++    #[test]+    fn craft_prefix_is_reserved() {+        for constant in [APP_DEFAULT, SUITE_DEFAULT] {+            let id = ThemeId::parse(constant).unwrap();+            assert!(id.is_builtin());+            assert_eq!(ThemeId::parse_user(constant), Err(IdError::Reserved));+        }+        assert!(ThemeId::app_default().is_app_default());+        assert_eq!(ThemeId::suite_default().as_str(), SUITE_DEFAULT);+        assert!(!ThemeId::parse("crafty").unwrap().is_builtin());+        assert!(ThemeId::parse_user("crafty.night").is_ok());+    }++    #[test]+    fn windows_device_names_are_refused_for_user_ids() {+        for bad in ["con", "nul", "aux", "prn", "com1", "com9", "lpt1", "lpt9", "con.dark", "nul.x", "aux.my-theme"] {+            assert_eq!(ThemeId::parse_user(bad), Err(IdError::DeviceName), "{bad}");+            assert!(ThemeId::parse(bad).is_ok(), "{bad}");+        }+        for ok in ["console", "con-dark", "com0", "com10", "lpt", "my.con", "nullish", "auxiliary"] {+            assert!(ThemeId::parse_user(ok).is_ok(), "{ok}");+        }+    }+}diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/lib.rs@@ -0,0 +1,35 @@+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]+//! The Craft suite's theme contract: theme identity, 13 core colour roles per light and dark+//! variant, derived interaction states, contrast checks, the built-in themes and resolution of a+//! selection against what the OS reports.+//!+//! Pure data and functions: no egui, no I/O, no threads. Theme files, the suite folder and change+//! watching belong to `craft-theme-store`; `craft_ui::theme` turns colours into egui ones.+//!+//! ```+//! use craft_theme::{Mode, Resolution, Selection, ThemeId, Variant, lookup_builtin, resolve};+//!+//! let sel = Selection { theme: ThemeId::parse("craft.graphite").unwrap(), mode: Mode::System };+//! // The OS says dark; the app falls back to light when the OS says nothing.+//! match resolve(&sel, Some(Variant::Dark), Variant::Light, &lookup_builtin) {+//!     Resolution::Theme(r) => assert_eq!(r.roles.text.to_hex(), "#ececef"),+//!     Resolution::AppDefault { .. } => unreachable!(),+//! }+//! ```++mod builtins;+mod color;+pub mod contrast;+mod derive;+mod id;+mod resolve;+mod roles;+mod theme;++pub use builtins::{builtin, builtins, lookup_builtin};+pub use color::Rgba;+pub use derive::{Derived, derive};+pub use id::{APP_DEFAULT, IdError, RESERVED_PREFIX, SUITE_DEFAULT, ThemeId};+pub use resolve::{AppChoice, AppDefaults, Fallback, Resolution, Resolved, Selection, resolve};+pub use roles::{CoreRoles, Mode, Role, Variant};+pub use theme::{DefinitionError, MissingRole, Origin, RoleMap, SuiteTheme};diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/resolve.rs@@ -0,0 +1,147 @@+use crate::{CoreRoles, Derived, Mode, SuiteTheme, ThemeId, Variant, derive};++/// A theme and a mode: the suite's selection, or an app's own.+///+/// There is no default: a missing suite selection means the user hasn't picked one, and each app+/// shows its own [`AppDefaults`] until they do.+#[derive(Clone, Debug, PartialEq, Eq, Hash)]+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]+pub struct Selection {+    pub theme: ThemeId,+    pub mode: Mode,+}++/// What an app shows while it follows the suite and the user hasn't picked a suite theme yet.+///+/// Each app states its own, normally [`crate::SUITE_DEFAULT`] with the mode that suits it (the+/// creative apps Dark; office apps may ship Light).+#[derive(Clone, Debug, PartialEq, Eq, Hash)]+pub struct AppDefaults {+    pub theme: ThemeId,+    pub mode: Mode,+}++impl AppDefaults {+    pub fn selection(&self) -> Selection {+        Selection { theme: self.theme.clone(), mode: self.mode }+    }+}++/// An app's choice, kept in the app's own preferences.+///+/// No `Default`: an app states what a fresh install and a legacy preference file each become, so+/// a `#[serde(default)]` can't silently move existing users to the suite.+#[derive(Clone, Debug, PartialEq, Eq, Hash)]+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "snake_case"))]+pub enum AppChoice {+    FollowSuite,+    /// "Use a different theme in this app".+    Own(Selection),+}++impl AppChoice {+    /// The selection to resolve: the app's own, else the suite's, else the app's defaults when+    /// the suite has no selection.+    pub fn effective(&self, suite: Option<&Selection>, defaults: &AppDefaults) -> Selection {+        match (self, suite) {+            (AppChoice::Own(own), _) => own.clone(),+            (AppChoice::FollowSuite, Some(suite)) => suite.clone(),+            (AppChoice::FollowSuite, None) => defaults.selection(),+        }+    }+}++#[derive(Clone, Debug, PartialEq, Eq)]+pub enum Resolution {+    /// Paint the app's own palette in this variant.+    AppDefault {+        variant: Variant,+        why: Option<Fallback>,+    },+    Theme(Resolved),+}++impl Resolution {+    pub fn variant(&self) -> Variant {+        match self {+            Resolution::AppDefault { variant, .. } => *variant,+            Resolution::Theme(r) => r.variant,+        }+    }++    pub fn fallback(&self) -> Option<&Fallback> {+        match self {+            Resolution::AppDefault { why, .. } => why.as_ref(),+            Resolution::Theme(r) => r.note.as_ref(),+        }+    }+}++#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Resolved {+    pub theme: ThemeId,+    pub variant: Variant,+    pub roles: CoreRoles,+    pub derived: Derived,+    pub note: Option<Fallback>,+}++/// Why the result differs from what the selection asked for. When several apply, the first listed+/// here is reported.+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum Fallback {+    /// No theme has this id (deleted, or from another machine): the app's own palette.+    UnknownTheme(ThemeId),+    /// The theme has only this variant and the other was asked for.+    OnlyVariant(Variant),+    /// The mode follows the system and the OS gave no answer: the app's fallback variant.+    SystemSilent,+}++/// Pure. `system` = OS answer; `fallback` = the app's variant when the OS gives none.+///+/// `craft.app-default` and unknown ids resolve to [`Resolution::AppDefault`], never to another+/// named theme. `lookup` is called at most once, with `sel.theme`.+pub fn resolve(sel: &Selection, system: Option<Variant>, fallback: Variant, lookup: &dyn Fn(&ThemeId) -> Option<SuiteTheme>) -> Resolution {+    let (wanted, silent) = match sel.mode {+        Mode::Light => (Variant::Light, false),+        Mode::Dark => (Variant::Dark, false),+        Mode::System => (system.unwrap_or(fallback), system.is_none()),+    };+    let silent = silent.then_some(Fallback::SystemSilent);+    if sel.theme.is_app_default() {+        return Resolution::AppDefault { variant: wanted, why: silent };+    }+    let Some(theme) = lookup(&sel.theme) else {+        return Resolution::AppDefault { variant: wanted, why: Some(Fallback::UnknownTheme(sel.theme.clone())) };+    };+    let (variant, roles, note) = match (theme.variant(wanted), theme.variants().next()) {+        (Some(roles), _) => (wanted, *roles, silent),+        (None, Some((only, roles))) => (only, *roles, Some(Fallback::OnlyVariant(only))),+        // A SuiteTheme without variants can't come from `from_maps` or the built-ins; treat it+        // like a missing theme rather than invent colours.+        (None, None) => {+            return Resolution::AppDefault { variant: wanted, why: Some(Fallback::UnknownTheme(sel.theme.clone())) };+        }+    };+    Resolution::Theme(Resolved { theme: sel.theme.clone(), variant, roles, derived: derive(&roles, variant), note })+}++#[cfg(all(test, feature = "serde"))]+mod tests {+    use super::*;++    #[test]+    fn serde_forms() {+        let own = AppChoice::Own(Selection { theme: ThemeId::app_default(), mode: Mode::Dark });+        let json = serde_json::to_string(&own).unwrap();+        assert_eq!(json, r#"{"own":{"theme":"craft.app-default","mode":"dark"}}"#);+        assert_eq!(serde_json::from_str::<AppChoice>(&json).unwrap(), own);+        assert_eq!(serde_json::to_string(&AppChoice::FollowSuite).unwrap(), r#""follow_suite""#);+        assert!(serde_json::from_str::<Selection>(r#"{"theme":"Bad Id","mode":"dark"}"#).is_err());+        let roles = crate::builtins::graphite::LIGHT;+        let json = serde_json::to_value(roles).unwrap();+        assert_eq!(json["text_muted"], "#5e5e66");+        assert_eq!(serde_json::from_value::<CoreRoles>(json).unwrap(), roles);+    }+}diff --git a/crates/theme/src/roles.rs b/crates/theme/src/roles.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/roles.rs@@ -0,0 +1,178 @@+use crate::Rgba;++/// One of a theme's two colour sets.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "snake_case"))]+pub enum Variant {+    Light,+    Dark,+}++impl Variant {+    pub const ALL: [Variant; 2] = [Variant::Light, Variant::Dark];+}++/// Which variant the user asked for: the OS's answer, or a fixed one.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "snake_case"))]+pub enum Mode {+    #[default]+    System,+    Light,+    Dark,+}++impl Mode {+    pub const ALL: [Mode; 3] = [Mode::System, Mode::Light, Mode::Dark];+}++/// The 13 core colour roles every suite theme defines per variant.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "snake_case"))]+pub enum Role {+    /// Behind panels: the window, pasteboard or canvas surround.+    Background,+    /// Panels, sidebars and controls at rest.+    Surface,+    /// Cards, popups and menus above a surface.+    Raised,+    Text,+    /// Secondary labels.+    TextMuted,+    /// Decorative dividers; control edges use the derived `control_edge`.+    Line,+    Accent,+    /// Text and icons on an accent fill.+    OnAccent,+    /// Selected rows and items, with `text` on top.+    Selection,+    /// Keyboard focus rings.+    Focus,+    Good,+    Warn,+    Bad,+}++impl Role {+    pub const ALL: [Role; 13] = [+        Role::Background,+        Role::Surface,+        Role::Raised,+        Role::Text,+        Role::TextMuted,+        Role::Line,+        Role::Accent,+        Role::OnAccent,+        Role::Selection,+        Role::Focus,+        Role::Good,+        Role::Warn,+        Role::Bad,+    ];++    /// The role's key in theme files, equal to its [`CoreRoles`] field name.+    pub fn key(self) -> &'static str {+        match self {+            Role::Background => "background",+            Role::Surface => "surface",+            Role::Raised => "raised",+            Role::Text => "text",+            Role::TextMuted => "text_muted",+            Role::Line => "line",+            Role::Accent => "accent",+            Role::OnAccent => "on_accent",+            Role::Selection => "selection",+            Role::Focus => "focus",+            Role::Good => "good",+            Role::Warn => "warn",+            Role::Bad => "bad",+        }+    }++    pub fn from_key(k: &str) -> Option<Role> {+        Role::ALL.into_iter().find(|r| r.key() == k)+    }+}++/// One variant's colours for the 13 core roles.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]+pub struct CoreRoles {+    pub background: Rgba,+    pub surface: Rgba,+    pub raised: Rgba,+    pub text: Rgba,+    pub text_muted: Rgba,+    pub line: Rgba,+    pub accent: Rgba,+    pub on_accent: Rgba,+    pub selection: Rgba,+    pub focus: Rgba,+    pub good: Rgba,+    pub warn: Rgba,+    pub bad: Rgba,+}++impl CoreRoles {+    pub fn get(&self, r: Role) -> Rgba {+        *self.slot(r)+    }++    pub fn set(&mut self, r: Role, c: Rgba) {+        *self.slot_mut(r) = c;+    }++    fn slot(&self, r: Role) -> &Rgba {+        match r {+            Role::Background => &self.background,+            Role::Surface => &self.surface,+            Role::Raised => &self.raised,+            Role::Text => &self.text,+            Role::TextMuted => &self.text_muted,+            Role::Line => &self.line,+            Role::Accent => &self.accent,+            Role::OnAccent => &self.on_accent,+            Role::Selection => &self.selection,+            Role::Focus => &self.focus,+            Role::Good => &self.good,+            Role::Warn => &self.warn,+            Role::Bad => &self.bad,+        }+    }++    fn slot_mut(&mut self, r: Role) -> &mut Rgba {+        match r {+            Role::Background => &mut self.background,+            Role::Surface => &mut self.surface,+            Role::Raised => &mut self.raised,+            Role::Text => &mut self.text,+            Role::TextMuted => &mut self.text_muted,+            Role::Line => &mut self.line,+            Role::Accent => &mut self.accent,+            Role::OnAccent => &mut self.on_accent,+            Role::Selection => &mut self.selection,+            Role::Focus => &mut self.focus,+            Role::Good => &mut self.good,+            Role::Warn => &mut self.warn,+            Role::Bad => &mut self.bad,+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn role_keys_round_trip_and_address_distinct_fields() {+        let mut roles = crate::builtins::graphite::LIGHT;+        for (i, role) in Role::ALL.into_iter().enumerate() {+            assert_eq!(Role::from_key(role.key()), Some(role));+            roles.set(role, Rgba::rgb(i as u8, 0, 0));+        }+        for (i, role) in Role::ALL.into_iter().enumerate() {+            assert_eq!(roles.get(role), Rgba::rgb(i as u8, 0, 0));+        }+        assert_eq!(Role::from_key("Text"), None);+    }+}diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rsnew file mode 100644--- /dev/null+++ b/crates/theme/src/theme.rs@@ -0,0 +1,136 @@+use std::{collections::BTreeMap, fmt};++use crate::{CoreRoles, IdError, Rgba, Role, ThemeId, Variant, builtins};++#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub enum Origin {+    BuiltIn,+    User,+}++/// A named theme: one or both variants of the 13 core roles.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct SuiteTheme {+    pub id: ThemeId,+    pub name: String,+    pub version: u32,+    pub light: Option<CoreRoles>,+    pub dark: Option<CoreRoles>,+    pub origin: Origin,+}++/// One variant as a theme file holds it: role key to colour.+pub type RoleMap = BTreeMap<String, Rgba>;++/// A role a definition left out, filled from built-in Graphite of the same variant.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub struct MissingRole {+    pub variant: Variant,+    pub role: Role,+}++#[derive(Clone, Debug, PartialEq, Eq)]+pub enum DefinitionError {+    Id(IdError),+    /// Neither a light nor a dark variant.+    NoVariant,+}++impl SuiteTheme {+    /// A user or imported theme from its file's role maps.+    ///+    /// Missing roles are filled from built-in Graphite of the same variant and reported; unknown+    /// keys are ignored here (the store keeps them on disk). An empty name falls back to the id.+    /// Contrast is not checked: [`crate::contrast::diagnose`] reports it without refusing anything.+    pub fn from_maps(+        id: &str,+        name: &str,+        version: u32,+        light: Option<&RoleMap>,+        dark: Option<&RoleMap>,+    ) -> Result<(SuiteTheme, Vec<MissingRole>), DefinitionError> {+        let id = ThemeId::parse_user(id).map_err(DefinitionError::Id)?;+        if light.is_none() && dark.is_none() {+            return Err(DefinitionError::NoVariant);+        }+        let mut missing = Vec::new();+        let mut fill = |variant: Variant, map: &RoleMap| {+            let mut roles = builtins::graphite::roles(variant);+            for role in Role::ALL {+                match map.get(role.key()) {+                    Some(&c) => roles.set(role, c),+                    None => missing.push(MissingRole { variant, role }),+                }+            }+            roles+        };+        let light = light.map(|m| fill(Variant::Light, m));+        let dark = dark.map(|m| fill(Variant::Dark, m));+        let name = if name.trim().is_empty() { id.as_str().to_owned() } else { name.to_owned() };+        Ok((SuiteTheme { id, name, version, light, dark, origin: Origin::User }, missing))+    }++    pub fn variant(&self, v: Variant) -> Option<&CoreRoles> {+        match v {+            Variant::Light => self.light.as_ref(),+            Variant::Dark => self.dark.as_ref(),+        }+    }++    /// The variants this theme defines, light first.+    pub fn variants(&self) -> impl Iterator<Item = (Variant, &CoreRoles)> {+        Variant::ALL.into_iter().filter_map(|v| self.variant(v).map(|r| (v, r)))+    }+}++impl fmt::Display for DefinitionError {+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {+        match self {+            DefinitionError::Id(e) => e.fmt(f),+            DefinitionError::NoVariant => f.write_str("a theme needs a light or a dark variant"),+        }+    }+}++impl std::error::Error for DefinitionError {+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {+        match self {+            DefinitionError::Id(e) => Some(e),+            DefinitionError::NoVariant => None,+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn map(pairs: &[(&str, u32)]) -> RoleMap {+        pairs.iter().map(|&(k, c)| (k.to_owned(), Rgba::from_rgb_u32(c))).collect()+    }++    #[test]+    fn missing_roles_come_from_graphite_and_are_reported() {+        let dark = map(&[("text", 0xFFFFFF), ("background", 0x000000), ("sparkle", 0x123456)]);+        let (theme, missing) = SuiteTheme::from_maps("night", "Night", 3, None, Some(&dark)).unwrap();+        assert_eq!(theme.origin, Origin::User);+        assert_eq!(theme.version, 3);+        assert!(theme.light.is_none());+        let roles = theme.dark.unwrap();+        assert_eq!(roles.text, Rgba::WHITE);+        assert_eq!(roles.background, Rgba::BLACK);+        assert_eq!(roles.accent, builtins::graphite::DARK.accent);+        assert_eq!(missing.len(), 11);+        assert!(missing.iter().all(|m| m.variant == Variant::Dark && !matches!(m.role, Role::Text | Role::Background)));+    }++    #[test]+    fn definition_errors() {+        let m = map(&[]);+        assert_eq!(SuiteTheme::from_maps("x", "X", 1, Some(&m), None).unwrap_err(), DefinitionError::Id(IdError::Length));+        assert_eq!(SuiteTheme::from_maps("craft.mine", "Mine", 1, Some(&m), None).unwrap_err(), DefinitionError::Id(IdError::Reserved));+        assert_eq!(SuiteTheme::from_maps("mine", "Mine", 1, None, None).unwrap_err(), DefinitionError::NoVariant);+        let (theme, _) = SuiteTheme::from_maps("mine", "  ", 1, Some(&m), None).unwrap();+        assert_eq!(theme.name, "mine");+    }+}diff --git a/crates/theme/tests/contract.rs b/crates/theme/tests/contract.rsnew file mode 100644--- /dev/null+++ b/crates/theme/tests/contract.rs@@ -0,0 +1,252 @@+//! The suite-theme contract as apps see it: built-ins, resolution, derived states and advisory+//! contrast diagnostics.++use std::cell::RefCell;++use craft_theme::{+    APP_DEFAULT, AppChoice, AppDefaults, CoreRoles, Fallback, Mode, Origin, Resolution, Rgba, Role, RoleMap, SUITE_DEFAULT, Selection, SuiteTheme,+    ThemeId, Variant,+    contrast::{self, Need, Paint},+    derive, lookup_builtin, resolve,+};+use proptest::prelude::*;++fn id(s: &str) -> ThemeId {+    ThemeId::parse(s).unwrap()+}++fn role_map(roles: &CoreRoles) -> RoleMap {+    Role::ALL.into_iter().map(|r| (r.key().to_owned(), roles.get(r))).collect()+}++fn graphite(v: Variant) -> CoreRoles {+    *craft_theme::builtin(&id("craft.graphite")).unwrap().variant(v).unwrap()+}++#[test]+fn builtins_have_both_variants_and_no_diagnostics() {+    let ids: Vec<_> = craft_theme::builtins().iter().map(|t| t.id.as_str()).collect();+    assert_eq!(ids, ["craft.artcraft", "craft.graphite", "craft.high-contrast"]);+    for t in craft_theme::builtins() {+        assert_eq!(t.origin, Origin::BuiltIn);+        assert_eq!(ThemeId::parse(t.id.as_str()).as_ref(), Ok(&t.id));+        assert!(t.id.is_builtin() && !t.id.is_app_default());+        assert!(t.light.is_some() && t.dark.is_some(), "{}", t.id);+        for (v, roles) in t.variants() {+            assert!(Role::ALL.iter().all(|&r| roles.get(r).is_opaque()), "{} {v:?}", t.id);+        }+        // Covers every derived state in the painted-pair table, hover and pressed included.+        assert_eq!(contrast::diagnose(t), [], "{}", t.id);+    }+}++#[test]+fn effective_selection() {+    let defaults = AppDefaults { theme: ThemeId::suite_default(), mode: Mode::Dark };+    let suite = Selection { theme: id("craft.graphite"), mode: Mode::Light };+    let own = Selection { theme: ThemeId::app_default(), mode: Mode::System };+    let follow = AppChoice::FollowSuite;+    // No suite selection yet: a following app shows its own defaults.+    assert_eq!(follow.effective(None, &defaults), Selection { theme: id(SUITE_DEFAULT), mode: Mode::Dark });+    assert_eq!(follow.effective(Some(&suite), &defaults), suite);+    assert_eq!(AppChoice::Own(own.clone()).effective(None, &defaults), own);+    assert_eq!(AppChoice::Own(own.clone()).effective(Some(&suite), &defaults), own);+    assert!(craft_theme::builtin(&ThemeId::suite_default()).is_some());+}++/// Fails to compile if `T: Default` (the method lookup becomes ambiguous): an app's+/// `#[serde(default)]` must not invent a choice or a suite selection.+#[test]+fn choices_and_selections_have_no_default() {+    trait AmbiguousIfDefault<A> {+        fn check() {}+    }+    impl<T: ?Sized> AmbiguousIfDefault<()> for T {}+    struct IsDefault;+    impl<T: Default> AmbiguousIfDefault<IsDefault> for T {}+    <AppChoice as AmbiguousIfDefault<_>>::check();+    <Selection as AmbiguousIfDefault<_>>::check();+    <AppDefaults as AmbiguousIfDefault<_>>::check();+}++/// One user theme with only a dark variant, alongside the built-ins.+fn lookup_with_dark_only(id: &ThemeId) -> Option<SuiteTheme> {+    if id.as_str() == "night" {+        let dark = role_map(&graphite(Variant::Dark));+        return Some(SuiteTheme::from_maps("night", "Night", 1, None, Some(&dark)).unwrap().0);+    }+    lookup_builtin(id)+}++#[test]+fn resolution_table() {+    let themes = [APP_DEFAULT, "craft.artcraft", "craft.graphite", "craft.high-contrast", "night", "gone"];+    let systems = [None, Some(Variant::Light), Some(Variant::Dark)];+    for theme in themes {+        for mode in Mode::ALL {+            for system in systems {+                for fallback in Variant::ALL {+                    let sel = Selection { theme: id(theme), mode };+                    let calls = RefCell::new(Vec::new());+                    let lookup = |i: &ThemeId| {+                        calls.borrow_mut().push(i.clone());+                        lookup_with_dark_only(i)+                    };+                    let got = resolve(&sel, system, fallback, &lookup);+                    let case = format!("{theme} {mode:?} {system:?} {fallback:?}");+                    // Pure: the same inputs give the same answer, and only the selected id is looked up.+                    assert_eq!(resolve(&sel, system, fallback, &lookup), got, "{case}");+                    assert!(calls.borrow().iter().all(|i| *i == sel.theme), "{case}");++                    let wanted = match mode {+                        Mode::Light => Variant::Light,+                        Mode::Dark => Variant::Dark,+                        Mode::System => system.unwrap_or(fallback),+                    };+                    let silent = (mode == Mode::System && system.is_none()).then_some(Fallback::SystemSilent);+                    match (theme, &got) {+                        (APP_DEFAULT, Resolution::AppDefault { variant, why }) => {+                            assert_eq!((*variant, why), (wanted, &silent), "{case}");+                        }+                        ("gone", Resolution::AppDefault { variant, why }) => {+                            assert_eq!((*variant, why), (wanted, &Some(Fallback::UnknownTheme(id("gone")))), "{case}");+                        }+                        ("night", Resolution::Theme(r)) => {+                            assert_eq!(r.variant, Variant::Dark, "{case}");+                            let note = if wanted == Variant::Dark { silent } else { Some(Fallback::OnlyVariant(Variant::Dark)) };+                            assert_eq!(r.note, note, "{case}");+                            assert_eq!(r.roles, graphite(Variant::Dark), "{case}");+                        }+                        (_, Resolution::Theme(r)) if id(theme).is_builtin() => {+                            let builtin = craft_theme::builtin(&r.theme).unwrap();+                            assert_eq!((r.theme.as_str(), r.variant, &r.note), (theme, wanted, &silent), "{case}");+                            assert_eq!(&r.roles, builtin.variant(wanted).unwrap(), "{case}");+                            assert_eq!(r.derived, derive(&r.roles, r.variant), "{case}");+                        }+                        _ => panic!("{case}: {got:?}"),+                    }+                }+            }+        }+    }+}++#[test]+fn low_contrast_user_theme_is_reported_but_kept() {+    let mut light = graphite(Variant::Light);+    light.text = light.surface;+    let (theme, missing) = SuiteTheme::from_maps("flat", "Flat", 1, Some(&role_map(&light)), None).unwrap();+    assert_eq!(missing, []);+    let diagnostics = contrast::diagnose(&theme);+    let flat = diagnostics.iter().find(|d| d.fg == Paint::Role(Role::Text) && d.bg == Paint::Role(Role::Surface)).unwrap();+    assert_eq!((flat.variant, flat.need), (Variant::Light, Need::Text));+    assert!((flat.ratio - 1.0).abs() < 1e-6);+    assert_eq!(theme.light, Some(light));++    let sel = Selection { theme: theme.id.clone(), mode: Mode::Light };+    let Resolution::Theme(r) = resolve(&sel, None, Variant::Light, &|_| Some(theme.clone())) else { panic!() };+    assert_eq!((r.roles, r.note), (light, None));+}++fn lighter(a: Rgba, b: Rgba) -> bool {+    contrast::ratio(a, Rgba::BLACK) > contrast::ratio(b, Rgba::BLACK)+}++#[test]+fn derived_states_step_away_from_text_when_toward_would_fail() {+    // ArtCraft dark: hover and pressed lighten toward text; lightening the mid-grey selection+    // would take text below 4.5:1 and text_muted below 3.5:1, so selected_hover darkens.+    let artcraft = craft_theme::builtin(&ThemeId::suite_default()).unwrap().dark.unwrap();+    let d = derive(&artcraft, Variant::Dark);+    assert_eq!((d.hover.to_hex(), d.pressed.to_hex()), ("#5f5f5f".to_owned(), "#696969".to_owned()));+    assert!(lighter(artcraft.selection, d.selected_hover));+    let light = graphite(Variant::Light);+    assert!(lighter(light.surface, derive(&light, Variant::Light).hover));++    let mut grey = light;+    (grey.background, grey.surface, grey.raised, grey.text, grey.text_muted) =+        (Rgba::from_rgb_u32(0xC4C4C4), Rgba::from_rgb_u32(0xC4C4C4), Rgba::from_rgb_u32(0xC4C4C4), Rgba::BLACK, Rgba::from_rgb_u32(0x606060));+    let d = derive(&grey, Variant::Light);+    assert!(lighter(d.hover, grey.surface) && lighter(d.pressed, grey.surface));+    for bg in [d.hover, d.pressed] {+        assert!(contrast::ratio(grey.text_muted, bg) >= 3.5);+    }+}++fn channel_range(lo: u8, hi: u8) -> impl Strategy<Value = Rgba> {+    [lo..=hi, lo..=hi, lo..=hi].prop_map(|[r, g, b]| Rgba::rgb(r, g, b))+}++fn opaque() -> impl Strategy<Value = Rgba> {+    channel_range(0, 255)+}++/// Mostly plausible themes, dark text on light backgrounds or light on dark, so the+/// preconditions below hold often; the other roles are arbitrary.+fn core_roles() -> impl Strategy<Value = CoreRoles> {+    let shaped = |(back_lo, back_hi), (ink_lo, ink_hi)| {+        (+            [channel_range(back_lo, back_hi), channel_range(back_lo, back_hi), channel_range(back_lo, back_hi), channel_range(back_lo, back_hi)],+            [channel_range(ink_lo, ink_hi), channel_range(ink_lo, ink_hi)],+            [opaque(), opaque(), opaque(), opaque(), opaque(), opaque(), opaque()],+        )+            .prop_map(|([background, surface, raised, selection], [text, text_muted], [line, accent, on_accent, focus, good, warn, bad])| {+                CoreRoles { background, surface, raised, text, text_muted, line, accent, on_accent, selection, focus, good, warn, bad }+            })+    };+    prop_oneof![shaped((170, 255), (0, 110)), shaped((0, 90), (150, 255))]+}++/// [`Rgba::lerp_gamma`] with `-t`, as the derived-state guard steps away from `text`.+fn step_away(base: Rgba, from: Rgba, t: f32) -> Rgba {+    let ch = |a: u8, b: u8| (f32::from(a) - (f32::from(b) - f32::from(a)) * t + 0.5).floor().clamp(0.0, 255.0) as u8;+    Rgba::rgb(ch(base.r(), from.r()), ch(base.g(), from.g()), ch(base.b(), from.b()))+}++/// Whether `candidate`, painted as the derived background `name`, meets every pair on it.+fn meets_pairs_on(name: &'static str, candidate: Rgba, roles: &CoreRoles) -> bool {+    contrast::painted_pairs().iter().filter(|p| p.bg == Paint::Derived(name)).all(|p| {+        let Paint::Role(fg) = p.fg else { return true };+        p.need.is_met(contrast::ratio(roles.get(fg), candidate))+    })+}++proptest! {+    #[test]+    fn derived_states_keep_pairs_their_core_roles_allow(roles in core_roles(), dark in any::<bool>()) {+        let v = if dark { Variant::Dark } else { Variant::Light };+        let map = role_map(&roles);+        let (light, dark_map) = if dark { (None, Some(&map)) } else { (Some(&map), None) };+        let theme = SuiteTheme::from_maps("random", "Random", 1, light, dark_map).unwrap().0;+        let failing: Vec<_> = contrast::diagnose(&theme).into_iter().map(|d| (d.fg, d.bg)).collect();+        let fails = |name: &'static str| failing.iter().any(|&(fg, bg)| fg == Paint::Derived(name) || bg == Paint::Derived(name));+        let text_ok = |fg| [roles.background, roles.surface, roles.raised].iter().all(|&bg| contrast::ratio(fg, bg) >= 4.5);+        if text_ok(roles.text) {+            prop_assert!(!fails("accent_text") && !fails("control_edge"));+        }+        if text_ok(roles.text_muted) {+            prop_assert!(!fails("text_faint"));+        }+        if contrast::ratio(roles.on_accent, roles.accent) >= 4.5 {+            prop_assert!(!fails("accent_hover") && !fails("accent_pressed"));+        }+        // The guard: if a step toward text or the same step away meets every pair, the state does.+        let (hover, pressed) = if dark { (0.08, 0.14) } else { (0.06, 0.12) };+        for (name, base, t) in [("hover", roles.surface, hover), ("pressed", roles.surface, pressed), ("selected_hover", roles.selection, 0.08)] {+            let toward = base.lerp_gamma(roles.text, t);+            if meets_pairs_on(name, toward, &roles) || meets_pairs_on(name, step_away(base, roles.text, t), &roles) {+                prop_assert!(!fails(name), "{}", name);+            }+        }+        // Resolution is pure and hands back the theme's colours with `derive`'s states.+        let sel = Selection { theme: theme.id.clone(), mode: if dark { Mode::Dark } else { Mode::Light } };+        let lookup = |_: &ThemeId| Some(theme.clone());+        let got = resolve(&sel, None, v, &lookup);+        prop_assert_eq!(&got, &resolve(&sel, None, v, &lookup));+        let Resolution::Theme(r) = got else { return Err(TestCaseError::fail("not a theme")) };+        prop_assert_eq!((r.variant, r.note), (v, None));+        prop_assert_eq!(r.roles, roles);+        prop_assert_eq!(r.derived, derive(&roles, v));+        prop_assert_eq!(r.derived.accent_soft, roles.surface.lerp_gamma(roles.accent, 0.14));+    }+}diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml--- a/crates/ui/Cargo.toml+++ b/crates/ui/Cargo.toml@@ -13,6 +13,7 @@ workspace = true [dependencies] craft-fonts-build = { workspace = true } craft-i18n = { workspace = true }+craft-theme = { workspace = true } egui = { workspace = true } epaint = { version = "0.37", default-features = false } log = "0.4"diff --git a/crates/ui/README.md b/crates/ui/README.md--- a/crates/ui/README.md+++ b/crates/ui/README.md@@ -2,7 +2,7 @@  `craft-ui` provides small egui components with application-supplied styles. `icons` prepares embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and-interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.+interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling and bridges suite themes to egui. `docking` supplies transactional panel moves and an optional docking surface. `printing` supplies printer/job controls and asynchronous feedback used by WordCraft and GridCraft. `fonts` installs the UI font stack, the same fallback order in every app.@@ -222,6 +222,10 @@ application; fonts are installed by `fonts`, below. See the [eight-app comparison](../../docs/theme-infrastructure.md) for the exact common operation and the differences preserved by the adapters. +`theme::color32` converts a [`craft-theme`](../theme/README.md) colour to egui's, and+`theme::install(ctx, light, dark)` sets egui's light and dark styles and `ThemePreference::System`+for an app that maps a suite theme onto its own styles ([suite themes](../../docs/suite-themes.md)).+ ## UI fonts  egui draws a character only if one of its installed faces has it, so an app's labels, layer namesdiff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs--- a/crates/ui/src/theme/mod.rs+++ b/crates/ui/src/theme/mod.rs@@ -1,9 +1,29 @@ //! Theme mechanisms for egui widgets, with palettes and theme selection owned by applications. //!-//! This module does not store a theme or change global visuals; [`crate::fonts`] installs fonts.-//! Applications decide their colors, borders, typography, spacing and saved preferences independently.+//! [`WidgetPalette`] colors egui's widget states. [`color32`] and [`install`] bridge the suite-theme+//! contract in `craft_theme` to egui: an app maps a resolved theme's roles to its own tokens and+//! styles, then installs one style per egui theme. This module stores no theme and keeps nothing in+//! egui's context; [`crate::fonts`] installs fonts. Applications decide their colors, borders,+//! typography, spacing and saved preferences independently. -use egui::{Color32, CornerRadius, Stroke, style::Widgets};+use egui::{Color32, CornerRadius, Stroke, Style, Theme, ThemePreference, style::Widgets};++/// A suite-theme colour as egui's (premultiplied) colour.+pub fn color32(c: craft_theme::Rgba) -> Color32 {+    let [r, g, b, a] = c.0;+    Color32::from_rgba_unmultiplied(r, g, b, a)+}++/// Sets the style egui uses in light and in dark, and lets egui pick between them from the+/// system's answer ([`ThemePreference::System`]).+///+/// An app with a fixed mode passes its chosen variant's style twice; one following the system+/// passes each variant's, and egui switches without a rebuild.+pub fn install(ctx: &egui::Context, light: Style, dark: Style) {+    ctx.set_style_of(Theme::Light, light);+    ctx.set_style_of(Theme::Dark, dark);+    ctx.set_theme(ThemePreference::System);+}  /// Background colors for egui's five widget interaction states. ///@@ -94,4 +114,22 @@ mod tests {         assert_eq!(widgets.inactive.weak_bg_fill, Color32::GRAY);         assert_eq!(widgets.active.bg_stroke, Stroke::new(3.0, Color32::YELLOW));     }++    #[test]+    fn bridge_converts_colors_and_installs_both_styles() {+        assert_eq!(color32(craft_theme::Rgba::from_rgb_u32(0x2D81FF)), Color32::from_rgb(0x2D, 0x81, 0xFF));+        assert_eq!(color32(craft_theme::Rgba([255, 0, 0, 128])), Color32::from_rgba_unmultiplied(255, 0, 0, 128));++        let ctx = egui::Context::default();+        let mut light = Style::default();+        light.visuals = egui::Visuals::light();+        light.visuals.panel_fill = Color32::RED;+        let mut dark = Style::default();+        dark.visuals.panel_fill = Color32::BLUE;+        ctx.set_theme(ThemePreference::Light);+        install(&ctx, light, dark);+        assert_eq!(ctx.style_of(Theme::Light).visuals.panel_fill, Color32::RED);+        assert_eq!(ctx.style_of(Theme::Dark).visuals.panel_fill, Color32::BLUE);+        assert_eq!(ctx.options(|o| o.theme_preference), ThemePreference::System);+    } }diff --git a/docs/suite-themes.md b/docs/suite-themes.mdnew file mode 100644--- /dev/null+++ b/docs/suite-themes.md@@ -0,0 +1,166 @@+# Suite themes++A suite theme is a named set of colours that every Craft app can paint its interface with, so that+one choice in one app can restyle the whole suite. This page is the contract the apps share and+how an app adopts it. It follows skylartaylor's brief on+[pdfcraft#1090](https://github.com/storytold/pdfcraft/pull/1090). PdfCraft, PhotoCraft and the+ArtCraft Launcher are its first consumers.++The contract is deliberately small. Apps keep their own `Tokens`, `egui::Visuals`, layout, fonts,+document and canvas colours and saved preferences. They map the 13 core roles below into their own+tokens through an adapter.++| Crate | Contents |+|---|---|+| [`craft-theme`](../crates/theme/README.md) | Theme ids, roles, themes, derived states, contrast, built-ins, resolution. No egui, no I/O; builds for wasm. |+| `craft-theme-store` | Theme files, the suite folder, the suite selection and change watching, outside rendering. |+| [`craft_ui::theme`](../crates/ui/README.md#theme-mechanisms) | `color32` and `install`: the bridge to egui. |++## Themes and selection++A theme has a stable `ThemeId` (`[a-z0-9][a-z0-9._-]{1,63}`), a display name, a version and a light+variant, a dark variant or both. Renaming a theme keeps its id. Ids starting with `craft.` belong to+the built-ins. User theme ids become file names, so `ThemeId::parse_user` and+`SuiteTheme::from_maps` refuse `craft.` ids and ids whose first dot-separated part is a Windows+device name (`con`, `nul`, `aux`, `prn`, `com1`-`com9`, `lpt1`-`lpt9`).++| Id | Name | Light | Dark |+|---|---|---|---|+| `craft.app-default` | App default | the app's own palette | the app's own palette |+| `craft.artcraft` | ArtCraft | PhotoCraft Pro Light | PhotoCraft Pro Medium |+| `craft.graphite` | Graphite | PdfCraft Light gray | PdfCraft Dark gray |+| `craft.high-contrast` | High contrast | black on white, dark blue accent | white on black, yellow accent |++`builtins()` returns the last three. `craft.app-default` is not a theme with colours: it stands for+each app's current palette, and the settings UI lists it beside the others.++A `Selection` is a theme id and a `Mode` (`System`, `Light` or `Dark`). The suite has at most one+selection, stored in the suite folder's `selection.json`. No `selection.json` means no suite+selection: nobody has picked one yet, and neither `Selection` nor `AppChoice` has a default that+could invent one.++Each app keeps an `AppChoice` in its own preferences: `FollowSuite`, or `Own(Selection)` ("Use a+different theme in this app"). Each app also states its `AppDefaults` (a theme and a mode): what it+shows while it follows the suite and the suite has no selection. Apps normally use `SUITE_DEFAULT`+(`craft.artcraft`); the pilot apps (PdfCraft, PhotoCraft, the launcher) use ArtCraft with+`Mode::Dark`, and office apps may ship Light. `AppChoice::effective(suite, &defaults)` returns the+selection that applies:++| `AppChoice` | Suite selection | Effective selection |+|---|---|---|+| `Own(sel)` | any | `sel` |+| `FollowSuite` | `Some(suite)` | `suite` |+| `FollowSuite` | none | the app's `AppDefaults` |++An app writes `selection.json` only when the user picks a suite theme or mode, never on first+start.++## Core roles++Every variant defines all 13 roles. A user theme that leaves roles out gets them from Graphite of+the same variant, and `from_maps` lists each one it filled.++| Role | Painted as |+|---|---|+| `background` | behind panels: the window, pasteboard or canvas surround |+| `surface` | panels, sidebars, controls at rest |+| `raised` | cards, popups, menus, fields |+| `text`, `text_muted` | primary and secondary text |+| `line` | decorative dividers (no contrast requirement) |+| `accent`, `on_accent` | accent fills and the text or icons on them |+| `selection` | selected rows and items, with `text` on top |+| `focus` | keyboard focus rings |+| `good`, `warn`, `bad` | status marks |++## Derived states++`derive(&roles, variant)` computes the interaction and secondary colours, mixed in encoded sRGB+(`Rgba::lerp_gamma`). Apps may overwrite any field.++| State | Rule |+|---|---|+| `hover`, `pressed` | `surface` 6% / 12% toward `text` in light, 8% / 14% in dark |+| `selected_hover` | `selection` 8% toward `text` |+| `accent_hover`, `accent_pressed` | `accent` moved away from `on_accent`'s luminance in 2% steps, up to 10% / 22%, stopping before a step that takes `on_accent` below 4.5:1 |+| `accent_text` | `accent` toward `text` until it reaches 4.5:1 on background, surface and raised |+| `accent_soft` | `accent` at 14% over `surface` |+| `text_faint` | `text_muted` toward `surface` while it stays at 4.5:1 on background, surface and raised |+| `control_edge` | `line` toward `text` until it reaches 3:1 on background, surface and raised |++Each state keeps the painted pairs it is part of at their requirement. When a step toward `text`+would break one, the state steps away from `text` by the same amount instead. For example,+ArtCraft's dark selection is mid grey, so its `selected_hover` is darker than the selection: a+lighter one would leave `text` below 4.5:1 and `text_muted` below 3.5:1. Built-in colours are never changed to suit a+derived state.++## Contrast++`contrast::painted_pairs()` lists every pair the apps paint from the roles and derived states.+Ratios are WCAG 2; a translucent foreground is composited over the background first. Text needs+4.5:1 (WCAG AA). Muted text needs 4.5:1 on the resting backgrounds and 3.5:1 on the hover, pressed+and selected state backgrounds, where it sits only while the pointer or the selection is there.++| Foreground | Background | Need |+|---|---|---|+| `text` | `background`, `surface`, `raised`, `hover`, `pressed`, `selection`, `selected_hover` | 4.5:1 |+| `text_muted` | `background`, `surface`, `raised` | 4.5:1 |+| `text_muted` | `hover`, `pressed`, `selection`, `selected_hover` | 3.5:1 |+| `text_faint` | `background`, `surface`, `raised` | 4.5:1 |+| `on_accent` | `accent`, `accent_hover`, `accent_pressed` | 4.5:1 |+| `accent_text` | `background`, `surface`, `raised` | 4.5:1 |+| `focus`, `control_edge`, `accent` | `surface`, `raised`, `background` | 3:1 |+| `good`, `warn`, `bad` | `background`, `surface` | 3:1 |++`contrast::diagnose(&theme)` returns the pairs that miss their need. Diagnostics are advisory: a+user or imported theme keeps its colours and can still be imported and selected, and the app shows+the diagnostics. The built-in themes have none; a test enforces that. `contrast::check` tests pairs+an app paints outside this table.++## Resolution++`resolve(&selection, system, fallback, &lookup)` is pure. `system` is the OS's light/dark answer+(`None` when it gives none), `fallback` is the app's variant for that case, and `lookup` finds a+theme by id (the built-ins, or the store's library).++| Selection | Result | `Fallback` note |+|---|---|---|+| `Mode::System`, OS answers | the OS's variant | none |+| `Mode::System`, OS silent | the app's fallback variant | `SystemSilent` |+| `Mode::Light` or `Mode::Dark` | that variant | none |+| `craft.app-default` | `Resolution::AppDefault` in the variant above | as above |+| an id `lookup` doesn't know | `Resolution::AppDefault` in the variant above, never another named theme | `UnknownTheme` |+| a theme with only the other variant | `Resolution::Theme` in the variant it has | `OnlyVariant` |++A `Resolved` theme carries the roles of the chosen variant and their derived states.++## Adopting suite themes in an app++There is no shared trait and no shared context key. Each app adds to its own theme module:++1. `Tokens::app_default(variant)`: its existing palette, unchanged.+2. `Tokens::from_resolved(&Resolved)`: maps the core roles and derived states onto its token fields.+3. `install(ctx, light: Tokens, dark: Tokens)`: builds its own `Style` for each token set and calls+   `craft_ui::theme::install(ctx, light_style, dark_style)`, which sets both of egui's styles and+   `ThemePreference::System`. With a fixed mode both slots get the chosen variant; with+   `Mode::System` each slot gets its own variant, and egui switches when the OS does, without a+   rebuild. The app stores both token sets in its own state, so `Tokens::get` returns the set for+   `ctx.theme()`.++On Linux and the BSDs, the portal's answer from `craft_platform::appearance::Watcher::current()`+replaces winit's in `raw.system_theme` (in the raw-input hook); `None` from the portal clears it.+Each app sets `Options::fallback_theme`. The shared crates store nothing in egui's context.++An app's saved light/dark preference becomes `Own(craft.app-default, same mode)` on adoption, so+its look doesn't change until the user picks a named theme or turns on "Follow suite". A fresh+install follows the suite and shows the app's `AppDefaults` until the user picks a suite theme.+Each app sets these defaults explicitly for a fresh install and for a legacy preference file, so a+`#[serde(default)]` never moves existing users to the suite.++Each adapter PR carries an exact style comparison (see+[theme-infrastructure.md](theme-infrastructure.md#migration-and-validation)): with+`craft.app-default` the app's `Style` and `Tokens` stay identical.++## File format and storage++`craft-theme-store` (coming) defines the theme file format, the machine-local theme library, the+suite selection and how apps see each other's changes.diff --git a/docs/theme-infrastructure.md b/docs/theme-infrastructure.md--- a/docs/theme-infrastructure.md+++ b/docs/theme-infrastructure.md@@ -159,6 +159,28 @@ shared tabs and buttons now also serve PdfCraft, WordCraft, GridCraft, DeckCraft SoundCraft; their full palettes stay local. Further extractions need two real consumers and their own visual and interaction checks. +## Suite themes (2026-10)++PdfCraft, PhotoCraft and the ArtCraft Launcher are the runtime consumers of a shared suite-theme+contract:+theme identity, 13 core colour roles, derived states, resolution and contrast checks, following+skylartaylor's brief on [pdfcraft#1090](https://github.com/storytold/pdfcraft/pull/1090). It is not+a universal token struct: apps keep their `Tokens`, `Visuals` and saved preferences and map the+core roles through their own adapters. There is no shared theme enum: themes are data with a string+`ThemeId`. The app identifier appears only as the key of the app's saved choice and of reserved+per-app overrides in theme files. The shared crates store nothing in egui's context.++The contract lives in the egui-free [`craft-theme`](../crates/theme/README.md) crate, so a CLI, the+launcher's catalogue or an extension validator can use it without egui. Theme files, storage and+change watching belong to `craft-theme-store`, outside rendering. `craft_ui::theme` only bridges to+egui (`color32`, `install`). The contrast checks run on the colour pairs the apps paint; PdfCraft+(its Appearance warnings and adapter tests), PhotoCraft and the launcher (their adapter tests) are+their runtime consumers. [Suite themes](suite-themes.md) describes the+contract and how an app adopts it.++The earlier statements on this page remain true for `WidgetPalette` and everything outside the+core roles.+ [vector-loop]: https://github.com/storytold/vectorcraft/blob/825d4fdb8b527c597855d273fb4bcc1971855b6a/crates/ui-egui/src/theme.rs#L299-L312  [design-loop]: https://github.com/storytold/designcraft/blob/66c7ce7e96ec286a091878cc2541ee8369aa79d6/crates/ui-egui/src/theme.rs#L336-L348