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
Generated
+5
View File
@@ -3324,6 +3324,7 @@ dependencies = [
"lumbridge-runtime", "lumbridge-runtime",
"lumbridge-storage", "lumbridge-storage",
"lumbridge-terminal", "lumbridge-terminal",
"lumbridge-theme",
"lumbridge-ui-fixture", "lumbridge-ui-fixture",
"serde", "serde",
"serde_json", "serde_json",
@@ -3392,6 +3393,10 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
] ]
[[package]]
name = "lumbridge-theme"
version = "0.0.1"
[[package]] [[package]]
name = "lumbridge-ui-fixture" name = "lumbridge-ui-fixture"
version = "0.0.1" version = "0.0.1"
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/lumbridge-runtime", "crates/lumbridge-runtime",
"crates/lumbridge-storage", "crates/lumbridge-storage",
"crates/lumbridge-terminal", "crates/lumbridge-terminal",
"crates/lumbridge-theme",
"crates/lumbridge-ui-fixture", "crates/lumbridge-ui-fixture",
] ]
exclude = ["spikes"] exclude = ["spikes"]
+1
View File
@@ -14,6 +14,7 @@ lumbridge-harness = { path = "../../crates/lumbridge-harness" }
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
lumbridge-storage = { path = "../../crates/lumbridge-storage" } lumbridge-storage = { path = "../../crates/lumbridge-storage" }
lumbridge-terminal = { path = "../../crates/lumbridge-terminal" } lumbridge-terminal = { path = "../../crates/lumbridge-terminal" }
lumbridge-theme = { path = "../../crates/lumbridge-theme" }
lumbridge-ui-fixture = { path = "../../crates/lumbridge-ui-fixture" } lumbridge-ui-fixture = { path = "../../crates/lumbridge-ui-fixture" }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
+315 -274
View File
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
//! The bridge between the derivation engine and GPUI.
//!
//! `lumbridge-theme` knows nothing about a renderer. This converts a derived
//! [`Palette`] into GPUI's colour type once, when the theme changes, and the
//! shell owns the result. GPUI has no inherited-context mechanism, so a palette
//! either travels as an argument or lives in one owner; it lives in the shell,
//! and `ThemeColors` is `Copy` so passing it costs nothing.
//!
//! Because the catalog is compiled in, the first frame paints the right theme.
//! There is no cache to warm, nothing to load asynchronously, and no flash of
//! the wrong colours on startup.
use gpui::Rgba;
use lumbridge_core::UsageProvenance;
use lumbridge_theme::{Palette, Provenance, Srgb, ThemeAnchors, accent_or_default, derive};
/// A [`Palette`] in the renderer's colour type.
///
/// Every field is the same role by the same name. `Rgba` coerces into GPUI's
/// `Fill` and `Hsla`, so one of these can be handed straight to `.bg()`,
/// `.text_color()`, or `.border_color()`.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ThemeColors {
pub(crate) chrome: Rgba,
pub(crate) surface: Rgba,
pub(crate) surface_raised: Rgba,
pub(crate) surface_active: Rgba,
/// Not yet painted anywhere. The command palette and the panel chooser
/// still sit on `surface_raised`; moving them is a visible change, and the
/// commit that introduced this engine is deliberately not one.
#[expect(dead_code, reason = "the overlay surfaces move onto it separately")]
pub(crate) surface_overlay: Rgba,
pub(crate) border: Rgba,
pub(crate) border_quiet: Rgba,
pub(crate) text: Rgba,
pub(crate) muted: Rgba,
pub(crate) accent: Rgba,
pub(crate) success: Rgba,
/// No surface reports failure in colour yet; a dead pane is described in
/// words. Defined here so the role exists when one does.
#[expect(dead_code, reason = "no failure surface paints yet")]
pub(crate) danger: Rgba,
pub(crate) attention: Rgba,
/// The needs-input row tint, which arrives with the sidebar rework.
#[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")]
pub(crate) attention_wash: Rgba,
}
/// Widens an 8-bit channel into the 0..=1 float GPUI wants.
const fn channel(value: u8) -> f32 {
value as f32 / 255.0
}
fn rgba(color: Srgb) -> Rgba {
Rgba {
r: channel(color.r),
g: channel(color.g),
b: channel(color.b),
a: 1.0,
}
}
impl From<&Palette> for ThemeColors {
fn from(palette: &Palette) -> Self {
Self {
chrome: rgba(palette.chrome),
surface: rgba(palette.surface),
surface_raised: rgba(palette.surface_raised),
surface_active: rgba(palette.surface_active),
surface_overlay: rgba(palette.surface_overlay),
border: rgba(palette.border),
border_quiet: rgba(palette.border_quiet),
text: rgba(palette.text),
muted: rgba(palette.muted),
accent: rgba(palette.accent),
success: rgba(palette.success),
danger: rgba(palette.danger),
attention: rgba(palette.attention),
attention_wash: rgba(palette.attention_wash),
}
}
}
/// The theme every surface reads from.
///
/// Owned by the shell rather than installed as a GPUI global: `&self` render
/// methods far outnumber the ones holding an `App`, and one owner is easier to
/// reason about than a global plus a cache of it. Switching themes replaces
/// this value and asks for a redraw.
pub(crate) struct ActiveTheme {
/// Both are read by the theme picker, which is not built yet.
#[allow(dead_code, reason = "read by the theme picker")]
pub(crate) name: &'static str,
#[allow(dead_code, reason = "read by the theme picker")]
pub(crate) is_dark: bool,
pub(crate) colors: ThemeColors,
palette: Palette,
}
impl ActiveTheme {
pub(crate) fn new(anchors: &ThemeAnchors, accent_wire: &str) -> Self {
let accent = accent_or_default(accent_wire);
let palette = derive(anchors, accent.resolve(anchors.is_dark(), anchors.fg));
Self {
name: anchors.name,
is_dark: palette.is_dark,
colors: ThemeColors::from(&palette),
palette,
}
}
/// The colour a usage reading is drawn in, by where its number came from.
///
/// Decision 0012's rule, enforced by the signature: a provenance goes in
/// and there is no way to pass a value, so this cannot drift into
/// "red when nearly exhausted".
pub(crate) fn provenance(&self, provenance: UsageProvenance) -> Rgba {
rgba(self.palette.provenance(match provenance {
UsageProvenance::ProviderReported => Provenance::Provider,
UsageProvenance::HarnessReported => Provenance::Harness,
UsageProvenance::LocallyMeasured => Provenance::Local,
UsageProvenance::Estimated => Provenance::Estimated,
UsageProvenance::Unavailable => Provenance::Unavailable,
}))
}
}
#[cfg(test)]
mod tests {
use super::{ActiveTheme, channel};
use lumbridge_core::UsageProvenance;
use lumbridge_theme::LUMBRIDGE_SLATE;
#[test]
fn the_default_theme_converts_to_the_colours_the_interface_used_before() {
let theme = ActiveTheme::new(&LUMBRIDGE_SLATE, "blue");
assert!(theme.is_dark);
// 0x090c12 was the BG constant.
assert!((theme.colors.chrome.r - channel(0x09)).abs() < f32::EPSILON);
assert!((theme.colors.chrome.g - channel(0x0c)).abs() < f32::EPSILON);
assert!((theme.colors.chrome.b - channel(0x12)).abs() < f32::EPSILON);
assert!((theme.colors.chrome.a - 1.0).abs() < f32::EPSILON);
}
#[test]
fn provenance_colours_stay_distinct_after_conversion() {
let theme = ActiveTheme::new(&LUMBRIDGE_SLATE, "blue");
// Compared as bit patterns: these are exact conversions of distinct
// 8-bit values, so an equality test is the right question here and
// clippy's float-comparison warning does not apply.
let colors: Vec<[u32; 3]> = [
UsageProvenance::ProviderReported,
UsageProvenance::HarnessReported,
UsageProvenance::LocallyMeasured,
UsageProvenance::Estimated,
UsageProvenance::Unavailable,
]
.into_iter()
.map(|provenance| {
let color = theme.provenance(provenance);
[color.r.to_bits(), color.g.to_bits(), color.b.to_bits()]
})
.collect();
for (index, first) in colors.iter().enumerate() {
for second in &colors[index + 1..] {
assert_ne!(first, second, "two provenance colours collapsed");
}
}
}
}
+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,
}
+19 -1
View File
@@ -27,6 +27,7 @@ messages should be real from the beginning.
- `lumbridge-runtime`: session ownership, supervision, recovery, IPC server. - `lumbridge-runtime`: session ownership, supervision, recovery, IPC server.
- `lumbridge-pty`: portable PTY and process-tree adapters. - `lumbridge-pty`: portable PTY and process-tree adapters.
- `lumbridge-terminal`: VT parsing, scrollback, selection, search, render model. - `lumbridge-terminal`: VT parsing, scrollback, selection, search, render model.
- `lumbridge-theme`: derives the interface palette from a syntax theme's anchors.
- `lumbridge-acp`: ACP client, capability negotiation, transcript normalization. - `lumbridge-acp`: ACP client, capability negotiation, transcript normalization.
- `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters, - `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters,
and documented status/usage probes. and documented status/usage probes.
@@ -43,7 +44,7 @@ messages should be real from the beginning.
The scaffold currently contains `lumbridge-core`, `lumbridge-storage`, The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`, `lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`,
`lumbridge-harness`, `lumbridge-ui-fixture`, and the `lumbridge` application `lumbridge-harness`, `lumbridge-theme`, `lumbridge-ui-fixture`, and the `lumbridge` application
itself. The GPUI shell was a spike in an excluded workspace until it graduated itself. The GPUI shell was a spike in an excluded workspace until it graduated
into `apps/lumbridge`; `scripts/ci.sh` now takes `--headless` and `--ui` so the into `apps/lumbridge`; `scripts/ci.sh` now takes `--headless` and `--ui` so the
non-UI crates still build in seconds, and `cargo deny check licenses` gates the non-UI crates still build in seconds, and `cargo deny check licenses` gates the
@@ -207,6 +208,23 @@ typed command plans. Suggestions have no authority. Plan execution is routed
through the same command plane as human actions, and trace export to a hosted through the same command plane as human actions, and trace export to a hosted
model requires explicit scope and destination consent. See decision 0007. model requires explicit scope and destination consent. See decision 0007.
## Theme model
Every colour in the interface is a named semantic role derived from a syntax
theme's five anchors, not a constant. `lumbridge-theme` holds the derivation and
no framework types, so the arithmetic is tested headless; the shell converts a
derived palette into GPUI colours once per theme change and owns the result.
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. Provenance colours are separate
roles from state colours, with a test holding them pairwise distinct, because
decision 0012 colours a usage reading by its source and never by its value.
The default theme pins its roles to the eleven constants that preceded it, so
introducing the engine changed no pixels. The terminal ANSI palette and the
theme catalog are still fixed tables. See decision 0018.
## Usage model ## Usage model
Usage is an append-only observation stream, not a mutable percentage field. Usage is an append-only observation stream, not a mutable percentage field.
+91
View File
@@ -0,0 +1,91 @@
# 0018: The interface palette is derived from a syntax theme's anchors
Status: accepted; the default theme reproduces the previous appearance exactly.
Lumbridge had eleven `const … : u32` colours in `main.rs` and a byte-identical
copy of the same eleven in the Floem spike. Every one was a judgement call
someone made once, and there was no way for a user to change any of them without
recompiling.
## The approach
A syntax theme has already answered the hard question — which background,
foreground, and comment colour work together — so the interface derives itself
from that answer instead of being invented beside it. Five anchors go in
(`bg`, `fg`, `comment`, and the git added/deleted/modified colours when the theme
has them) and a full role set comes out.
The core of it: the application frame 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. The step is logarithmic rather than fixed,
because a separation that reads clearly against near-black is invisible against
white. A theme already at black has 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
seam between panel and frame.
## Adapted from
Buzz's `desktop/src/shared/theme/adaptive-theme.ts` (block/buzz, Apache-2.0,
Copyright 2026 Block, Inc.), pinned in `docs/RESEARCH_SNAPSHOTS.md`. It is itself
a port of an earlier `builderbot` original, which is not in `Research/` and whose
licence has not been verified; Block's Apache-2.0 grant covers what Block
distributes, which is what was read here.
No implementation code was copied. The algorithm was read as a specification and
reimplemented in Rust, and the golden vectors in `derive.rs` are what hold the
two together.
**Those vectors were taken by running the original under Node, not by
re-deriving them.** That distinction is not pedantry. A re-implementation in
Python reports `#191c20` for github-dark's chrome; the real answer is `#171a1d`.
Python's `round` is banker's rounding and JavaScript's `Math.round` is half-up,
they disagree on exactly one channel value — 22.5 — and that one channel decides
whether the bisection's convergence test trips a step early. The first draft of
this work took the Python number on trust from a research pass that claimed to
have "reproduced it byte-exactly", and it was wrong. Anything claiming to
reproduce these must run the original.
Two consequences for the port: the arithmetic is `f64` throughout, because
JavaScript numbers are `f64` and `f32` drifts the search; and `mix` quantises to
eight bits on *every* call, because the bisection searches over the space that
rounding produces.
## The roles
Named for what they do, not for a container ladder: `surface_active` is the
selected panel and the hover fill, not "surface container highest". Nothing in
Lumbridge has to reason about how many containers deep it is.
The five **provenance** colours are separate fields from the state colours, and a
test asserts they stay pairwise distinct in every theme. Decision 0012 says a
usage value is coloured by where it came from and never by how alarming it is; a
theme that collapsed two of them would silently defeat that, and once themes are
user-editable the number of ways to do so multiplies.
## Why the default theme carries overrides
`RoleOverrides` lets a theme pin a role instead of deriving it. Only
`lumbridge-slate` uses it, and only so this change is a **zero-pixel** one: the
commit should be reviewable as "colours now come from a palette", with no
appearance change smuggled inside it. That was verified by screenshot rather
than asserted — the only pixels that differ between the before and after builds
are the digits of a process ID, which changes per run.
The anchors underneath are real, and a test bounds how far the pure derivation
lands from the pinned values, so the overrides are a starting position rather
than a permanent exemption. A catalog theme setting them would be defeating the
engine.
## Not in this change
The **terminal ANSI palette** still has its own fixed table, so `main.rs` is not
yet free of colour literals — twenty-nine remain, all of them terminal. Per-theme
terminal palettes need the extraction pass that arrives with the catalog.
The **catalog** itself is one theme. The generator, the vendored theme files, and
their attribution are a separate piece of work, as is the picker. What lands here
is the engine and one first-party theme, which is what the sidebar and settings
work needs in order to be built against roles rather than constants.
The **Floem spike keeps its eleven constants**. It is frozen under decision 0017
and is not maintained in parity.