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