//! Derives Lumbridge's entire interface palette from a syntax theme's anchors. //! //! A syntax theme has already solved the hard problem: which background, //! foreground, and comment colour work together. This crate takes that answer //! and derives everything else — frame, panels, borders, state colours — so a //! user picking "Catppuccin Mocha" gets an interface built out of it rather than //! a fixed interface with a themed terminal inside. //! //! It holds no framework types. The maths is testable headless, and the UI layer //! converts a [`Palette`] into whatever its renderer wants once per theme //! change. See decision 0018. #![forbid(unsafe_code)] // A colour is read as six hex digits. `0x00d9_4824` is harder to check against a // design token than `0xd94824`, not easier, and this crate is almost entirely // colour literals. #![allow( clippy::unreadable_literal, reason = "six-digit colour hex reads whole" )] pub mod accent; pub mod color; pub mod derive; pub mod generated; pub mod palette; pub mod terminal; pub use accent::{ACCENTS, Accent, DEFAULT_ACCENT, accent_or_default}; pub use color::{Srgb, contrast_ratio, relative_luminance}; pub use derive::{RoleOverrides, ThemeAnchors, derive}; pub use palette::{Palette, Provenance}; pub use terminal::{AnsiColor, TerminalPalette}; /// The default theme's identifier. pub const DEFAULT_THEME: &str = "lumbridge-slate"; /// Lumbridge's own dark theme. /// /// Its overrides are the eleven constants the interface used before the palette /// existed, reproduced exactly. That is deliberate and temporary: introducing /// the engine should be reviewable as "colours now come from a palette" with no /// appearance change smuggled inside it. The anchors are real, so removing the /// overrides yields a theme very close to this one — see the test below for how /// close. Decision 0018 records the list. pub const LUMBRIDGE_SLATE: ThemeAnchors = ThemeAnchors { name: DEFAULT_THEME, display_name: "Lumbridge Slate", bg: Srgb::from_hex(0x101620), fg: Srgb::from_hex(0xdbe5f4), comment: Srgb::from_hex(0x8290a8), added: Some(Srgb::from_hex(0x70d6a8)), deleted: Some(Srgb::from_hex(0xff6b6b)), modified: Some(Srgb::from_hex(0xf1b96a)), // The sixteen values `main.rs` already hard-codes for indexed terminal // colours, moved here unchanged, so adopting per-theme terminal palettes // costs the default theme no pixels either. The cursor colour is the one // `main.rs` uses; the colour of the glyph *under* a block cursor was never // chosen, so it derives the way every catalog theme's does — from the // background — rather than being guessed at. terminal: TerminalPalette { background: Srgb::from_hex(0x101620), foreground: Srgb::from_hex(0xdbe5f4), cursor: Srgb::from_hex(0xf4f8ff), cursor_text: Srgb::from_hex(0x101620), ansi: [ Srgb::from_hex(0x1d2430), Srgb::from_hex(0xff6b6b), Srgb::from_hex(0x70d6a8), Srgb::from_hex(0xf1c76a), Srgb::from_hex(0x68b5f8), Srgb::from_hex(0xc79bf2), Srgb::from_hex(0x63d5da), Srgb::from_hex(0xdbe5f4), Srgb::from_hex(0x6d7a91), Srgb::from_hex(0xff8b8b), Srgb::from_hex(0x93e6be), Srgb::from_hex(0xf8d98c), Srgb::from_hex(0x8bc8ff), Srgb::from_hex(0xd9b4fb), Srgb::from_hex(0x86e7eb), Srgb::from_hex(0xf4f8ff), ], }, overrides: RoleOverrides { chrome: Some(Srgb::from_hex(0x090c12)), surface: Some(Srgb::from_hex(0x101620)), surface_raised: Some(Srgb::from_hex(0x151d29)), surface_active: Some(Srgb::from_hex(0x182334)), border: Some(Srgb::from_hex(0x263246)), border_quiet: Some(Srgb::from_hex(0x1c2636)), accent: Some(Srgb::from_hex(0x68b5f8)), success: Some(Srgb::from_hex(0x70d6a8)), attention: Some(Srgb::from_hex(0xf1b96a)), }, }; /// The number of themes on offer: the first-party one plus the generated set. const CATALOG_LEN: usize = 1 + generated::catalog::CATALOG.len(); /// Assembles the catalog with the first-party theme in front. /// /// Written out rather than folded into the generated file so that the ordering /// rule lives where a reader looks for it. Decision 0018 requires the default /// theme to keep reproducing the appearance it replaced, and it is the only /// theme allowed to carry `RoleOverrides`; a generated file that knew about it /// could quietly stop putting it first. const fn build_catalog() -> [ThemeAnchors; CATALOG_LEN] { let mut catalog = [LUMBRIDGE_SLATE; CATALOG_LEN]; let mut index = 0; while index < generated::catalog::CATALOG.len() { catalog[index + 1] = generated::catalog::CATALOG[index]; index += 1; } catalog } const CATALOG_STORAGE: [ThemeAnchors; CATALOG_LEN] = build_catalog(); /// Every theme available, `lumbridge-slate` first. pub const CATALOG: &[ThemeAnchors] = &CATALOG_STORAGE; /// Looks a theme up by name, falling back to the default. /// /// A name that is no longer in the catalog — after a downgrade, or after a theme /// is withdrawn — resolves to the default rather than failing to start. #[must_use] pub fn theme_or_default(name: &str) -> &'static ThemeAnchors { CATALOG .iter() .find(|theme| theme.name == name) .unwrap_or(&LUMBRIDGE_SLATE) } #[cfg(test)] mod tests { use super::{ CATALOG, DEFAULT_THEME, LUMBRIDGE_SLATE, accent_or_default, contrast_ratio, derive, theme_or_default, }; use crate::palette::Provenance; fn slate_palette() -> crate::Palette { let accent = accent_or_default(crate::DEFAULT_ACCENT); derive(&LUMBRIDGE_SLATE, accent.resolve(true, LUMBRIDGE_SLATE.fg)) } /// A theme as the interface will paint it out of the box. fn catalog_palette(theme: &crate::ThemeAnchors) -> crate::Palette { let accent = accent_or_default(crate::DEFAULT_ACCENT); derive(theme, accent.resolve(theme.is_dark(), theme.fg)) } #[test] fn the_default_theme_reproduces_the_palette_it_replaced() { // The eleven constants that lived in main.rs. If the engine ever stops // reproducing them, the commit that introduced it stopped being a // no-op and this says so. let palette = slate_palette(); assert_eq!(palette.chrome.to_hex(), 0x090c12, "BG"); assert_eq!(palette.surface.to_hex(), 0x101620, "PANEL"); assert_eq!(palette.surface_raised.to_hex(), 0x151d29, "PANEL_ALT"); assert_eq!(palette.surface_active.to_hex(), 0x182334, "PANEL_ACTIVE"); assert_eq!(palette.border.to_hex(), 0x263246, "BORDER"); assert_eq!(palette.border_quiet.to_hex(), 0x1c2636, "BORDER_QUIET"); assert_eq!(palette.text.to_hex(), 0xdbe5f4, "TEXT"); assert_eq!(palette.muted.to_hex(), 0x8290a8, "MUTED"); assert_eq!(palette.accent.to_hex(), 0x68b5f8, "ACCENT"); assert_eq!(palette.attention.to_hex(), 0xf1b96a, "ATTENTION"); assert_eq!(palette.success.to_hex(), 0x70d6a8, "SUCCESS"); } /// How far the pure derivation sits from the hand-picked values. Not an /// assertion that they match — they do not, which is why the overrides /// exist — but a guard that the anchors are honest rather than arbitrary. #[test] fn the_derivation_lands_near_the_hand_picked_values() { let mut bare = LUMBRIDGE_SLATE; bare.overrides = crate::RoleOverrides::default(); let derived = derive(&bare, crate::Srgb::from_hex(0x68b5f8)); let overridden = slate_palette(); for (label, a, b) in [ ("chrome", derived.chrome, overridden.chrome), ( "surface_raised", derived.surface_raised, overridden.surface_raised, ), ("border", derived.border, overridden.border), ] { let distance = u32::from(a.r.abs_diff(b.r)) + u32::from(a.g.abs_diff(b.g)) + u32::from(a.b.abs_diff(b.b)); assert!( distance < 90, "{label}: derived {:06x} is far from {:06x} (distance {distance})", a.to_hex(), b.to_hex() ); } } /// The catalog is only usable if every theme in it is readable, so the gate /// is here rather than only in the generator: a theme added by hand, or a /// generator whose contrast rule regressed, has to fail somewhere the test /// suite runs. #[test] fn every_catalog_theme_keeps_body_text_readable_on_its_own_surface() { for theme in CATALOG { let palette = catalog_palette(theme); let text = contrast_ratio(palette.text, palette.surface); assert!( text >= 4.5, "{}: body text is {text:.2}:1 against its own surface, below WCAG AA; \ the generator should have held this theme back", theme.name ); } } /// Secondary text is the theme author's comment colour, and a comment is /// meant to recede, so a handful of themes put it below the 3:1 large-text /// floor. That is a fact about those themes rather than a defect, and the /// generated list is where it is written down — checked in both directions /// so it can neither grow quietly nor go stale. #[test] fn secondary_text_below_the_readable_floor_is_named_in_the_catalog() { let listed = crate::generated::catalog::SECONDARY_TEXT_BELOW_3_TO_1; for theme in CATALOG { let palette = catalog_palette(theme); let muted = contrast_ratio(palette.muted, palette.surface); let recorded = listed.iter().find(|(name, _)| *name == theme.name); match recorded { None => assert!( muted >= 3.0, "{}: secondary text is {muted:.2}:1 but the catalog does not say so; \ regenerate with `cargo run -p theme-gen`", theme.name ), Some((_, ratio)) => { assert!( muted < 3.0, "{}: listed as below 3:1 but measures {muted:.2}:1; the list is stale", theme.name ); assert!( (muted - ratio).abs() < 0.01, "{}: recorded {ratio:.3}:1 but measures {muted:.3}:1", theme.name ); } } } for (name, _) in listed { assert!( CATALOG.iter().any(|theme| theme.name == *name), "{name} is listed as low-contrast but is not in the catalog" ); } } /// Decision 0012 colours a usage reading by where it came from. A theme that /// collapsed two provenance colours would silently defeat that. #[test] fn provenance_colours_stay_distinguishable_in_every_theme() { for theme in CATALOG { let palette = catalog_palette(theme); let roles = palette.provenance_roles(); for (index, first) in roles.iter().enumerate() { for second in &roles[index + 1..] { assert_ne!( first, second, "{}: two provenance colours are identical, so a usage value's \ source would be unreadable", theme.name ); } } } } /// A terminal that paints two different escape codes the same colour has /// dropped information the running program encoded. The generator forces the /// eight base hues apart; this is the assertion that says it must. #[test] fn every_theme_keeps_its_eight_base_ansi_hues_apart() { for theme in CATALOG { let hues = theme.terminal.base_hues(); for (index, first) in hues.iter().enumerate() { for second in &hues[index + 1..] { assert_ne!( first, second, "{}: ANSI slots {index} and later share {:06x}", theme.name, first.to_hex() ); } } } } #[test] fn the_catalog_leads_with_the_first_party_theme_and_nothing_else_pins_a_role() { assert_eq!( CATALOG.first().map(|theme| theme.name), Some(DEFAULT_THEME), "decision 0018 puts the first-party theme first" ); for theme in &CATALOG[1..] { assert_eq!( theme.overrides, crate::RoleOverrides::NONE, "{}: a catalog theme that pins a role is defeating the engine", theme.name ); } } #[test] fn every_catalog_theme_has_an_identifier_no_other_theme_uses() { let mut seen: Vec<&str> = Vec::with_capacity(CATALOG.len()); for theme in CATALOG { assert!(!theme.name.is_empty(), "a theme has no identifier"); assert!( !theme.display_name.is_empty(), "{}: no display name to show in a picker", theme.name ); assert!( !seen.contains(&theme.name), "{} appears twice; theme_or_default would resolve to whichever came first", theme.name ); seen.push(theme.name); } } #[test] fn provenance_maps_to_a_role_and_cannot_see_a_value() { let palette = slate_palette(); assert_eq!(palette.provenance(Provenance::Provider), palette.success); assert_eq!(palette.provenance(Provenance::Harness), palette.accent); assert_eq!(palette.provenance(Provenance::Unavailable), palette.muted); } #[test] fn an_unknown_theme_name_self_heals_to_the_default() { assert_eq!(theme_or_default("no-such-theme").name, DEFAULT_THEME); assert_eq!(theme_or_default(DEFAULT_THEME).name, DEFAULT_THEME); } } /// The catalog and the derivation, checked against Buzz's original TypeScript. /// /// The vectors in [`generated::reference_vectors`] were produced by running that /// TypeScript under Node over the same theme files this catalog was generated /// from — not by re-deriving them, which decision 0018 records as having gone /// wrong once already. A failure here means the Rust port drifted from the /// implementation the catalog's colours were promised to match. #[cfg(test)] mod reference_tests { use crate::color::Srgb; use crate::derive::{RoleOverrides, ThemeAnchors, derive}; use crate::generated::catalog::{ANSI_SUBSTITUTIONS, COMMENT_SOURCES, SKIPPED_GIT_KEYS}; use crate::generated::reference_vectors::{REFERENCE_VECTORS, ReferenceVector}; use crate::palette::Palette; use crate::terminal::TerminalPalette; use crate::{CATALOG, DEFAULT_THEME}; fn palette_of(vector: &ReferenceVector) -> Palette { let anchors = ThemeAnchors { name: vector.name, display_name: vector.name, bg: vector.bg, fg: vector.fg, comment: vector.comment, added: vector.added, deleted: vector.deleted, modified: vector.modified, terminal: TerminalPalette { background: vector.bg, foreground: vector.fg, cursor: vector.fg, cursor_text: vector.bg, ansi: vector.ansi, }, overrides: RoleOverrides::NONE, }; derive(&anchors, Srgb::from_hex(0x3b82f6)) } /// Sixty-four real backgrounds through the luminance bisection, compared /// against the answers the reference gives for each one. #[test] fn the_derivation_reproduces_the_reference_for_every_vendored_theme() { assert!( REFERENCE_VECTORS.len() > 60, "the reference vectors look truncated: {} themes", REFERENCE_VECTORS.len() ); for vector in REFERENCE_VECTORS { let palette = palette_of(vector); for (role, ours, theirs) in [ ("chrome", palette.chrome, vector.chrome), ("surface", palette.surface, vector.surface), ( "surface_raised", palette.surface_raised, vector.surface_raised, ), ( "surface_active", palette.surface_active, vector.surface_active, ), ( "surface_overlay", palette.surface_overlay, vector.surface_overlay, ), ( "surface_between", palette.surface_between, vector.surface_between, ), ("border", palette.border, vector.border), ("border_quiet", palette.border_quiet, vector.border_quiet), ] { assert_eq!( ours.to_hex(), theirs.to_hex(), "{}: {role} derived {:06x}, reference says {:06x}", vector.name, ours.to_hex(), theirs.to_hex() ); } assert_eq!( palette.is_dark, vector.is_dark, "{}: light/dark classification disagrees", vector.name ); } } /// Every catalog theme's anchors, against what the reference extracted from /// the same file. The comment colour is the one field the two can disagree /// on, and only for the themes the catalog names and says why. #[test] fn the_extracted_anchors_reproduce_the_reference() { for theme in &CATALOG[1..] { let vector = reference_for(theme.name); assert_eq!( theme.bg.to_hex(), vector.bg.to_hex(), "{}: background disagrees with the reference", theme.name ); assert_eq!( theme.fg.to_hex(), vector.fg.to_hex(), "{}: foreground disagrees with the reference", theme.name ); match COMMENT_SOURCES.iter().find(|(name, _)| *name == theme.name) { None => assert_eq!( theme.comment.to_hex(), vector.comment.to_hex(), "{}: comment colour disagrees with the reference and is not \ recorded as one of the themes the reference cannot read", theme.name ), Some((_, why)) => { assert_eq!( vector.comment.to_hex(), vector.fg.to_hex(), "{}: recorded as unreadable by the reference ({why}), but the \ reference did read a colour for it", theme.name ); assert_ne!( theme.comment.to_hex(), theme.fg.to_hex(), "{}: recorded as recovered ({why}), yet secondary text is still \ the same colour as body text", theme.name ); } } } } /// The terminal palette, slot by slot, with the recorded collisions as the /// only permitted disagreements. #[test] fn the_terminal_palette_reproduces_the_reference_except_where_a_collision_was_recorded() { for theme in &CATALOG[1..] { let vector = reference_for(theme.name); for (index, ours) in theme.terminal.ansi.iter().enumerate() { let substituted = ANSI_SUBSTITUTIONS.contains(&(theme.name, index)); let theirs = vector.ansi[index]; if substituted { assert_ne!( ours.to_hex(), theirs.to_hex(), "{}: slot {index} is recorded as substituted but matches the \ reference, so the record is stale", theme.name ); } else { assert_eq!( ours.to_hex(), theirs.to_hex(), "{}: slot {index} is {:06x}, reference says {:06x}, and no \ substitution is recorded for it", theme.name, ours.to_hex(), theirs.to_hex() ); } } } } /// A substitution recorded for a theme that no longer ships would hide a /// real disagreement behind a name nobody checks. #[test] fn every_recorded_substitution_belongs_to_a_theme_that_ships() { for (name, slot) in ANSI_SUBSTITUTIONS { assert!( CATALOG.iter().any(|theme| theme.name == *name), "{name} has a recorded ANSI substitution for slot {slot} but is not in \ the catalog" ); assert!( *slot < 8 || ANSI_SUBSTITUTIONS.contains(&(*name, slot - 8)), "{name}: bright slot {slot} is recorded as substituted but its base is \ not, so nothing explains why it moved" ); } for (name, why) in COMMENT_SOURCES { assert!( CATALOG.iter().any(|theme| theme.name == *name), "{name} has a recorded comment source ({why}) but is not in the catalog" ); } for (name, role, key) in SKIPPED_GIT_KEYS { assert!( CATALOG.iter().any(|theme| theme.name == *name), "{name} records skipping `{key}` for {role} but is not in the catalog" ); } } /// The three git-derived role colours, against what the reference read from /// the same file. A role whose first key the catalog passed over is allowed /// to differ, and only that role. #[test] fn the_git_role_colours_reproduce_the_reference_except_where_a_key_was_skipped() { for theme in &CATALOG[1..] { let vector = reference_for(theme.name); for (role, ours, theirs) in [ ("added", theme.added, vector.added), ("deleted", theme.deleted, vector.deleted), ("modified", theme.modified, vector.modified), ] { if SKIPPED_GIT_KEYS .iter() .any(|(name, skipped, _)| *name == theme.name && *skipped == role) { assert_ne!( ours, theirs, "{}: {role} is recorded as having skipped a key but matches the \ reference, so the record is stale", theme.name ); continue; } assert_eq!( ours.map(Srgb::to_hex), theirs.map(Srgb::to_hex), "{}: {role} disagrees with the reference and no skipped key is \ recorded for it", theme.name ); } } } fn reference_for(name: &str) -> &'static ReferenceVector { REFERENCE_VECTORS .iter() .find(|vector| vector.name == name) .unwrap_or_else(|| { panic!( "{name} is in the catalog but has no reference vector; rerun \ tools/theme-gen/reference/emit-reference-vectors.mjs" ) }) } /// The first-party theme has no upstream file, so it has no vector either. #[test] fn the_first_party_theme_is_the_only_one_without_a_reference_vector() { assert_eq!(CATALOG[0].name, DEFAULT_THEME); for theme in &CATALOG[1..] { assert!( REFERENCE_VECTORS.iter().any(|v| v.name == theme.name), "{} has no reference vector", theme.name ); } } }