Files
lumbridge-code/crates/lumbridge-theme/src/lib.rs
T
Metal AgentandClaude Opus 5 401760d670 Generate the theme catalog from real themes instead of a hand table
The derivation landed in decision 0018 with the catalog and the terminal ANSI
palette still fixed tables written by hand. tools/theme-gen reads the
TextMate themes under assets/themes/ and emits the catalog and the reference
vectors, so the anchors a palette is derived from are the ones the theme
actually ships rather than the ones somebody transcribed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPYebLiN2w4TqnHUYGdECq
2026-09-01 12:51:25 -07:00

616 lines
24 KiB
Rust

//! 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
);
}
}
}