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
new file mode 100644@@ -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 --locked@@ -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",@@ -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" }new file mode 100644@@ -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 = truenew file mode 100644@@ -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).new file mode 100644@@ -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),+};new file mode 100644@@ -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,+ }+}new file mode 100644@@ -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),+};new file mode 100644@@ -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()+}new file mode 100644@@ -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]));+ }+}new file mode 100644@@ -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}");+ }+ }+ }+ }+}new file mode 100644@@ -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+}new file mode 100644@@ -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}");+ }+ }+}new file mode 100644@@ -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};new file mode 100644@@ -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);+ }+}new file mode 100644@@ -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);+ }+}new file mode 100644@@ -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");+ }+}new file mode 100644@@ -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));+ }+}@@ -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"@@ -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 names@@ -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);+ } }new file mode 100644@@ -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.@@ -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