diff --git a/.github/workflows/theme.yml b/.github/workflows/theme.yml
new 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 --locked
diff --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.toml
new 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 = true
diff --git a/crates/theme/README.md b/crates/theme/README.md
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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 names
diff --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.md
new 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
