Files
lumbridge-code/crates/lumbridge-theme/src/derive.rs
T
Metal AgentandClaude Opus 5 1556b87f37 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>
2026-08-31 23:23:30 -07:00

276 lines
12 KiB
Rust

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