Files
lumbridge-code/apps/lumbridge/src/theme.rs
T
Metal AgentandClaude Opus 5 e5d7a3efd5 Add layered settings, and fix a migration mechanism that silently lied
Two things, because the second could not be built on the first.

The schema stamp was part of the same execute_batch as the CREATE TABLE IF NOT
EXISTS statements, and it wrote unconditionally. Opening an older file therefore
added no columns but flipped the version forward anyway; opening a *newer* file
stamped it back down and then wrote rows the newer build could not read. Both
produced a database whose recorded version was a lie, and every future schema
change would have inherited it.

Now the version is read before anything is applied, migrations are ordered and
forward-only inside one transaction, a newer file is refused with SchemaTooNew
rather than downgraded, and a supported version raised without a step to reach
it fails at the first open instead of claiming success. Tested by stamping a
file at version 99 and asserting both the refusal and that the stamp is left
untouched.

lumbridge-settings resolves compiled default -> settings.toml -> environment.
The environment sits above the file deliberately: decision 0016 calls
LUMBRIDGE_CLAUDE_OAUTH=0 "one switch off", and a switch a config file can
silently re-enable is not a switch. A pinned value renders disabled and names
the variable, rather than accepting an edit that would do nothing.

Every field carries a WriteAuthority. Routing all writes through Configure is
the obvious design and would hand a layout-only agent the program every future
pane launches — the guarantee decision 0006 exists to make. Anything naming a
program, path or destination is Human-only, asserted by a test that reads the
path rather than trusting the author.

Four paths are permanently not settings, with the reason recorded beside each
and a test asserting their absence: the usage endpoint URL, the credentials
path, the client identity, and the shell program. A configuration file that can
redirect where an access token is sent is a credential exfiltration path with a
friendly name.

Environment access is a trait rather than std::env, because the workspace forbids
unsafe, set_var is unsafe in Rust 2024, and the layering rule has to be testable
without mutating the process running the test.

Verified live with LUMBRIDGE_CLAUDE_OAUTH=0: the account-endpoint row reads off,
greyed, "pinned by LUMBRIDGE_CLAUDE_OAUTH". The Advanced page names every file,
endpoint and child process Lumbridge touches and states that nothing is sent
anywhere else — as a fact, not as a toggle nobody can flip.

File loading, comment-preserving writes and editable controls are not in this
pass; 0022 records why that order is the honest one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 00:06:06 -07:00

179 lines
6.9 KiB
Rust

//! 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,
/// The settings pane sits on this. The command palette and the panel
/// chooser still use `surface_raised`; moving them is a visible change and
/// belongs in a commit that says so.
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.
pub(crate) danger: Rgba,
/// A danger fill quiet enough to sit behind text.
pub(crate) danger_container: 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,
/// The dimming behind a modal. Translucent, so what it covers stays legible
/// as context rather than disappearing.
pub(crate) scrim_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),
danger_container: rgba(palette.danger_container),
attention: rgba(palette.attention),
attention_wash: rgba(palette.attention_wash),
scrim_wash: Rgba {
a: 0.55,
..rgba(palette.scrim)
},
}
}
}
/// 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");
}
}
}
}