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
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "lumbridge-theme"
description = "Derives a full interface palette from a syntax theme's anchor colours"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[lints]
workspace = true
+119
View File
@@ -0,0 +1,119 @@
//! Selectable action accents.
//!
//! The accent is the one colour a user picks independently of the theme, so it
//! is stored by name rather than by index: reordering this table must not
//! repaint someone's interface.
use crate::color::Srgb;
/// One accent, in its light-theme and dark-theme forms.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Accent {
/// The persisted identifier. Never an index.
pub wire: &'static str,
pub display_name: &'static str,
pub light: Srgb,
pub dark: Srgb,
/// Neutral tracks the theme's own foreground on dark themes rather than
/// carrying a colour of its own.
pub follows_foreground_when_dark: bool,
}
impl Accent {
/// The accent colour for a given appearance.
#[must_use]
pub const fn resolve(&self, is_dark: bool, foreground: Srgb) -> Srgb {
if is_dark {
if self.follows_foreground_when_dark {
foreground
} else {
self.dark
}
} else {
self.light
}
}
}
macro_rules! accent {
($wire:literal, $name:literal, $light:literal, $dark:literal) => {
Accent {
wire: $wire,
display_name: $name,
light: Srgb::from_hex($light),
dark: Srgb::from_hex($dark),
follows_foreground_when_dark: false,
}
};
}
/// Ember first: it is Lumbridge's own, and `BRAND.md` scopes the brighter
/// `#FF6B35` to the application icon, so the interface uses the two variants
/// that hold contrast against a panel.
pub const ACCENTS: &[Accent] = &[
accent!("ember", "Ember", 0xd94824, 0xff8a5b),
accent!("blue", "Blue", 0x3b82f6, 0x60a5fa),
accent!("cyan", "Cyan", 0x06b6d4, 0x22d3ee),
accent!("green", "Green", 0x22c55e, 0x4ade80),
accent!("orange", "Orange", 0xf97316, 0xfb923c),
accent!("red", "Red", 0xef4444, 0xf87171),
accent!("pink", "Pink", 0xec4899, 0xf472b6),
accent!("lilac", "Lilac", 0xc0a2f1, 0xc0a2f1),
accent!("purple", "Purple", 0xa855f7, 0xc084fc),
accent!("indigo", "Indigo", 0x6366f1, 0x818cf8),
Accent {
wire: "neutral",
display_name: "Neutral",
light: Srgb::from_hex(0x000000),
dark: Srgb::from_hex(0xe1e4e8),
follows_foreground_when_dark: true,
},
];
/// `UX_VERTICAL_SLICE.md` commits the interface to one cool-blue action accent;
/// `BRAND.md` scopes Ember to the icon. So Ember ships selectable, not default.
/// See decision 0017.
pub const DEFAULT_ACCENT: &str = "blue";
/// Looks an accent up by its persisted name, falling back to the default.
#[must_use]
pub fn accent_or_default(wire: &str) -> &'static Accent {
ACCENTS
.iter()
.find(|accent| accent.wire == wire)
.or_else(|| ACCENTS.iter().find(|accent| accent.wire == DEFAULT_ACCENT))
.unwrap_or(&ACCENTS[0])
}
#[cfg(test)]
mod tests {
use super::{ACCENTS, DEFAULT_ACCENT, accent_or_default};
use crate::color::Srgb;
#[test]
fn accents_are_addressed_by_name_so_reordering_cannot_repaint_anyone() {
let mut seen = Vec::new();
for accent in ACCENTS {
assert!(!accent.wire.is_empty());
assert!(!seen.contains(&accent.wire), "duplicate {}", accent.wire);
seen.push(accent.wire);
}
assert!(seen.contains(&DEFAULT_ACCENT));
}
#[test]
fn an_unknown_accent_falls_back_rather_than_failing() {
assert_eq!(accent_or_default("no-such-accent").wire, DEFAULT_ACCENT);
assert_eq!(accent_or_default("ember").wire, "ember");
}
#[test]
fn neutral_follows_the_theme_foreground_on_dark_themes_only() {
let neutral = accent_or_default("neutral");
let fg = Srgb::from_hex(0xdbe5f4);
assert_eq!(neutral.resolve(true, fg), fg);
assert_eq!(neutral.resolve(false, fg), Srgb::from_hex(0x000000));
let blue = accent_or_default("blue");
assert_eq!(blue.resolve(true, fg), Srgb::from_hex(0x60a5fa));
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Colour primitives for the derivation engine.
//!
//! Everything here is pure arithmetic on 8-bit sRGB, in `f64`. The width
//! matters: the derivation runs a bisection over luminance, and the reference
//! implementation is JavaScript, where every number is an `f64`. Running it in
//! `f32` drifts the search and lands on neighbouring colours.
/// An opaque 8-bit sRGB colour.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct Srgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// The sRGB transfer-function knee, from the WCAG relative-luminance definition.
const TRANSFER_KNEE: f64 = 0.03928;
const RED_WEIGHT: f64 = 0.2126;
const GREEN_WEIGHT: f64 = 0.7152;
const BLUE_WEIGHT: f64 = 0.0722;
const CHANNEL_MAX: f64 = 255.0;
impl Srgb {
pub const BLACK: Self = Self::from_hex(0x000000);
pub const WHITE: Self = Self::from_hex(0xffffff);
#[must_use]
pub const fn from_hex(value: u32) -> Self {
// Each shift is masked to one byte before the conversion.
Self {
r: ((value >> 16) & 0xff) as u8,
g: ((value >> 8) & 0xff) as u8,
b: (value & 0xff) as u8,
}
}
#[must_use]
pub const fn to_hex(self) -> u32 {
((self.r as u32) << 16) | ((self.g as u32) << 8) | (self.b as u32)
}
/// Parses `#rgb`, `#rgba`, `#rrggbb`, or `#rrggbbaa`, with or without the
/// leading `#`.
///
/// Alpha is accepted and discarded. Theme files spell translucent washes
/// this way, and a palette role is a solid colour: keeping the alpha would
/// mean every consumer deciding what to composite it over.
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
let digits = text.trim().strip_prefix('#').unwrap_or(text.trim());
if !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let expand = |slice: &str| u8::from_str_radix(slice, 16).ok();
match digits.len() {
3 | 4 => {
let mut bytes = digits.bytes().take(3).map(|byte| {
let digit = char::from(byte).to_digit(16)?;
#[expect(
clippy::cast_possible_truncation,
reason = "a hex digit repeated is at most 0xff"
)]
Some((digit * 17) as u8)
});
Some(Self {
r: bytes.next()??,
g: bytes.next()??,
b: bytes.next()??,
})
}
6 | 8 => Some(Self {
r: expand(digits.get(0..2)?)?,
g: expand(digits.get(2..4)?)?,
b: expand(digits.get(4..6)?)?,
}),
_ => None,
}
}
}
/// WCAG relative luminance.
#[must_use]
pub fn relative_luminance(color: Srgb) -> f64 {
fn channel(value: u8) -> f64 {
let normalized = f64::from(value) / CHANNEL_MAX;
if normalized <= TRANSFER_KNEE {
normalized / 12.92
} else {
((normalized + 0.055) / 1.055).powf(2.4)
}
}
RED_WEIGHT * channel(color.r) + GREEN_WEIGHT * channel(color.g) + BLUE_WEIGHT * channel(color.b)
}
/// Linear interpolation, quantised to 8 bits on every call.
///
/// The rounding is load-bearing, not incidental. The bisection below searches
/// over the space this function can actually produce, so mixing in full
/// precision and rounding once at the end lands on different colours than the
/// reference implementation, which rounds every step.
#[must_use]
pub fn mix(from: Srgb, to: Srgb, factor: f64) -> Srgb {
fn blend(from: u8, to: u8, factor: f64) -> u8 {
let value = f64::from(from) + (f64::from(to) - f64::from(from)) * factor;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "clamped into 0..=255 before conversion"
)]
{
value.round().clamp(0.0, CHANNEL_MAX) as u8
}
}
Srgb {
r: blend(from.r, to.r, factor),
g: blend(from.g, to.g, factor),
b: blend(from.b, to.b, factor),
}
}
/// Lightens by `amount` when positive, darkens when negative.
#[must_use]
pub fn adjust(color: Srgb, amount: f64) -> Srgb {
let target = if amount > 0.0 {
Srgb::WHITE
} else {
Srgb::BLACK
};
mix(color, target, amount.abs())
}
/// The number of bisection steps. Twenty over a quantised 8-bit ramp is far
/// past convergence; it is kept because the reference uses it and the loop is
/// the definition rather than an approximation of one.
const BISECTION_STEPS: usize = 20;
const LUMINANCE_EPSILON: f64 = 0.001;
/// Finds the mix of `base` toward black or white whose luminance is `target`.
#[must_use]
pub fn find_color_with_luminance(base: Srgb, target: f64) -> Srgb {
let base_luminance = relative_luminance(base);
if (base_luminance - target).abs() < LUMINANCE_EPSILON {
return base;
}
let toward_black = target < base_luminance;
let endpoint = if toward_black {
Srgb::BLACK
} else {
Srgb::WHITE
};
let (mut low, mut high) = (0.0_f64, 1.0_f64);
for _ in 0..BISECTION_STEPS {
let middle = f64::midpoint(low, high);
let luminance = relative_luminance(mix(base, endpoint, middle));
if (luminance - target).abs() < LUMINANCE_EPSILON {
break;
}
// Mixing further toward the endpoint moves luminance monotonically, so
// which half to keep depends on which endpoint we are heading for.
if toward_black {
if luminance > target {
low = middle;
} else {
high = middle;
}
} else if luminance < target {
low = middle;
} else {
high = middle;
}
}
mix(base, endpoint, f64::midpoint(low, high))
}
/// The WCAG contrast ratio between two colours, always at least 1.0.
#[must_use]
pub fn contrast_ratio(a: Srgb, b: Srgb) -> f64 {
let (first, second) = (relative_luminance(a), relative_luminance(b));
let (lighter, darker) = if first >= second {
(first, second)
} else {
(second, first)
};
(lighter + 0.05) / (darker + 0.05)
}
/// Black or white, whichever is more readable on `background`.
///
/// Ties go to black, which matches the reference and matters for mid-tone
/// accents: `#3b82f6` sits almost exactly on the boundary.
#[must_use]
pub fn contrast_foreground(background: Srgb) -> Srgb {
if contrast_ratio(background, Srgb::BLACK) >= contrast_ratio(background, Srgb::WHITE) {
Srgb::BLACK
} else {
Srgb::WHITE
}
}
#[cfg(test)]
mod tests {
use super::{
Srgb, adjust, contrast_foreground, contrast_ratio, find_color_with_luminance, mix,
relative_luminance,
};
#[test]
fn hex_round_trips_and_parses_every_documented_form() {
assert_eq!(Srgb::from_hex(0x24292e).to_hex(), 0x24292e);
assert_eq!(Srgb::parse("#24292e"), Some(Srgb::from_hex(0x24292e)));
assert_eq!(Srgb::parse("24292e"), Some(Srgb::from_hex(0x24292e)));
assert_eq!(Srgb::parse("#abc"), Some(Srgb::from_hex(0xaabbcc)));
assert_eq!(
Srgb::parse("#24292eff"),
Some(Srgb::from_hex(0x24292e)),
"alpha is discarded, not composited"
);
assert_eq!(Srgb::parse("#abcd"), Some(Srgb::from_hex(0xaabbcc)));
for bad in ["", "#", "nope", "#12345", "#zzzzzz"] {
assert_eq!(Srgb::parse(bad), None, "{bad:?} must not parse");
}
}
#[test]
fn luminance_matches_the_wcag_endpoints() {
assert!((relative_luminance(Srgb::BLACK) - 0.0).abs() < 1e-9);
assert!((relative_luminance(Srgb::WHITE) - 1.0).abs() < 1e-9);
assert!(relative_luminance(Srgb::from_hex(0x808080)) < 0.25);
}
#[test]
fn mixing_quantises_at_every_step() {
// 0.5 of the way from 0 to 1 rounds to 1, not to 0. Mixing in full
// precision and rounding once at the end would produce a different
// sequence through the bisection.
assert_eq!(
mix(Srgb::from_hex(0x000000), Srgb::from_hex(0x010101), 0.5),
Srgb::from_hex(0x010101)
);
assert_eq!(mix(Srgb::BLACK, Srgb::WHITE, 0.0), Srgb::BLACK);
assert_eq!(mix(Srgb::BLACK, Srgb::WHITE, 1.0), Srgb::WHITE);
}
#[test]
fn adjust_moves_toward_white_or_black() {
let base = Srgb::from_hex(0x808080);
assert!(relative_luminance(adjust(base, 0.2)) > relative_luminance(base));
assert!(relative_luminance(adjust(base, -0.2)) < relative_luminance(base));
assert_eq!(adjust(base, 0.0), base);
}
#[test]
fn the_bisection_hits_its_target_luminance() {
for hex in [0x24292e, 0xffffff, 0x1e1e2e, 0x101010] {
let base = Srgb::from_hex(hex);
for target in [0.0, 0.02, 0.15, 0.5] {
let found = find_color_with_luminance(base, target);
let error = (relative_luminance(found) - target).abs();
assert!(
error < 0.01,
"{hex:06x} toward {target}: landed {error} away"
);
}
}
}
#[test]
fn contrast_foreground_breaks_its_tie_toward_black() {
// #3b82f6 is close enough to the boundary that the tie-break decides it.
assert_eq!(contrast_foreground(Srgb::from_hex(0x3b82f6)), Srgb::BLACK);
assert_eq!(contrast_foreground(Srgb::from_hex(0x000000)), Srgb::WHITE);
assert_eq!(contrast_foreground(Srgb::from_hex(0xffffff)), Srgb::BLACK);
}
#[test]
fn contrast_ratio_is_symmetric_and_bounded() {
let a = Srgb::from_hex(0x24292e);
let b = Srgb::from_hex(0xdbe5f4);
assert!((contrast_ratio(a, b) - contrast_ratio(b, a)).abs() < 1e-12);
assert!((contrast_ratio(a, a) - 1.0).abs() < 1e-12);
assert!((contrast_ratio(Srgb::BLACK, Srgb::WHITE) - 21.0).abs() < 0.01);
}
}
+275
View File
@@ -0,0 +1,275 @@
//! The adaptive derivation: five anchor colours in, a full palette out.
//!
//! Adapted from Buzz's `adaptive-theme.ts` (block/buzz, Apache-2.0,
//! Copyright 2026 Block, Inc.), which is itself a port of an earlier
//! `builderbot` original. No implementation code was copied; the algorithm was
//! read as a specification and reimplemented, and the golden vectors in the
//! tests are what hold the two together. See decision 0018.
//!
//! The idea is that a syntax theme already answers the hard question — what
//! background, foreground, and comment colour go together — and everything the
//! interface needs can be derived from that answer rather than invented beside
//! it. The chrome is the editor background pushed one contrast step *away* from
//! the content, so the frame recedes and the work surface is the brightest
//! thing on screen.
use crate::color::{
Srgb, adjust, contrast_foreground, find_color_with_luminance, mix, relative_luminance,
};
use crate::palette::Palette;
/// The luminance step between the chrome and the work surface.
///
/// Logarithmic rather than fixed: a step that reads clearly against a
/// near-black background is invisible against a light one, and vice versa.
const CONTRAST_VALUE: f64 = 0.035;
const CONTRAST_OFFSET: f64 = 0.0135;
/// Below this the theme is treated as dark, and elevation lightens rather than
/// darkens. Buzz computes this rather than keeping a list of light theme names.
const DARK_THRESHOLD: f64 = 0.5;
/// The colours a syntax theme supplies, plus what Lumbridge adds.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ThemeAnchors {
pub name: &'static str,
pub display_name: &'static str,
/// `editor.background`.
pub bg: Srgb,
/// `editor.foreground`.
pub fg: Srgb,
/// The comment token's foreground. Every interface's secondary text.
pub comment: Srgb,
/// `gitDecoration.addedResourceForeground`, when the theme has one.
pub added: Option<Srgb>,
pub deleted: Option<Srgb>,
pub modified: Option<Srgb>,
/// Exact values for roles that would otherwise be derived.
pub overrides: RoleOverrides,
}
/// Per-role escapes from the derivation.
///
/// Only the first-party theme uses these, and only so that introducing the
/// engine is a zero-pixel change: the point of that commit is that the diff
/// reads as "colours now come from a palette", with no appearance change hidden
/// inside it. A catalog theme setting these would be defeating the engine, so
/// the field exists but the generated catalog leaves it empty. See decision 0018.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct RoleOverrides {
pub chrome: Option<Srgb>,
pub surface: Option<Srgb>,
pub surface_raised: Option<Srgb>,
pub surface_active: Option<Srgb>,
pub border: Option<Srgb>,
pub border_quiet: Option<Srgb>,
pub accent: Option<Srgb>,
pub success: Option<Srgb>,
pub attention: Option<Srgb>,
}
impl ThemeAnchors {
#[must_use]
pub fn is_dark(&self) -> bool {
relative_luminance(self.bg) < DARK_THRESHOLD
}
}
/// How far apart the chrome and the work surface should sit, at this background.
fn luminance_step(background_luminance: f64) -> f64 {
CONTRAST_VALUE * (1.0 + (background_luminance + CONTRAST_OFFSET) * 10.0).ln()
}
/// Splits a syntax background into a frame colour and a work-surface colour.
///
/// Normally the frame darkens and the surface keeps the theme's own background.
/// When the background is already at or near black there is no room below it, so
/// the frame pins to black and the *surface* lifts instead — which is why a
/// pitch-black theme still shows a visible seam between panel and frame.
fn chrome_and_surface(background: Srgb) -> (Srgb, Srgb) {
let luminance = relative_luminance(background);
let step = luminance_step(luminance);
let target = luminance - step;
if target >= 0.0 {
(find_color_with_luminance(background, target), background)
} else {
(
find_color_with_luminance(background, 0.0),
find_color_with_luminance(background, step),
)
}
}
/// Derives the full role set.
///
/// Pure: the same anchors and accent always produce the same palette, which is
/// what lets the whole catalog be snapshot-tested.
#[must_use]
pub fn derive(anchors: &ThemeAnchors, accent: Srgb) -> Palette {
let (derived_chrome, derived_surface) = chrome_and_surface(anchors.bg);
let overrides = anchors.overrides;
let chrome = overrides.chrome.unwrap_or(derived_chrome);
let surface = overrides.surface.unwrap_or(derived_surface);
let is_dark = anchors.is_dark();
// Elevation lifts on a dark theme and sinks on a light one, so "raised"
// means the same thing to the eye either way.
let direction = if is_dark { 1.0 } else { -1.0 };
let elevate = |amount: f64| adjust(surface, direction * amount);
let border = overrides
.border
.unwrap_or_else(|| mix(surface, anchors.fg, if is_dark { 0.15 } else { 0.12 }));
let accent = overrides.accent.unwrap_or(accent);
let success = overrides.success.unwrap_or_else(|| {
anchors
.added
.unwrap_or_else(|| Srgb::from_hex(if is_dark { 0x3fb950 } else { 0x1a7f37 }))
});
let danger = anchors
.deleted
.unwrap_or_else(|| Srgb::from_hex(if is_dark { 0xf85149 } else { 0xcf222e }));
let attention = overrides.attention.unwrap_or_else(|| {
anchors
.modified
.unwrap_or_else(|| Srgb::from_hex(if is_dark { 0xd29922 } else { 0x9a6700 }))
});
Palette {
chrome,
surface,
surface_raised: overrides.surface_raised.unwrap_or_else(|| elevate(0.04)),
surface_active: overrides.surface_active.unwrap_or_else(|| elevate(0.06)),
surface_overlay: elevate(0.08),
surface_between: mix(chrome, surface, 0.5),
border,
border_quiet: overrides
.border_quiet
.unwrap_or_else(|| mix(surface, border, 0.5)),
text: anchors.fg,
muted: anchors.comment,
accent,
on_accent: contrast_foreground(accent),
success,
danger,
danger_container: mix(surface, danger, 0.15),
attention,
// A wash rather than a border so it can accompany a glyph instead of
// replacing one: UX_VERTICAL_SLICE forbids colour as the only signal.
attention_wash: mix(surface, attention, if is_dark { 0.10 } else { 0.08 }),
inverse_surface: anchors.fg,
on_inverse: surface,
scrim: Srgb::BLACK,
is_dark,
}
}
#[cfg(test)]
mod tests {
use super::{RoleOverrides, ThemeAnchors, chrome_and_surface, derive};
use crate::color::Srgb;
fn anchors(bg: u32, fg: u32, comment: u32) -> ThemeAnchors {
ThemeAnchors {
name: "test",
display_name: "Test",
bg: Srgb::from_hex(bg),
fg: Srgb::from_hex(fg),
comment: Srgb::from_hex(comment),
added: None,
deleted: None,
modified: None,
overrides: RoleOverrides::default(),
}
}
/// The vectors the reference implementation produces.
///
/// Taken by running `desktop/src/shared/theme/adaptive-theme.ts` from the
/// pinned Buzz checkout under Node, not by re-deriving them by hand. That
/// distinction cost an afternoon: a re-implementation in Python reports
/// `#191c20` for github-dark's chrome, because Python's `round` is
/// banker's rounding and JavaScript's `Math.round` is half-up. The two
/// disagree on exactly one channel — 22.5 — and that one channel decides
/// whether the bisection's convergence test trips a step early. Anything
/// claiming to reproduce these must run the original, not a port of it.
#[test]
fn github_dark_reproduces_the_reference_vector() {
let theme = anchors(0x24292e, 0xe1e4e8, 0x6a737d);
let (chrome, surface) = chrome_and_surface(theme.bg);
assert_eq!(chrome.to_hex(), 0x171a1d, "chrome");
assert_eq!(surface.to_hex(), 0x24292e, "surface keeps the theme bg");
let palette = derive(&theme, Srgb::from_hex(0x3b82f6));
assert_eq!(palette.border.to_hex(), 0x40454a, "border");
assert_eq!(palette.surface_raised.to_hex(), 0x2d3236, "elevate 0.04");
assert_eq!(palette.surface_active.to_hex(), 0x31363b, "elevate 0.06");
assert_eq!(palette.surface_overlay.to_hex(), 0x363a3f, "elevate 0.08");
assert_eq!(palette.surface_between.to_hex(), 0x1e2226, "between");
assert_eq!(palette.border_quiet.to_hex(), 0x32373c, "border_quiet");
assert!(palette.is_dark);
}
#[test]
fn github_light_derives_downward() {
let theme = anchors(0xffffff, 0x24292e, 0x6a737d);
let (chrome, surface) = chrome_and_surface(theme.bg);
assert_eq!(chrome.to_hex(), 0xf6f6f6, "chrome");
assert_eq!(surface.to_hex(), 0xffffff);
let palette = derive(&theme, Srgb::from_hex(0x3b82f6));
assert_eq!(palette.border.to_hex(), 0xe5e5e6, "border");
assert!(!palette.is_dark);
assert!(
palette.surface_raised.to_hex() < 0xffffff,
"elevation sinks on a light theme"
);
}
#[test]
fn catppuccin_mocha_reproduces_the_reference_vector() {
let theme = anchors(0x1e1e2e, 0xcdd6f4, 0x6c7086);
let (chrome, _) = chrome_and_surface(theme.bg);
assert_eq!(chrome.to_hex(), 0x0f0f17, "chrome");
let palette = derive(&theme, Srgb::from_hex(0x3b82f6));
assert_eq!(palette.border.to_hex(), 0x383a4c, "border");
assert_eq!(palette.surface_active.to_hex(), 0x2c2c3b, "hover");
assert_eq!(palette.surface_overlay.to_hex(), 0x30303f, "popover");
}
/// The branch that only a near-black theme reaches: there is no room below
/// the background, so the surface lifts instead of the chrome sinking.
#[test]
fn a_pitch_black_theme_lifts_the_surface_instead() {
let theme = anchors(0x000000, 0xdbd7ca, 0x758575);
let (chrome, surface) = chrome_and_surface(theme.bg);
assert_eq!(chrome.to_hex(), 0x000000, "chrome pins to black");
assert_eq!(surface.to_hex(), 0x101010, "the surface lifts");
assert_ne!(chrome, surface, "the seam has to stay visible");
let palette = derive(&theme, Srgb::from_hex(0x3b82f6));
assert_eq!(palette.border.to_hex(), 0x2e2e2c, "border");
}
#[test]
fn overrides_replace_a_derived_role_and_nothing_else() {
let mut theme = anchors(0x24292e, 0xe1e4e8, 0x6a737d);
theme.overrides.chrome = Some(Srgb::from_hex(0x090c12));
let palette = derive(&theme, Srgb::from_hex(0x3b82f6));
assert_eq!(palette.chrome.to_hex(), 0x090c12);
assert_eq!(
palette.border.to_hex(),
0x40454a,
"an override must not disturb the roles it does not name"
);
}
#[test]
fn git_colours_supply_state_roles_when_the_theme_has_them() {
let mut theme = anchors(0x24292e, 0xe1e4e8, 0x6a737d);
let derived = derive(&theme, Srgb::from_hex(0x3b82f6));
assert_eq!(derived.success.to_hex(), 0x3fb950, "dark default");
theme.added = Some(Srgb::from_hex(0x00ff00));
theme.deleted = Some(Srgb::from_hex(0xff0000));
theme.modified = Some(Srgb::from_hex(0x0000ff));
let palette = derive(&theme, Srgb::from_hex(0x3b82f6));
assert_eq!(palette.success.to_hex(), 0x00ff00);
assert_eq!(palette.danger.to_hex(), 0xff0000);
assert_eq!(palette.attention.to_hex(), 0x0000ff);
}
}
+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);
}
}
+100
View File
@@ -0,0 +1,100 @@
//! The role set every surface paints from.
//!
//! Roles are named for what they do, not for a container ladder. `surface_active`
//! is the selected panel and the hover fill; it is not "surface container
//! highest", because nothing in Lumbridge has to reason about how many
//! containers deep it is.
//!
//! Two rules the rest of the application depends on:
//!
//! - **Provenance roles are distinct from state roles.** Decision 0012 says a
//! usage value is coloured by where it came from, never by how alarming it is.
//! They are separate fields here so a theme cannot quietly collapse the two,
//! and a test asserts the five stay pairwise distinguishable.
//! - **Nothing is derived lazily.** A `Palette` is computed once when the theme
//! changes and then only read, so a render pass never runs the bisection.
use crate::color::Srgb;
/// Every colour the interface is allowed to use.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Palette {
/// The application frame: window background, sidebar, footer, tab gutter.
pub chrome: Srgb,
/// The work surface: panel bodies, terminals, editors.
pub surface: Srgb,
/// One step off the surface: unselected tabs, inline cards.
pub surface_raised: Srgb,
/// Two steps: the selected panel, hover fills, chip backgrounds.
pub surface_active: Srgb,
/// Floating above everything: the command palette, popovers, menus.
pub surface_overlay: Srgb,
/// The seam where the frame meets the content.
pub surface_between: Srgb,
pub border: Srgb,
/// A divider that should be felt rather than seen.
pub border_quiet: Srgb,
pub text: Srgb,
/// Secondary text. The theme's own comment colour, so it is legible against
/// the surface by construction rather than by our guess.
pub muted: Srgb,
/// Focus rings, selection, the active tab underline.
pub accent: Srgb,
/// Black or white, whichever reads on `accent`.
pub on_accent: Srgb,
pub success: Srgb,
pub danger: Srgb,
/// A danger fill quiet enough to sit behind text.
pub danger_container: Srgb,
/// Needs a human. Paired with a glyph, never used alone.
pub attention: Srgb,
pub attention_wash: Srgb,
/// Tooltips and inverted chips.
pub inverse_surface: Srgb,
pub on_inverse: Srgb,
pub scrim: Srgb,
pub is_dark: bool,
}
impl Palette {
/// The colour a usage reading is drawn in, by where the number came from.
///
/// Decision 0012's rule made explicit: the argument is a provenance, and
/// there is no way to pass a value in, so this cannot accidentally become
/// "red when low".
#[must_use]
pub const fn provenance(&self, provenance: Provenance) -> Srgb {
match provenance {
Provenance::Provider => self.success,
Provenance::Harness => self.accent,
Provenance::Local => self.text,
Provenance::Estimated => self.attention,
Provenance::Unavailable => self.muted,
}
}
/// The five provenance colours, for the distinctness test.
#[must_use]
pub const fn provenance_roles(&self) -> [Srgb; 5] {
[
self.success,
self.accent,
self.text,
self.attention,
self.muted,
]
}
}
/// Where a usage number came from.
///
/// A structural mirror of `lumbridge_core::UsageProvenance`, kept here so this
/// crate stays free of a dependency on the ledger. The UI maps between them.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Provenance {
Provider,
Harness,
Local,
Estimated,
Unavailable,
}