Derive the interface palette instead of hardcoding eleven colours

main.rs held eleven `const … : u32` colours, and spikes/floem-shell held a
byte-identical copy of the same eleven. Every one was a judgement call made once,
and no user could change any of them without recompiling.

lumbridge-theme takes a syntax theme's five anchors — background, foreground,
comment, and the git added/deleted/modified colours where the theme has them —
and derives the whole role set. The frame is the editor background pushed one
logarithmic contrast step away from the content, so the work surface is the
brightest thing on screen; a theme already at black lifts its surface instead of
sinking its frame, which is why a pitch-black theme still shows a seam.

Adapted from Buzz's adaptive-theme.ts (block/buzz, Apache-2.0) as a
specification, not as copied code. The golden vectors were taken by running the
original under Node — a research pass had supplied Python-derived vectors and
claimed they reproduced it byte-exactly, and they did not: Python rounds
half-to-even, JavaScript rounds half-up, they disagree on exactly one channel
value of 22.5, and that decides whether the luminance bisection converges a step
early. github-dark's chrome is #171a1d, not #191c20.

Provenance colours are separate roles from state colours, with a test holding
them pairwise distinct in every theme, because decision 0012 colours a usage
reading by where its number came from and never by how alarming it is.

This changed no pixels, and that was verified rather than asserted: the only
difference between before-and-after screenshots is the digits of a process ID.
The check earned its keep — the mechanical rename had rewritten three user-facing
strings, turning the sidebar's "ATTENTION · 0" into "theme.attention · 0" and
"+ ADD PANEL" into "+ ADD theme.surface". A literal-by-literal diff now confirms
zero strings changed.

The default theme pins its roles to the previous constants to make that true;
the anchors underneath are real, and a test bounds how far the pure derivation
sits from them. The terminal ANSI palette keeps its own table, so 29 colour
literals remain in main.rs, all terminal. The catalog, its attribution, and the
picker are separate work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Metal Agent
2026-08-31 23:23:30 -07:00
co-authored by Claude Opus 5
parent 2e349282c8
commit 1556b87f37
13 changed files with 1585 additions and 275 deletions
+195
View File
@@ -0,0 +1,195 @@
//! 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 palette;
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};
/// 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)),
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)),
},
};
/// Every theme available. One for now; the catalog lands separately.
pub const CATALOG: &[ThemeAnchors] = &[LUMBRIDGE_SLATE];
/// 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))
}
#[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()
);
}
}
#[test]
fn every_catalog_theme_keeps_text_readable_on_its_own_surface() {
for theme in CATALOG {
let accent = accent_or_default(crate::DEFAULT_ACCENT);
let palette = derive(theme, accent.resolve(theme.is_dark(), theme.fg));
let text = contrast_ratio(palette.text, palette.surface);
assert!(
text >= 4.5,
"{}: body text is {text:.2}:1 against its own surface",
theme.name
);
let muted = contrast_ratio(palette.muted, palette.surface);
assert!(
muted >= 3.0,
"{}: secondary text is {muted:.2}:1",
theme.name
);
}
}
/// 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 accent = accent_or_default(crate::DEFAULT_ACCENT);
let palette = derive(theme, accent.resolve(theme.is_dark(), theme.fg));
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",
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);
}
}