From 1556b87f37b3d32f4596ef8201105e1688b1c438 Mon Sep 17 00:00:00 2001 From: Metal Agent Date: Mon, 31 Aug 2026 23:23:30 -0700 Subject: [PATCH] Derive the interface palette instead of hardcoding eleven colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.lock | 5 + Cargo.toml | 1 + apps/lumbridge/Cargo.toml | 1 + apps/lumbridge/src/main.rs | 589 +++++++++++++----------- apps/lumbridge/src/theme.rs | 170 +++++++ crates/lumbridge-theme/Cargo.toml | 11 + crates/lumbridge-theme/src/accent.rs | 119 +++++ crates/lumbridge-theme/src/color.rs | 283 ++++++++++++ crates/lumbridge-theme/src/derive.rs | 275 +++++++++++ crates/lumbridge-theme/src/lib.rs | 195 ++++++++ crates/lumbridge-theme/src/palette.rs | 100 ++++ docs/ARCHITECTURE.md | 20 +- docs/decisions/0018-theme-derivation.md | 91 ++++ 13 files changed, 1585 insertions(+), 275 deletions(-) create mode 100644 apps/lumbridge/src/theme.rs create mode 100644 crates/lumbridge-theme/Cargo.toml create mode 100644 crates/lumbridge-theme/src/accent.rs create mode 100644 crates/lumbridge-theme/src/color.rs create mode 100644 crates/lumbridge-theme/src/derive.rs create mode 100644 crates/lumbridge-theme/src/lib.rs create mode 100644 crates/lumbridge-theme/src/palette.rs create mode 100644 docs/decisions/0018-theme-derivation.md diff --git a/Cargo.lock b/Cargo.lock index fd37900..01e04e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3324,6 +3324,7 @@ dependencies = [ "lumbridge-runtime", "lumbridge-storage", "lumbridge-terminal", + "lumbridge-theme", "lumbridge-ui-fixture", "serde", "serde_json", @@ -3392,6 +3393,10 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "lumbridge-theme" +version = "0.0.1" + [[package]] name = "lumbridge-ui-fixture" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 61e78df..8e9ec3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/lumbridge-runtime", "crates/lumbridge-storage", "crates/lumbridge-terminal", + "crates/lumbridge-theme", "crates/lumbridge-ui-fixture", ] exclude = ["spikes"] diff --git a/apps/lumbridge/Cargo.toml b/apps/lumbridge/Cargo.toml index 8464073..dcbb056 100644 --- a/apps/lumbridge/Cargo.toml +++ b/apps/lumbridge/Cargo.toml @@ -14,6 +14,7 @@ lumbridge-harness = { path = "../../crates/lumbridge-harness" } lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } lumbridge-storage = { path = "../../crates/lumbridge-storage" } lumbridge-terminal = { path = "../../crates/lumbridge-terminal" } +lumbridge-theme = { path = "../../crates/lumbridge-theme" } lumbridge-ui-fixture = { path = "../../crates/lumbridge-ui-fixture" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index 1024582..3f716f1 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -1,4 +1,5 @@ mod panel_registry; +mod theme; mod usage_feed; use std::collections::{BTreeMap, VecDeque}; @@ -7,7 +8,8 @@ use std::time::{Duration, Instant}; use gpui::{ App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels, - Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size, + Rgba, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, + size, }; use lumbridge_core::UsageProvenance; use lumbridge_runtime::{ @@ -26,66 +28,9 @@ use lumbridge_ui_fixture::{ }; use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; +use theme::{ActiveTheme, ThemeColors}; use usage_feed::{UsageFeed, UsageSegment}; -// A colour is read as six hex digits, so `0x8b_c8ff` would be harder to check -// against a design token than `0x8bc8ff`, not easier. Scoped to the palette and -// the two terminal colour tables; every other literal keeps the lint. -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const BG: u32 = 0x090c12; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const PANEL: u32 = 0x101620; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const PANEL_ALT: u32 = 0x151d29; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const PANEL_ACTIVE: u32 = 0x182334; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const BORDER: u32 = 0x263246; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const BORDER_QUIET: u32 = 0x1c2636; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const TEXT: u32 = 0xdbe5f4; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const MUTED: u32 = 0x8290a8; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const ACCENT: u32 = 0x68b5f8; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const ATTENTION: u32 = 0xf1b96a; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const SUCCESS: u32 = 0x70d6a8; const TIMING_SAMPLE_LIMIT: usize = 256; const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16); const RUNTIME_DRAIN_LIMIT: usize = 64; @@ -127,6 +72,7 @@ actions!( ); struct LumbridgeShell { + theme: ActiveTheme, model: ShellModel, panels: PanelRegistry, timing: RenderTiming, @@ -307,12 +253,12 @@ struct RenderTiming { dispatch_to_element_micros: VecDeque, } -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone, PartialEq)] struct TerminalPaintRun { text: String, columns: u16, - foreground: u32, - background: u32, + foreground: Rgba, + background: Rgba, style: TerminalCellStyle, cursor: Option, hyperlink: bool, @@ -328,7 +274,10 @@ impl TerminalPaintRun { } } -fn terminal_paint_rows(snapshot: &TerminalSnapshot) -> Vec> { +fn terminal_paint_rows( + snapshot: &TerminalSnapshot, + theme: ThemeColors, +) -> Vec> { (0..snapshot.dimensions.rows()) .map(|row| { let mut runs: Vec = Vec::new(); @@ -340,8 +289,8 @@ fn terminal_paint_rows(snapshot: &TerminalSnapshot) -> Vec continue; } - let mut foreground = terminal_color(cell.foreground); - let mut background = terminal_color(cell.background); + let mut foreground = terminal_color(cell.foreground, theme); + let mut background = terminal_color(cell.background, theme); if cell.style.contains(TerminalCellStyle::INVERSE) { std::mem::swap(&mut foreground, &mut background); } @@ -429,41 +378,41 @@ fn clamp_to_u16(value: f32) -> u16 { clippy::unreadable_literal, reason = "six-digit colour hex reads whole" )] -fn terminal_color(color: TerminalColor) -> u32 { +fn terminal_color(color: TerminalColor, theme: ThemeColors) -> Rgba { match color { TerminalColor::Rgb { red, green, blue } => { - (u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue) + rgb((u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue)) } - TerminalColor::Indexed(index) => indexed_terminal_color(index), + TerminalColor::Indexed(index) => rgb(indexed_terminal_color(index)), TerminalColor::Named(named) => match named { - TerminalNamedColor::Black => 0x1d2430, - TerminalNamedColor::Red => 0xff6b6b, - TerminalNamedColor::Green => 0x70d6a8, - TerminalNamedColor::Yellow => 0xf1c76a, - TerminalNamedColor::Blue => 0x68b5f8, - TerminalNamedColor::Magenta => 0xc79bf2, - TerminalNamedColor::Cyan => 0x63d5da, - TerminalNamedColor::White => 0xdbe5f4, - TerminalNamedColor::BrightBlack => 0x6d7a91, - TerminalNamedColor::BrightRed => 0xff8b8b, - TerminalNamedColor::BrightGreen => 0x93e6be, - TerminalNamedColor::BrightYellow => 0xf8d98c, - TerminalNamedColor::BrightBlue => 0x8bc8ff, - TerminalNamedColor::BrightMagenta => 0xd9b4fb, - TerminalNamedColor::BrightCyan => 0x86e7eb, + TerminalNamedColor::Black => rgb(0x1d2430), + TerminalNamedColor::Red => rgb(0xff6b6b), + TerminalNamedColor::Green => rgb(0x70d6a8), + TerminalNamedColor::Yellow => rgb(0xf1c76a), + TerminalNamedColor::Blue => rgb(0x68b5f8), + TerminalNamedColor::Magenta => rgb(0xc79bf2), + TerminalNamedColor::Cyan => rgb(0x63d5da), + TerminalNamedColor::White => rgb(0xdbe5f4), + TerminalNamedColor::BrightBlack => rgb(0x6d7a91), + TerminalNamedColor::BrightRed => rgb(0xff8b8b), + TerminalNamedColor::BrightGreen => rgb(0x93e6be), + TerminalNamedColor::BrightYellow => rgb(0xf8d98c), + TerminalNamedColor::BrightBlue => rgb(0x8bc8ff), + TerminalNamedColor::BrightMagenta => rgb(0xd9b4fb), + TerminalNamedColor::BrightCyan => rgb(0x86e7eb), TerminalNamedColor::BrightWhite | TerminalNamedColor::BrightForeground - | TerminalNamedColor::Cursor => 0xf4f8ff, - TerminalNamedColor::Foreground => TEXT, - TerminalNamedColor::Background => BG, - TerminalNamedColor::DimBlack => 0x121820, - TerminalNamedColor::DimRed => 0x9f4a4a, - TerminalNamedColor::DimGreen => 0x4e9878, - TerminalNamedColor::DimYellow => 0xa88a49, - TerminalNamedColor::DimBlue => 0x477fac, - TerminalNamedColor::DimMagenta => 0x896ca4, - TerminalNamedColor::DimCyan => 0x459397, - TerminalNamedColor::DimWhite | TerminalNamedColor::DimForeground => MUTED, + | TerminalNamedColor::Cursor => rgb(0xf4f8ff), + TerminalNamedColor::Foreground => theme.text, + TerminalNamedColor::Background => theme.chrome, + TerminalNamedColor::DimBlack => rgb(0x121820), + TerminalNamedColor::DimRed => rgb(0x9f4a4a), + TerminalNamedColor::DimGreen => rgb(0x4e9878), + TerminalNamedColor::DimYellow => rgb(0xa88a49), + TerminalNamedColor::DimBlue => rgb(0x477fac), + TerminalNamedColor::DimMagenta => rgb(0x896ca4), + TerminalNamedColor::DimCyan => rgb(0x459397), + TerminalNamedColor::DimWhite | TerminalNamedColor::DimForeground => theme.muted, }, } } @@ -494,9 +443,16 @@ fn indexed_terminal_color(index: u8) -> u32 { } } -fn dim_color(color: u32) -> u32 { - let dim = |channel: u32| channel * 13 / 20; - (dim((color >> 16) & 0xff) << 16) | (dim((color >> 8) & 0xff) << 8) | dim(color & 0xff) +/// SGR 2. Not a theme role: dimming is a property of the escape sequence, so it +/// scales whatever colour the cell already had. +fn dim_color(color: Rgba) -> Rgba { + const DIM: f32 = 13.0 / 20.0; + Rgba { + r: color.r * DIM, + g: color.g * DIM, + b: color.b * DIM, + a: color.a, + } } fn terminal_dimensions_for_window( @@ -724,6 +680,13 @@ impl LumbridgeShell { .detach(); Self { + // The catalog is compiled in, so the first frame paints the right + // theme: nothing to load, nothing to cache, no flash of the wrong + // colours on startup. + theme: ActiveTheme::new( + lumbridge_theme::theme_or_default(lumbridge_theme::DEFAULT_THEME), + lumbridge_theme::DEFAULT_ACCENT, + ), model: ShellModel::with_external_outputs([ FixturePaneId::CodexRuntime, FixturePaneId::ClaudeUi, @@ -1271,7 +1234,7 @@ impl LumbridgeShell { cx.notify(); } - fn terminal_run(run: TerminalPaintRun) -> gpui::AnyElement { + fn terminal_run(run: TerminalPaintRun, theme: ThemeColors) -> gpui::AnyElement { let cursor = run.cursor; div() .flex_none() @@ -1279,8 +1242,8 @@ impl LumbridgeShell { .w(px(f32::from(run.columns) * TERMINAL_CELL_WIDTH)) .overflow_hidden() .whitespace_nowrap() - .text_color(rgb(run.foreground)) - .bg(rgb(run.background)) + .text_color(run.foreground) + .bg(run.background) .when(run.style.contains(TerminalCellStyle::BOLD), |view| { view.font_weight(FontWeight::BOLD) }) @@ -1300,16 +1263,16 @@ impl LumbridgeShell { view.line_through() }) .when(cursor == Some(TerminalCursorShape::Block), |view| { - view.bg(rgb(TEXT)).text_color(rgb(BG)) + view.bg(theme.text).text_color(theme.chrome) }) .when(cursor == Some(TerminalCursorShape::HollowBlock), |view| { - view.border_1().border_color(rgb(TEXT)) + view.border_1().border_color(theme.text) }) .when(cursor == Some(TerminalCursorShape::Underline), |view| { - view.border_b_2().border_color(rgb(TEXT)) + view.border_b_2().border_color(theme.text) }) .when(cursor == Some(TerminalCursorShape::Beam), |view| { - view.border_l_2().border_color(rgb(TEXT)) + view.border_l_2().border_color(theme.text) }) .child(run.text) .into_any_element() @@ -1377,18 +1340,19 @@ impl LumbridgeShell { } fn terminal_view(&self, pane: PanelId) -> gpui::AnyElement { + let theme = self.theme.colors; let terminal = self .live_terminals .get(&pane) .expect("external terminal pane has live state"); - let rows = terminal_paint_rows(&terminal.snapshot) + let rows = terminal_paint_rows(&terminal.snapshot, theme) .into_iter() .map(|runs| { div() .flex() .h(px(TERMINAL_CELL_HEIGHT)) .flex_none() - .children(runs.into_iter().map(Self::terminal_run)) + .children(runs.into_iter().map(|run| Self::terminal_run(run, theme))) }) .collect::>(); div() @@ -1396,7 +1360,7 @@ impl LumbridgeShell { .flex_col() .size_full() .overflow_hidden() - .bg(rgb(BG)) + .bg(theme.chrome) .font_family("monospace") .text_size(px(13.0)) .children(rows) @@ -1413,6 +1377,7 @@ impl LumbridgeShell { selected: bool, cx: &mut Context, ) -> gpui::AnyElement { + let theme = self.theme.colors; let pane_id = pane.id; let can_detach = self.panels.attached_count() > 1; let external = pane.output_source == OutputSource::External; @@ -1464,15 +1429,15 @@ impl LumbridgeShell { // Provided but unselected reads as available; unprovided // stays dim, so the tab strip shows what this pane can do // before you click rather than after. - .text_color(rgb(if active { - ACCENT + .text_color(if active { + theme.accent } else if provided { - TEXT + theme.text } else { - MUTED - })) - .when(active, |view| view.border_b_2().border_color(rgb(ACCENT))) - .hover(|view| view.bg(rgb(PANEL_ACTIVE)).text_color(rgb(TEXT))) + theme.muted + }) + .when(active, |view| view.border_b_2().border_color(theme.accent)) + .hover(|view| view.bg(theme.surface_active).text_color(theme.text)) .child(tab.label()) .on_click(cx.listener(move |shell, _, window, cx| { cx.stop_propagation(); @@ -1489,9 +1454,13 @@ impl LumbridgeShell { .overflow_hidden() .px_3() .py_2() - .bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT })) + .bg(if selected { + theme.surface_active + } else { + theme.surface_raised + }) .border_b_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .flex() @@ -1505,7 +1474,7 @@ impl LumbridgeShell { div() .truncate() .text_sm() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child(pane.title.clone()), ) .child( @@ -1513,7 +1482,7 @@ impl LumbridgeShell { .mt_1() .truncate() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(detail), ), ) @@ -1527,13 +1496,13 @@ impl LumbridgeShell { .child( div() .text_xs() - .text_color(rgb(if pane.needs_input() { - ATTENTION + .text_color(if pane.needs_input() { + theme.attention } else if selected { - SUCCESS + theme.success } else { - MUTED - })) + theme.muted + }) .child(surface_status), ) .child( @@ -1544,13 +1513,17 @@ impl LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(if can_detach { MUTED } else { BORDER })) + .text_color(if can_detach { + theme.muted + } else { + theme.border + }) .when(can_detach, |view| { view.hover(|view| { - view.border_color(rgb(ATTENTION)) - .text_color(rgb(ATTENTION)) + view.border_color(theme.attention) + .text_color(theme.attention) }) }) .child(if can_detach { @@ -1573,7 +1546,7 @@ impl LumbridgeShell { .mt_2() .overflow_hidden() .border_t_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) .children(tabs), ) .child( @@ -1583,7 +1556,7 @@ impl LumbridgeShell { .justify_between() .mt_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("TOOLS · CONTEXT · GOAL") .child(if pane.needs_input() { "NEEDS INPUT" @@ -1608,7 +1581,11 @@ impl LumbridgeShell { /// /// It says so, and it says what is still running underneath, because the /// pane's process identity does not change when the view does. - fn unavailable_surface(pane: &PanelView, shown: SurfaceTab) -> gpui::AnyElement { + fn unavailable_surface( + pane: &PanelView, + shown: SurfaceTab, + theme: ThemeColors, + ) -> gpui::AnyElement { let native = SurfaceTab::native_for(pane.kind); div() .flex() @@ -1617,9 +1594,9 @@ impl LumbridgeShell { .min_w_0() .min_h_0() .overflow_hidden() - .bg(rgb(BG)) + .bg(theme.chrome) .border_y_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .flex() @@ -1629,20 +1606,20 @@ impl LumbridgeShell { .child( div() .text_sm() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child(format!("{} unavailable", shown.label())), ) .child( div() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(shown.unavailable_reason()), ) .child( div() .mt_2() .text_xs() - .text_color(rgb(SUCCESS)) + .text_color(theme.success) .child(format!( "{} is still this pane's live surface. Nothing was stopped.", native.label() @@ -1653,9 +1630,10 @@ impl LumbridgeShell { } fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement { + let theme = self.theme.colors; let shown = self.surface_for(pane.id, pane.kind); if shown != SurfaceTab::native_for(pane.kind) { - return Self::unavailable_surface(pane, shown); + return Self::unavailable_surface(pane, shown, theme); } let external = pane.output_source == OutputSource::External; let content = if external { @@ -1670,7 +1648,7 @@ impl LumbridgeShell { .overflow_hidden() .font_family("monospace") .text_sm() - .text_color(rgb(TEXT)) + .text_color(theme.text) .children(pane.lines[start..].iter().cloned()) .into_any_element() }; @@ -1682,9 +1660,9 @@ impl LumbridgeShell { .min_w_0() .min_h_0() .overflow_hidden() - .bg(rgb(BG)) + .bg(theme.chrome) .border_y_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .flex_1() @@ -1696,7 +1674,12 @@ impl LumbridgeShell { .into_any_element() } + #[allow( + clippy::too_many_lines, + reason = "a single declarative element tree, not a sequence of steps" + )] fn pane_decision_region(&self, pane: &PanelView, cx: &mut Context) -> gpui::AnyElement { + let theme = self.theme.colors; let pane_id = pane.id; let picked = self.shelf_choice.get(&pane_id).copied(); // Every choice names the capability it would need. None of them holds @@ -1722,19 +1705,23 @@ impl LumbridgeShell { .overflow_hidden() .px_2() .py_2() - .bg(rgb(PANEL)) + .bg(theme.surface) .border_t_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .flex() .items_center() .justify_between() .text_xs() - .child(div().text_color(rgb(MUTED)).child("DECISION SHELF")) + .child(div().text_color(theme.muted).child("DECISION SHELF")) .child( div() - .text_color(rgb(if pane.needs_input() { ATTENTION } else { MUTED })) + .text_color(if pane.needs_input() { + theme.attention + } else { + theme.muted + }) .child(if pane.needs_input() { "REVIEW REQUIRED" } else { @@ -1757,19 +1744,23 @@ impl LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(if chosen { - ACCENT + .border_color(if chosen { + theme.accent } else if attention { - ATTENTION + theme.attention } else { - BORDER - })) - .bg(rgb(if chosen { PANEL_ACTIVE } else { PANEL_ALT })) - .hover(|view| view.border_color(rgb(ACCENT))) + theme.border + }) + .bg(if chosen { + theme.surface_active + } else { + theme.surface_raised + }) + .hover(|view| view.border_color(theme.accent)) .text_xs() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child(label) - .child(div().truncate().text_color(rgb(MUTED)).child(detail)) + .child(div().truncate().text_color(theme.muted).child(detail)) .on_click(cx.listener(move |shell, _, window, cx| { cx.stop_propagation(); shell.select_shelf_choice(pane_id, index, window, cx); @@ -1781,7 +1772,11 @@ impl LumbridgeShell { div() .mt_2() .text_xs() - .text_color(rgb(if picked.is_some() { ACCENT } else { MUTED })) + .text_color(if picked.is_some() { + theme.accent + } else { + theme.muted + }) .child(picked.map_or_else( || "Pick one to see what it would need".to_owned(), |index| { @@ -1801,6 +1796,7 @@ impl LumbridgeShell { selected: bool, cx: &mut Context, ) -> gpui::AnyElement { + let theme = self.theme.colors; let pane_id = pane.id; div() .id(("workspace-panel", pane_id.get())) @@ -1815,7 +1811,7 @@ impl LumbridgeShell { .min_h_0() .overflow_hidden() .border_1() - .border_color(rgb(if selected { ACCENT } else { BORDER })) + .border_color(if selected { theme.accent } else { theme.border }) .child( div() .h(relative(0.20)) @@ -1843,7 +1839,12 @@ impl LumbridgeShell { .into_any_element() } + #[allow( + clippy::too_many_lines, + reason = "a single declarative element tree, not a sequence of steps" + )] fn command_palette(&self) -> impl IntoElement { + let theme = self.theme.colors; let query = self.model.command_palette().query(); let prompt = if query.is_empty() { "Type a command…".to_owned() @@ -1863,17 +1864,21 @@ impl LumbridgeShell { div() .w(px(620.0)) .overflow_hidden() - .bg(rgb(PANEL_ALT)) + .bg(theme.surface_raised) .border_1() - .border_color(rgb(ACCENT)) + .border_color(theme.accent) .rounded(px(8.0)) .child( div() .px_4() .py_3() .border_b_1() - .border_color(rgb(BORDER)) - .text_color(rgb(if query.is_empty() { MUTED } else { TEXT })) + .border_color(theme.border) + .text_color(if query.is_empty() { + theme.muted + } else { + theme.text + }) .child(prompt), ) .child( @@ -1887,13 +1892,13 @@ impl LumbridgeShell { .px_3() .py_2() .rounded(px(5.0)) - .bg(rgb(PANEL_ACTIVE)) + .bg(theme.surface_active) .child("Focus next pane") .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Workspace · navigation"), ), ) @@ -1901,7 +1906,7 @@ impl LumbridgeShell { div() .px_3() .py_2() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Add workspace panel") .child( div() @@ -1914,7 +1919,7 @@ impl LumbridgeShell { div() .px_3() .py_2() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Detach selected panel") .child( div() @@ -1927,7 +1932,7 @@ impl LumbridgeShell { div() .px_3() .py_2() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Share selected pane to Buzz…"), ), ) @@ -1938,9 +1943,9 @@ impl LumbridgeShell { .px_4() .py_2() .border_t_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Enter to run") .child("Esc to close"), ), @@ -1952,6 +1957,7 @@ impl LumbridgeShell { reason = "a single declarative element tree, not a sequence of steps" )] fn add_panel_chooser(&self, cx: &mut Context) -> impl IntoElement { + let theme = self.theme.colors; let kinds = PanelKind::ALL .into_iter() .enumerate() @@ -1967,8 +1973,8 @@ impl LumbridgeShell { .py_3() .rounded(px(6.0)) .border_1() - .border_color(rgb(BORDER)) - .bg(rgb(PANEL)) + .border_color(theme.border) + .bg(theme.surface) .child( div() .flex() @@ -1977,8 +1983,8 @@ impl LumbridgeShell { .size(px(34.0)) .flex_none() .rounded(px(5.0)) - .bg(rgb(PANEL_ACTIVE)) - .text_color(rgb(ACCENT)) + .bg(theme.surface_active) + .text_color(theme.accent) .child(match kind { PanelKind::Terminal => ">_", PanelKind::Browser => "◎", @@ -1990,12 +1996,12 @@ impl LumbridgeShell { div() .min_w_0() .flex_1() - .child(div().text_sm().text_color(rgb(TEXT)).child(kind.label())) + .child(div().text_sm().text_color(theme.text).child(kind.label())) .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(kind.description()), ), ) @@ -2003,7 +2009,7 @@ impl LumbridgeShell { div() .flex_none() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(shortcut.to_string()), ) .on_click(cx.listener(move |shell, _, window, cx| { @@ -2037,8 +2043,8 @@ impl LumbridgeShell { .overflow_hidden() .rounded(px(9.0)) .border_1() - .border_color(rgb(ACCENT)) - .bg(rgb(PANEL_ALT)) + .border_color(theme.accent) + .bg(theme.surface_raised) .child( div() .flex() @@ -2047,20 +2053,20 @@ impl LumbridgeShell { .px_4() .py_3() .border_b_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .child( div() .text_lg() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child("Add workspace panel"), ) .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Created beside the selected pane with a persistent local identity."), ), ) @@ -2072,9 +2078,9 @@ impl LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("ESC") .on_click(cx.listener(|shell, _, _, cx| { shell.add_panel_chooser_open = false; @@ -2090,9 +2096,9 @@ impl LumbridgeShell { .pt_2() .pb_1() .border_t_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("REATTACH RUNNING SESSION"), ) .children(detached.into_iter().map(|(id, title, kind)| { @@ -2107,21 +2113,21 @@ impl LumbridgeShell { .px_3() .py_2() .rounded(px(5.0)) - .bg(rgb(PANEL)) + .bg(theme.surface) .border_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() - .child(div().text_sm().text_color(rgb(TEXT)).child(title)) + .child(div().text_sm().text_color(theme.text).child(title)) .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(format!("{kind} · pane-{}", id.get())), ), ) - .child(div().text_xs().text_color(rgb(ACCENT)).child("REATTACH")) + .child(div().text_xs().text_color(theme.accent).child("REATTACH")) .on_click(cx.listener(move |shell, _, window, cx| { shell.add_panel_chooser_open = false; shell.attach_panel(id, window, cx); @@ -2135,9 +2141,9 @@ impl LumbridgeShell { .px_4() .py_2() .border_t_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("1–4 create · click reattaches") .child("Esc closes"), ), @@ -2160,6 +2166,7 @@ impl LumbridgeShell { /// Where sessions are owned, and what each host is actually doing. fn runtime_rows(&self) -> Vec { + let theme = self.theme.colors; let running = self .live_terminals .values() @@ -2169,7 +2176,11 @@ impl LumbridgeShell { let local = RuntimeRow { label: "this machine", detail: format!("{running}/{total} live PTYs"), - tone: if running == 0 { MUTED } else { SUCCESS }, + tone: if running == 0 { + theme.muted + } else { + theme.success + }, }; // The usage adapter is neither a host nor an endpoint, but its health // belongs beside them: it is the reason the footer can or cannot answer. @@ -2182,9 +2193,9 @@ impl LumbridgeShell { self.usage.declared_profile_count() ), tone: if self.usage.reporting_profile_count() == 0 { - MUTED + theme.muted } else { - SUCCESS + theme.success }, }; vec![local, adapter] @@ -2199,6 +2210,7 @@ impl LumbridgeShell { attached_panes: &[PanelId], visible_count: usize, detached_count: usize, + theme: ThemeColors, cx: &mut Context, ) -> gpui::AnyElement { let attached_count = attached_panes.len(); @@ -2209,9 +2221,9 @@ impl LumbridgeShell { .flex_none() .px_2() .gap_1() - .bg(rgb(PANEL)) + .bg(theme.surface) .border_b_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) // One tab, because there is one workspace. Two more used to sit // here looking like navigation and doing nothing. .child( @@ -2221,12 +2233,12 @@ impl LumbridgeShell { .items_center() .px_3() .border_b_2() - .border_color(rgb(ACCENT)) + .border_color(theme.accent) .text_sm() .child("Lumbridge Code"), ) .child(div().flex_1()) - .child(div().px_3().text_xs().text_color(rgb(MUTED)).child(format!( + .child(div().px_3().text_xs().text_color(theme.muted).child(format!( "{visible_count} shown · {attached_count} attached · {detached_count} detached" ))) .child( @@ -2238,9 +2250,9 @@ impl LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(ACCENT)) + .border_color(theme.accent) .text_xs() - .text_color(rgb(ACCENT)) + .text_color(theme.accent) .child("+ ADD PANEL ▾") .on_click(cx.listener(|shell, _, window, cx| { shell.dispatch(ShellAction::CloseCommandPalette); @@ -2258,6 +2270,7 @@ impl LumbridgeShell { reason = "a single declarative element tree, not a sequence of steps" )] fn render_root(&self, parts: RootParts, cx: &mut Context) -> gpui::AnyElement { + let theme = self.theme.colors; let RootParts { sidebar, tabs, @@ -2309,8 +2322,8 @@ impl LumbridgeShell { .flex() .flex_col() .size_full() - .bg(rgb(BG)) - .text_color(rgb(TEXT)) + .bg(theme.chrome) + .text_color(theme.text) .child( div() .flex() @@ -2318,15 +2331,15 @@ impl LumbridgeShell { .h(px(48.0)) .flex_none() .px_4() - .bg(rgb(PANEL_ALT)) + .bg(theme.surface_raised) .border_b_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) .child(div().text_lg().child("Lumbridge")) .child( div() .ml_3() .text_sm() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Lumbridge Code"), ) .child(div().flex_1()) @@ -2336,17 +2349,17 @@ impl LumbridgeShell { div() .mr_4() .text_xs() - .text_color(rgb(if running_runtime_count == 0 { - MUTED + .text_color(if running_runtime_count == 0 { + theme.muted } else { - SUCCESS - })) + theme.success + }) .child(runtime_summary), ) .child( div() .text_sm() - .text_color(rgb(ACCENT)) + .text_color(theme.accent) .child("Ctrl/⌘ K · Commands"), ), ) @@ -2372,11 +2385,11 @@ impl LumbridgeShell { .flex_none() .px_3() .gap_4() - .bg(rgb(PANEL_ALT)) + .bg(theme.surface_raised) .border_t_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child( div() .flex() @@ -2413,25 +2426,26 @@ impl LumbridgeShell { detached_entries: &[(PanelId, String)], cx: &mut Context, ) -> gpui::AnyElement { + let theme = self.theme.colors; div() .flex() .flex_col() .w(px(248.0)) .flex_none() - .bg(rgb(PANEL)) + .bg(theme.surface) .border_r_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) .child( div() .px_4() .pt_4() .pb_2() .text_xs() - .text_color(rgb(if attention_panels.is_empty() { - MUTED + .text_color(if attention_panels.is_empty() { + theme.muted } else { - ATTENTION - })) + theme.attention + }) .child(format!("ATTENTION · {}", attention_panels.len())), ) .when(attention_panels.is_empty(), |view| { @@ -2442,7 +2456,7 @@ impl LumbridgeShell { .px_3() .py_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("No pane is waiting on you"), ) }) @@ -2456,21 +2470,21 @@ impl LumbridgeShell { .px_3() .py_2() .rounded(px(5.0)) - .bg(rgb(PANEL_ACTIVE)) + .bg(theme.surface_active) .border_1() - .border_color(rgb(ATTENTION)) - .hover(|view| view.bg(rgb(PANEL_ALT))) + .border_color(theme.attention) + .hover(|view| view.bg(theme.surface_raised)) .child( div() .text_sm() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child(pane.title.clone()), ) .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(pane.target.clone()), ) .on_click(cx.listener(move |shell, _, window, cx| { @@ -2483,22 +2497,22 @@ impl LumbridgeShell { .px_4() .py_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("RUNTIMES"), ) .children(self.runtime_rows().into_iter().map(|row| { div() .px_4() .py_1() - .child(div().text_sm().text_color(rgb(row.tone)).child(row.label)) - .child(div().text_xs().text_color(rgb(MUTED)).child(row.detail)) + .child(div().text_sm().text_color(row.tone).child(row.label)) + .child(div().text_xs().text_color(theme.muted).child(row.detail)) })) .child( div() .px_4() .pt_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(self.persistence_status.clone()), ) .child( @@ -2507,7 +2521,7 @@ impl LumbridgeShell { .px_4() .py_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(format!("DETACHED SESSIONS · {}", detached_entries.len())), ) .children(detached_entries.iter().map(|(pane, title)| { @@ -2521,10 +2535,10 @@ impl LumbridgeShell { .py_2() .rounded(px(4.0)) .border_1() - .border_color(rgb(BORDER_QUIET)) - .hover(|view| view.border_color(rgb(ACCENT)).text_color(rgb(TEXT))) + .border_color(theme.border_quiet) + .hover(|view| view.border_color(theme.accent).text_color(theme.text)) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(format!("↪ {title}")) .on_click(cx.listener(move |shell, _, window, cx| { shell.attach_panel(pane, window, cx); @@ -2541,6 +2555,7 @@ impl LumbridgeShell { /// harness on screen and leads with what is left, because that is the /// question. The selected harness expands with its rate and its trust. fn footer_usage_zone(&self) -> impl IntoElement { + let theme = self.theme.colors; let active = self .panels .panel(self.panels.selected()) @@ -2556,7 +2571,7 @@ impl LumbridgeShell { view.child( div() .flex_none() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("no harness on this pane"), ) }) @@ -2571,7 +2586,7 @@ impl LumbridgeShell { let repeats = previous.as_deref() == Some(segment.label.as_str()); previous = Some(segment.label.clone()); let selected = active.as_ref() == Some(&segment.id); - usage_segment(segment, selected, repeats) + usage_segment(segment, selected, repeats, &self.theme) }) .collect::>() }) @@ -2580,19 +2595,17 @@ impl LumbridgeShell { /// The provenance of a value is carried by the colour of its meter, so the /// strip reads at a glance without a legend on every segment. -const fn provenance_color(provenance: UsageProvenance) -> u32 { - match provenance { - UsageProvenance::ProviderReported => SUCCESS, - UsageProvenance::HarnessReported => ACCENT, - UsageProvenance::LocallyMeasured => TEXT, - UsageProvenance::Estimated => ATTENTION, - UsageProvenance::Unavailable => MUTED, - } +fn provenance_color(provenance: UsageProvenance, theme: &ActiveTheme) -> Rgba { + theme.provenance(provenance) } /// A quota meter. The filled part is spent; the empty part is what is left, /// which is the way round an engineer reads it. -fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { +fn usage_meter( + consumed_permille: Option, + color: Rgba, + theme: ThemeColors, +) -> impl IntoElement { let Some(permille) = consumed_permille else { // No capsule. A full-length empty gauge reads as "plenty left" from // across the room, which is the opposite of the truth. A hairline is @@ -2601,7 +2614,7 @@ fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { .w(px(80.0)) .h(px(2.0)) .flex_none() - .bg(rgb(BORDER)) + .bg(theme.border) .into_any_element(); }; let clamped = u16::try_from(permille.min(1_000)).unwrap_or(1_000); @@ -2610,13 +2623,13 @@ fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { .h(px(8.0)) .flex_none() .rounded(px(4.0)) - .bg(rgb(BORDER_QUIET)) + .bg(theme.border_quiet) .overflow_hidden() .child( div() .h_full() .w(relative(f32::from(clamped) / 1_000.0)) - .bg(rgb(color)), + .bg(color), ) .into_any_element() } @@ -2625,33 +2638,39 @@ fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { /// /// `continues_group` means the harness above this one is the same, so its name /// is left off and only the window is named. -fn usage_segment(segment: UsageSegment, selected: bool, continues_group: bool) -> impl IntoElement { - let color = provenance_color(segment.provenance); - let name_color = if selected { TEXT } else { MUTED }; +fn usage_segment( + segment: UsageSegment, + selected: bool, + continues_group: bool, + active: &ActiveTheme, +) -> impl IntoElement { + let theme = active.colors; + let color = provenance_color(segment.provenance, active); + let name_color = if selected { theme.text } else { theme.muted }; let headline_color = if segment.headline.is_none() { - MUTED + theme.muted } else if segment.critical { - ATTENTION + theme.attention } else { - TEXT + theme.text }; div() .flex() .items_center() .gap_2() .when(selected, |view| { - view.px_2().py_1().rounded(px(4.0)).bg(rgb(PANEL_ACTIVE)) + view.px_2().py_1().rounded(px(4.0)).bg(theme.surface_active) }) .when(!continues_group, |view| { view.child( div() .flex_none() - .text_color(rgb(name_color)) + .text_color(name_color) .child(segment.label), ) }) // A quiet pill rather than more running text. The window's name is a - // label on the number, not another number, and at BORDER weight it was + // label on the number, not another number, and at border weight it was // simply invisible. .child( div() @@ -2659,31 +2678,35 @@ fn usage_segment(segment: UsageSegment, selected: bool, continues_group: bool) - .px(px(5.0)) .py(px(1.0)) .rounded(px(3.0)) - .bg(rgb(if selected { BORDER } else { BORDER_QUIET })) - .text_color(rgb(if selected { TEXT } else { MUTED })) + .bg(if selected { + theme.border + } else { + theme.border_quiet + }) + .text_color(if selected { theme.text } else { theme.muted }) .child(segment.scope), ) - .child(usage_meter(segment.consumed_permille, color)) + .child(usage_meter(segment.consumed_permille, color, theme)) .child( div() .flex_none() - .text_color(rgb(headline_color)) + .text_color(headline_color) .child(segment.headline.unwrap_or_else(|| "no reading".to_owned())), ) .when_some(segment.reset, |view, reset| { - view.child(div().flex_none().text_color(rgb(MUTED)).child(reset)) + view.child(div().flex_none().text_color(theme.muted).child(reset)) }) // Only the harness you are looking at spends footer width on its rate // and its trust. The rest stay one glance wide. .when(selected, |view| { - view.child(footer_separator()) + view.child(footer_separator(theme)) .child( div() .flex_none() - .text_color(rgb(provenance_color(segment.burn_provenance))) + .text_color(provenance_color(segment.burn_provenance, active)) .child(segment.burn), ) - .child(provenance_chip(&segment.trust, segment.provenance)) + .child(provenance_chip(&segment.trust, segment.provenance, active)) }) } @@ -2703,29 +2726,34 @@ struct RootParts { struct RuntimeRow { label: &'static str, detail: String, - tone: u32, + tone: Rgba, } -fn footer_separator() -> impl IntoElement { - div().text_color(rgb(BORDER)).child("│") +fn footer_separator(theme: ThemeColors) -> impl IntoElement { + div().text_color(theme.border).child("│") } /// The trust marker. Its colour is the provenance, never the value. -fn provenance_chip(text: &str, provenance: UsageProvenance) -> impl IntoElement { +fn provenance_chip( + text: &str, + provenance: UsageProvenance, + active: &ActiveTheme, +) -> impl IntoElement { + let theme = active.colors; let color = match provenance { - UsageProvenance::ProviderReported => SUCCESS, - UsageProvenance::HarnessReported => ACCENT, - UsageProvenance::LocallyMeasured => TEXT, - UsageProvenance::Estimated => ATTENTION, - UsageProvenance::Unavailable => MUTED, + UsageProvenance::ProviderReported => theme.success, + UsageProvenance::HarnessReported => theme.accent, + UsageProvenance::LocallyMeasured => theme.text, + UsageProvenance::Estimated => theme.attention, + UsageProvenance::Unavailable => theme.muted, }; div() .px_2() .py(px(1.0)) .rounded(px(3.0)) .border_1() - .border_color(rgb(color)) - .text_color(rgb(color)) + .border_color(color) + .text_color(color) .child(text.to_owned()) } @@ -2766,7 +2794,13 @@ impl Render for LumbridgeShell { .iter() .position(|pane| *pane == self.panels.selected()) .expect("the selected pane must remain attached"); - let tabs = Self::render_tabs(&attached_panes, visible_count, detached_count, cx); + let tabs = Self::render_tabs( + &attached_panes, + visible_count, + detached_count, + self.theme.colors, + cx, + ); let panel_range = visible_panel_range(attached_count, selected_position, visible_count); let workspace_panels = panel_range .map(|index| { @@ -3039,7 +3073,14 @@ mod tests { fn terminal_paint_rows_merge_runs_and_preserve_cursor_boundary() { let mut terminal = TerminalEngine::new(TerminalEngineOptions::default()); terminal.process(b"\x1b[31mAB\x1b[0m"); - let rows = terminal_paint_rows(&terminal.snapshot()); + let rows = terminal_paint_rows( + &terminal.snapshot(), + crate::theme::ActiveTheme::new( + lumbridge_theme::theme_or_default(lumbridge_theme::DEFAULT_THEME), + lumbridge_theme::DEFAULT_ACCENT, + ) + .colors, + ); assert!( rows[0] .iter() diff --git a/apps/lumbridge/src/theme.rs b/apps/lumbridge/src/theme.rs new file mode 100644 index 0000000..0ac9fc9 --- /dev/null +++ b/apps/lumbridge/src/theme.rs @@ -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"); + } + } + } +} diff --git a/crates/lumbridge-theme/Cargo.toml b/crates/lumbridge-theme/Cargo.toml new file mode 100644 index 0000000..6780a46 --- /dev/null +++ b/crates/lumbridge-theme/Cargo.toml @@ -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 diff --git a/crates/lumbridge-theme/src/accent.rs b/crates/lumbridge-theme/src/accent.rs new file mode 100644 index 0000000..be02ca0 --- /dev/null +++ b/crates/lumbridge-theme/src/accent.rs @@ -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)); + } +} diff --git a/crates/lumbridge-theme/src/color.rs b/crates/lumbridge-theme/src/color.rs new file mode 100644 index 0000000..5c66f53 --- /dev/null +++ b/crates/lumbridge-theme/src/color.rs @@ -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 { + 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); + } +} diff --git a/crates/lumbridge-theme/src/derive.rs b/crates/lumbridge-theme/src/derive.rs new file mode 100644 index 0000000..2800bf1 --- /dev/null +++ b/crates/lumbridge-theme/src/derive.rs @@ -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, + pub deleted: Option, + pub modified: Option, + /// 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, + pub surface: Option, + pub surface_raised: Option, + pub surface_active: Option, + pub border: Option, + pub border_quiet: Option, + pub accent: Option, + pub success: Option, + pub attention: Option, +} + +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); + } +} diff --git a/crates/lumbridge-theme/src/lib.rs b/crates/lumbridge-theme/src/lib.rs new file mode 100644 index 0000000..f57d798 --- /dev/null +++ b/crates/lumbridge-theme/src/lib.rs @@ -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); + } +} diff --git a/crates/lumbridge-theme/src/palette.rs b/crates/lumbridge-theme/src/palette.rs new file mode 100644 index 0000000..53e57ba --- /dev/null +++ b/crates/lumbridge-theme/src/palette.rs @@ -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, +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7481105..d0c01a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,7 @@ messages should be real from the beginning. - `lumbridge-runtime`: session ownership, supervision, recovery, IPC server. - `lumbridge-pty`: portable PTY and process-tree adapters. - `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-harness`: manifests, launch profiles, hooks, PTY fallback adapters, and documented status/usage probes. @@ -43,7 +44,7 @@ messages should be real from the beginning. The scaffold currently contains `lumbridge-core`, `lumbridge-storage`, `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 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 @@ -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 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 is an append-only observation stream, not a mutable percentage field. diff --git a/docs/decisions/0018-theme-derivation.md b/docs/decisions/0018-theme-derivation.md new file mode 100644 index 0000000..76232dd --- /dev/null +++ b/docs/decisions/0018-theme-derivation.md @@ -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.