diff --git a/crates/theme/src/appearance.rs b/crates/theme/src/appearance.rs new file mode 100644 index 00000000..936751b6 --- /dev/null +++ b/crates/theme/src/appearance.rs @@ -0,0 +1,150 @@ +//! Light/dark switching, adapted from bezel +//! (`crates/theme/src/appearance.rs`, MIT License, Copyright (c) 2026 bezel +//! contributors): what the user asked for, what the OS reports, and the +//! plumbing that turns a change in either into a repaint. + +use gpui::{App, Global, Subscription, Window}; +use serde::{Deserialize, Serialize}; + +use crate::{Theme, ThemeMode}; + +/// The user's appearance preference. Serde-serializable so callers can persist +/// it wherever their settings live; this crate never touches disk. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum AppearanceMode { + /// Follow the OS. The default — matches every other native app on the + /// machine, including when the OS is set to switch at sunset. + #[default] + System, + Light, + Dark, +} + +impl AppearanceMode { + pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark]; + + /// Menu/label text. + pub fn label(self) -> &'static str { + match self { + Self::System => "System", + Self::Light => "Light", + Self::Dark => "Dark", + } + } +} + +/// Global state behind the current theme: what the user chose, and what the OS +/// last said. Kept separate from [`Theme`] itself so that flipping the OS +/// appearance while the user has pinned Light still records the new system +/// value (and takes effect the moment they switch back to `System`). +pub struct AppearanceState { + pub mode: AppearanceMode, + pub system: ThemeMode, +} + +impl Global for AppearanceState {} + +/// Combine the user's choice with the OS state. +pub fn resolve(mode: AppearanceMode, system: ThemeMode) -> ThemeMode { + match mode { + AppearanceMode::System => system, + AppearanceMode::Light => ThemeMode::Light, + AppearanceMode::Dark => ThemeMode::Dark, + } +} + +/// Install the appearance globals and the matching theme. Call once at boot, +/// before any window opens, so the first frame is already the right palette +/// (installing later produces a visible dark-to-light flash). +pub fn init(mode: AppearanceMode, cx: &mut App) { + let system = ThemeMode::from_window(cx.window_appearance()); + cx.set_global(AppearanceState { mode, system }); + Theme::change(resolve(mode, system), None, cx); +} + +/// The mode currently in effect (defaults to `System` before [`init`]). +pub fn mode(cx: &App) -> AppearanceMode { + cx.try_global::() + .map(|state| state.mode) + .unwrap_or_default() +} + +/// Change the user's preference and repaint if that changed the palette. +/// Persisting the choice is the caller's job. +pub fn set_mode(mode: AppearanceMode, cx: &mut App) { + if !cx.has_global::() { + return; + } + let state = cx.global_mut::(); + if state.mode == mode { + return; + } + state.mode = mode; + // Coming back to `System`, re-read the OS: a pinned mode holds an + // appearance override, so `system` may be stale from before the pin. + if mode == AppearanceMode::System { + let system = ThemeMode::from_window(cx.window_appearance()); + cx.global_mut::().system = system; + } + apply(cx); +} + +/// Subscribe a window to OS appearance changes. The returned [`Subscription`] +/// must outlive the window — callers typically `.detach()` it. +/// +/// The notification is *per window*, but the appearance it reports is a system +/// setting, so any one window is enough to learn about the change; re-applying +/// is idempotent when several fire. +pub fn observe_window(window: &mut Window, cx: &mut App) -> Subscription { + // Reconcile against the *window's* appearance before subscribing: `init` + // runs before any window exists and can only ask the app, which on some + // platforms is not reliably populated that early in launch. + record_system(window.appearance().into(), cx); + window.observe_window_appearance(move |window, cx| { + record_system(window.appearance().into(), cx); + }) +} + +/// Record the OS appearance and re-apply if it moved. `window`, when given, +/// is the more authoritative source for what the OS currently reports. +pub fn record_system_appearance(window: Option<&Window>, cx: &mut App) { + let appearance = match window { + Some(window) => window.appearance(), + None => cx.window_appearance(), + }; + record_system(ThemeMode::from_window(appearance), cx); +} + +/// Record the OS appearance and re-apply if it moved. Only recorded while the +/// user's mode is `System` — a pinned mode ignores the OS anyway, and +/// recording an override read back would overwrite what the OS said with what +/// we asked for. +fn record_system(system: ThemeMode, cx: &mut App) { + if !cx.has_global::() { + return; + } + let state = cx.global_mut::(); + if state.mode != AppearanceMode::System || state.system == system { + return; + } + state.system = system; + apply(cx); +} + +/// Re-resolve the palette and, if it moved, swap the theme and force a full +/// repaint. A no-op when the resolved appearance is unchanged — the OS fires +/// the appearance notification for other changes too, and repainting every +/// window for those would be a visible hitch for nothing. +pub fn apply(cx: &mut App) { + let Some(state) = cx.try_global::() else { + return; + }; + let wanted = resolve(state.mode, state.system); + let changed = !cx + .try_global::() + .is_some_and(|theme| theme.mode == wanted); + if changed { + Theme::change(wanted, None, cx); + cx.refresh_windows(); + } +} diff --git a/crates/theme/src/brand.rs b/crates/theme/src/brand.rs new file mode 100644 index 00000000..d0f31965 --- /dev/null +++ b/crates/theme/src/brand.rs @@ -0,0 +1,294 @@ +//! Brand, ported from bezel (`crates/theme/src/brand.rs`, MIT License, +//! Copyright (c) 2026 bezel contributors): one hue for the greys, one for the +//! accent, one radius, and the text ink. +//! +//! A brand moves hue alone: every lightness in `palette.rs` stays as the +//! palette set it. + +use gpui::{App, Global, Hsla}; + +use crate::{ThemeColors, ThemeMode, color, layout}; + +/// A hue and how much of it, in oklch terms. `chroma: 0.0` is the shipped +/// neutral, so [`Brand::default`] reproduces the built-in palette exactly. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Tint { + /// oklch hue, in degrees. + pub hue: f32, + /// oklch chroma. Neutral ramps live near 0.01–0.05; an accent carries more. + pub chroma: f32, +} + +impl Tint { + pub const NONE: Self = Self { + hue: 0.0, + chroma: 0.0, + }; + + pub const fn new(hue: f32, chroma: f32) -> Self { + Self { hue, chroma } + } +} + +/// The greys a UI is built on, as oklch hue and chroma. +/// +/// Tailwind's five neutral families at their 500 step (tailwindcss.com/docs/colors) +/// — the same list shadcn offers as its base colour, and the reason these are +/// quoted rather than invented: a neutral that carries hue is a judgement +/// someone else has already made five times. +pub const BASE_COLORS: [(&str, Tint); 5] = [ + ("Neutral", Tint::NONE), + ("Stone", Tint::new(58.071, 0.013)), + ("Zinc", Tint::new(285.938, 0.016)), + ("Gray", Tint::new(264.364, 0.027)), + ("Slate", Tint::new(257.417, 0.046)), +]; + +/// The alpha of each text role over the appearance's ink — white in dark, +/// black in light. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TextInk { + /// [`ThemeColors::text`]. + pub text: f32, + /// [`ThemeColors::text_muted`]. + pub muted: f32, + /// [`ThemeColors::text_faint`]. + pub faint: f32, +} + +impl TextInk { + /// The role at `alpha` over `appearance`'s ink. + pub fn color(appearance: ThemeMode, alpha: f32) -> Hsla { + match appearance { + ThemeMode::Dark => gpui::hsla(0.0, 0.0, 1.0, alpha), + ThemeMode::Light => gpui::hsla(0.0, 0.0, 0.0, alpha), + } + } + + /// Writes [`ThemeColors::text`], [`ThemeColors::text_muted`] and + /// [`ThemeColors::text_faint`] for the appearance. + pub fn paint(&self, appearance: ThemeMode, colors: &mut ThemeColors) { + colors.text = Self::color(appearance, self.text); + colors.text_muted = Self::color(appearance, self.muted); + colors.text_faint = Self::color(appearance, self.faint); + } +} + +/// Text ink for both appearances. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Ink { + pub dark: TextInk, + pub light: TextInk, +} + +impl Ink { + /// Text and muted at 4.5:1 or more and faint at 3:1 or more, on + /// [`ThemeColors::bg`] and [`ThemeColors::surface`] in both appearances. + pub const ACCESSIBLE: Self = Self { + dark: TextInk { + text: 0.85, + muted: 0.52, + faint: 0.40, + }, + light: TextInk { + text: 0.85, + muted: 0.56, + faint: 0.43, + }, + }; + /// AppKit's label, secondary and tertiary label colours. Muted is under + /// 4.5:1 on white, and faint is under 3:1 in both appearances. + pub const APPKIT: Self = Self { + dark: TextInk { + text: 0.85, + muted: 0.55, + faint: 0.25, + }, + light: TextInk { + text: 0.85, + muted: 0.50, + faint: 0.26, + }, + }; + + pub fn for_appearance(&self, appearance: ThemeMode) -> TextInk { + match appearance { + ThemeMode::Dark => self.dark, + ThemeMode::Light => self.light, + } + } +} + +/// What an app changes about the shipped palette without redesigning it. +/// +/// Installed as a gpui [`Global`] via [`crate::AppExt::set_brand`]; every +/// palette install applies it, so it survives a light/dark switch and composes +/// with [`crate::AppExt::set_palette`] rather than competing with it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Brand { + /// The hue every grey in the palette carries. + pub tint: Tint, + /// The emphasis hue. Left neutral, the accent follows [`Self::tint`] like + /// any other grey — which is what the shipped palette already is. + pub accent: Tint, + /// The base corner radius; every other corner is a ratio of it. See + /// [`crate::BASE_RADIUS`]. + pub radius: f32, + /// The text roles' alphas. [`Ink::APPKIT`] is what the palettes ship. + pub ink: Ink, +} + +impl Global for Brand {} + +impl Default for Brand { + fn default() -> Self { + Self { + tint: Tint::NONE, + accent: Tint::NONE, + radius: layout::BASE_RADIUS, + ink: Ink::APPKIT, + } + } +} + +impl Brand { + /// Whether applying this brand would change nothing — the shipped palette + /// already carries these values, so a JSON theme's own text colors must be + /// left alone. + pub fn is_default(&self) -> bool { + *self == Self::default() + } + + /// The shipped palette for an appearance, rotated onto this brand. + pub fn branded(&self, appearance: ThemeMode) -> ThemeColors { + let mut colors = ThemeColors::for_appearance(appearance); + self.apply(appearance, &mut colors); + colors + } + + /// Rotate a palette onto this brand's hues. + pub fn apply(&self, appearance: ThemeMode, colors: &mut ThemeColors) { + self.ink + .for_appearance(appearance) + .paint(appearance, colors); + // Every colour token, with the rule doing the choosing: a token that is + // already grey takes the tint, and one that already carries a hue — + // danger, warning, success — is semantic and keeps it. Translucent ink + // is skipped because it paints over whatever is beneath it, which is + // tinted already. + let tokens: [&mut Hsla; 41] = [ + &mut colors.bg, + &mut colors.surface, + &mut colors.surface_raised, + &mut colors.surface_card, + &mut colors.surface_dialog, + &mut colors.surface_overlay, + &mut colors.element_hover, + &mut colors.element_active, + &mut colors.border_faint, + &mut colors.border, + &mut colors.border_strong, + &mut colors.text, + &mut colors.text_muted, + &mut colors.text_faint, + &mut colors.text_dim, + &mut colors.solid, + &mut colors.on_solid, + &mut colors.accent, + &mut colors.accent_strong, + &mut colors.on_accent, + &mut colors.danger, + &mut colors.danger_muted, + &mut colors.warning, + &mut colors.warning_muted, + &mut colors.success, + &mut colors.busy, + &mut colors.success_muted, + &mut colors.surface_raised_hover, + &mut colors.band, + &mut colors.input_bg, + &mut colors.selection, + &mut colors.cursor, + &mut colors.caret, + &mut colors.ring, + &mut colors.drop_target, + &mut colors.danger_strong, + &mut colors.code_text, + &mut colors.code_wash, + &mut colors.diff_add, + &mut colors.diff_del, + &mut colors.diff_hunk_bg, + ]; + let syntax: [&mut Hsla; 24] = [ + &mut colors.syntax.comment, + &mut colors.syntax.keyword, + &mut colors.syntax.string, + &mut colors.syntax.string_special, + &mut colors.syntax.escape, + &mut colors.syntax.number, + &mut colors.syntax.boolean, + &mut colors.syntax.type_name, + &mut colors.syntax.type_builtin, + &mut colors.syntax.constructor, + &mut colors.syntax.function, + &mut colors.syntax.function_builtin, + &mut colors.syntax.macro_name, + &mut colors.syntax.property, + &mut colors.syntax.constant, + &mut colors.syntax.variable, + &mut colors.syntax.variable_special, + &mut colors.syntax.parameter, + &mut colors.syntax.operator, + &mut colors.syntax.punctuation, + &mut colors.syntax.tag, + &mut colors.syntax.attribute, + &mut colors.syntax.label, + &mut colors.syntax.invalid, + ]; + for slot in tokens.into_iter().chain(syntax) { + if slot.a == 1.0 && slot.s <= f32::EPSILON { + *slot = color::tint(*slot, self.tint.hue, self.tint.chroma); + } + } + + if self.accent.chroma > 0.0 { + let (accent_l, plate_l) = if appearance.is_light() { + (ACCENT_L.1, PLATE_L.1) + } else { + (ACCENT_L.0, PLATE_L.0) + }; + colors.accent = color::oklch(accent_l, self.accent.chroma, self.accent.hue); + colors.accent_strong = color::oklch(plate_l, self.accent.chroma, self.accent.hue); + // Whichever label the plate can actually hold. The shipped accent is + // the maximum-contrast neutral, where the answer is always the + // inverse; a chromatic plate at a yellow hue is bright enough that + // the inverse would be the unreadable one. + colors.on_accent = label_on(colors.accent_strong, colors); + } + } +} + +/// The accent's lightness in each appearance — indigo-400's and indigo-600's, +/// the two steps `palette.rs` picked so an accent clears WCAG AA on its own +/// background rather than glowing on one and vanishing on the other. +const ACCENT_L: (f32, f32) = (0.673, 0.511); + +/// The lightness of a *plate* carrying [`ThemeColors::on_accent`], taken from +/// `danger_strong` — the palette's existing chromatic plate, already tuned to +/// hold a label in both appearances. +const PLATE_L: (f32, f32) = (0.58, 0.51); + +/// Whichever of the palette's two extremes the plate can actually hold. +fn label_on(plate: Hsla, colors: &ThemeColors) -> Hsla { + let (a, b) = (colors.solid, colors.on_solid); + if color::contrast_ratio(plate, a) >= color::contrast_ratio(plate, b) { + a + } else { + b + } +} + +/// Read the installed brand (the default before one is set). +pub fn installed(cx: &App) -> Brand { + cx.try_global::().copied().unwrap_or_default() +} diff --git a/crates/theme/src/color.rs b/crates/theme/src/color.rs new file mode 100644 index 00000000..d1e8d3e0 --- /dev/null +++ b/crates/theme/src/color.rs @@ -0,0 +1,215 @@ +//! Color math ported from bezel (`crates/theme/src/color.rs`, MIT License, +//! Copyright (c) 2026 bezel contributors): the oklch → sRGB → HSL converters +//! behind the token constructors, WCAG contrast for the palette tests, and +//! small paint helpers. + +use gpui::{Hsla, hsla}; + +/// A neutral (chroma 0) oklch tone as Hsla. Chroma 0 means r == g == b exactly, +/// so this goes straight to an achromatic Hsla (skipping the hue math avoids +/// float-noise saturation). +pub fn neutral(lightness: f32) -> Hsla { + let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0); + hsla(0.0, 0.0, v, 1.0) +} + +/// An exact achromatic tone from an 8-bit channel value (`grey(13)` ≡ `#0d0d0d`) +/// — for surfaces matched against reference samples. +pub fn grey(value: u8) -> Hsla { + hsla(0.0, 0.0, value as f32 / 255.0, 1.0) +} + +/// Convert an oklch color (CSS notation: L 0..1, C, H in degrees) to gpui Hsla. +pub fn oklch(l: f32, c: f32, h_deg: f32) -> Hsla { + let [r, g, b] = oklch_to_srgb(l, c, h_deg); + let (h, s, l) = rgb_to_hsl(r, g, b); + hsla(h, s, l, 1.0) +} + +/// oklch → sRGB (each 0..1, clamped/gamut-clipped per channel). +/// Reference: Björn Ottosson's OKLab definition (the same matrices CSS Color 4 uses). +pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> [f32; 3] { + let [r, g, b] = oklch_to_linear(l, c, h_deg); + [gamma_encode(r), gamma_encode(g), gamma_encode(b)] +} + +/// oklch → *linear* sRGB, unclamped: a component outside 0..1 is a color the +/// display cannot make, which is what [`fit_chroma`] tests for. +fn oklch_to_linear(l: f32, c: f32, h_deg: f32) -> [f32; 3] { + let h = h_deg.to_radians(); + let a = c * h.cos(); + let b = c * h.sin(); + + // OKLab → LMS (cube roots undone) + let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b; + let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b; + let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b; + let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); + + // LMS → linear sRGB + let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_93 * s3; + let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_4 * s3; + let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3; + + [r, g, b] +} + +fn gamma_encode(x: f32) -> f32 { + let x = x.clamp(0.0, 1.0); + if x <= 0.003_130_8 { + 12.92 * x + } else { + 1.055 * x.powf(1.0 / 2.4) - 0.055 + } +} + +fn gamma_decode(x: f32) -> f32 { + let x = x.clamp(0.0, 1.0); + if x <= 0.040_449_936 { + x / 12.92 + } else { + ((x + 0.055) / 1.055).powf(2.4) + } +} + +/// The oklch lightness of an achromatic tone: [`neutral`] inverted. +/// +/// Only greys round-trip through this. A grey's three channels are equal, and +/// the three LMS matrix rows each sum to 1, so the whole OKLab transform +/// collapses to one cube root — no inverse matrix needed. +pub fn lightness(color: Hsla) -> f32 { + gamma_decode(color.l).cbrt() +} + +/// The most chroma sRGB can hold at this lightness and hue, up to `chroma`. +/// +/// Near black and near white the gamut is a needle: asking for a mid-ramp +/// chroma there produces an out-of-range component, and clamping it per channel +/// shifts the hue instead of dropping the saturation. +fn fit_chroma(l: f32, chroma: f32, hue: f32) -> f32 { + let fits = |c: f32| { + oklch_to_linear(l, c, hue) + .iter() + .all(|x| (-1e-4..=1.0 + 1e-4).contains(x)) + }; + if fits(chroma) { + return chroma; + } + let (mut lo, mut hi) = (0.0, chroma); + for _ in 0..24 { + let mid = 0.5 * (lo + hi); + if fits(mid) { lo = mid } else { hi = mid } + } + lo +} + +/// Re-emit an achromatic tone at `hue`, carrying as much `chroma` as its +/// lightness can hold. Alpha rides through untouched. +pub fn tint(color: Hsla, hue: f32, chroma: f32) -> Hsla { + if chroma <= 0.0 { + return color; + } + let l = lightness(color); + let mut out = oklch(l, fit_chroma(l, chroma, hue), hue); + out.a = color.a; + out +} + +/// sRGB (0..1 components) → HSL, all components 0..1 (gpui's Hsla convention). +pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + let delta = max - min; + if delta < f32::EPSILON { + return (0.0, 0.0, l); + } + let s = if l > 0.5 { + delta / (2.0 - max - min) + } else { + delta / (max + min) + }; + let h = if (max - r).abs() < f32::EPSILON { + ((g - b) / delta).rem_euclid(6.0) + } else if (max - g).abs() < f32::EPSILON { + (b - r) / delta + 2.0 + } else { + (r - g) / delta + 4.0 + } / 6.0; + (h, s, l) +} + +/// HSL (gpui convention, all 0..1) → sRGB components 0..1. +pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] { + if s <= f32::EPSILON { + return [l, l, l]; + } + let q = if l < 0.5 { + l * (1.0 + s) + } else { + l + s - l * s + }; + let p = 2.0 * l - q; + let hue = |mut t: f32| { + t = t.rem_euclid(1.0); + if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + } + }; + [hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)] +} + +/// WCAG 2.1 relative luminance of an opaque color. +pub fn relative_luminance(color: Hsla) -> f32 { + let lin = |c: f32| { + if c <= 0.040_45 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }; + let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l); + 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) +} + +/// WCAG 2.1 contrast ratio between two opaque colors (1.0 … 21.0). +/// +/// Used by the palette tests to prove each light token reproduces the contrast +/// its dark counterpart had, rather than merely looking plausible. +pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 { + let (la, lb) = (relative_luminance(a), relative_luminance(b)); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) +} + +/// Composite `fg` (which may be translucent) over an opaque `bg`, returning the +/// opaque result — the color the eye actually receives. +pub fn flatten(fg: Hsla, bg: Hsla) -> Hsla { + let a = fg.a.clamp(0.0, 1.0); + let [fr, fg_, fb] = hsl_to_rgb(fg.h, fg.s, fg.l); + let [br, bg_, bb] = hsl_to_rgb(bg.h, bg.s, bg.l); + let (h, s, l) = rgb_to_hsl( + fr * a + br * (1.0 - a), + fg_ * a + bg_ * (1.0 - a), + fb * a + bb * (1.0 - a), + ); + hsla(h, s, l, 1.0) +} + +/// Linear per-component mix of two colors. +pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla { + let t = t.clamp(0.0, 1.0); + let lerp = |x: f32, y: f32| x + (y - x) * t; + hsla( + lerp(a.h, b.h), + lerp(a.s, b.s), + lerp(a.l, b.l), + lerp(a.a, b.a), + ) +} diff --git a/crates/theme/src/colors.rs b/crates/theme/src/colors.rs index 7d82b037..5a2dc66b 100644 --- a/crates/theme/src/colors.rs +++ b/crates/theme/src/colors.rs @@ -1,10 +1,21 @@ -use gpui::{Hsla, Rgba, hsla}; +use gpui::Hsla; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::scale::{ColorScale, ColorScaleSet}; +use crate::paint::{ink_for, plate_active_for, plate_hover_for, wash_for}; +use crate::syntax::SyntaxPalette; +use crate::{ThemeMode, palette}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)] +/// The full token set. +/// +/// The first groups are the legacy coop tokens; they stay until every call +/// site has moved to the bezel tokens (see docs/ui-theme-bezel-migration.md), +/// and are derived from the tokens by [`ThemeColors::sync_legacy`]. The bezel +/// token groups follow. Deserialization refines over the built-in dark +/// palette, so a JSON that names only some fields still yields a complete +/// palette. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(default)] pub struct ThemeColors { // Surface colors pub background: Hsla, @@ -96,488 +107,379 @@ pub struct ThemeColors { pub drop_target_background: Hsla, pub cursor: Hsla, pub selection: Hsla, + + // Bezel tokens: neutral surfaces + /// Main content panel. + pub bg: Hsla, + /// Shell / sidebar surface. + pub surface: Hsla, + /// Raised surface: opaque pills and chips that sit proud of the panel. + pub surface_raised: Hsla, + /// Inline card resting on the main panel. + pub surface_card: Hsla, + /// Modal dialog, floating over the scrim. + pub surface_dialog: Hsla, + /// Popover, menu and command-palette surface — the highest plane. + pub surface_overlay: Hsla, + + // Bezel tokens: hairlines + /// Faintest hairline: dividers inside a card, nested frames. + pub border_faint: Hsla, + /// Stronger border for focused/raised edges. + pub border_strong: Hsla, + + // Bezel tokens: text + /// Faint text: placeholders, disabled. + pub text_faint: Hsla, + /// One notch below `text_muted` — the diff file-path tone. + pub text_dim: Hsla, + + // Bezel tokens: high-contrast solid + /// The maximum-contrast solid fill: the primary button plate. + pub solid: Hsla, + /// Label/icon color on top of [`ThemeColors::solid`] — its inverse. + pub on_solid: Hsla, + + // Bezel tokens: accents + /// Accent — the emphasis weight for text and icons. Neutral by default. + pub accent: Hsla, + /// Stronger accent for fills that carry [`ThemeColors::on_accent`]. + pub accent_strong: Hsla, + /// Label color on top of [`ThemeColors::accent_strong`]. + pub on_accent: Hsla, + + // Bezel tokens: semantic + /// Danger — red (errors, stop button). + pub danger: Hsla, + /// Softer danger for secondary/inline error copy. + pub danger_muted: Hsla, + /// Destructive-action button fill (danger plate, carries `on_accent`). + pub danger_strong: Hsla, + /// Warning — amber. + pub warning: Hsla, + /// Softer warning for secondary copy. + pub warning_muted: Hsla, + /// Success / online — emerald. + pub success: Hsla, + /// Softer success for text on a success-tinted chip. + pub success_muted: Hsla, + /// Working / streaming indicator — pink. + pub busy: Hsla, + + // Bezel tokens: components + /// Hover tone for an *opaque* raised pill. + pub surface_raised_hover: Hsla, + /// Recessed band behind a header or footer strip. + pub band: Hsla, + /// Input plates. + pub input_bg: Hsla, + /// Text caret. + pub caret: Hsla, + /// Where a dragged block would land. + pub drop_line: Hsla, + /// Fill of the region a dragged pane would dock into. + pub drop_target: Hsla, + + // Bezel tokens: code & diff + /// Inline-code text. + pub code_text: Hsla, + /// Inline-code wash behind [`ThemeColors::code_text`]. + pub code_wash: Hsla, + /// Shared paint-only syntax palette. + pub syntax: SyntaxPalette, + /// Diff: added lines. + pub diff_add: Hsla, + /// Diff: deleted lines. + pub diff_del: Hsla, + /// Diff: hunk-header wash. + pub diff_hunk_bg: Hsla, +} + +impl Default for ThemeColors { + fn default() -> Self { + Self::dark() + } } -/// The default colors for the theme. -/// -/// Themes that do not specify all colors are refined off of these defaults. impl ThemeColors { /// Returns the default colors for light themes. - /// - /// Themes that do not specify all colors are refined off of these defaults. pub(crate) fn light() -> Self { - Self { - background: neutral().light().step_1(), - surface_background: neutral().light().step_2(), - elevated_surface_background: neutral().light().step_3(), - panel_background: neutral().light().step_1(), - overlay: neutral().light_alpha().step_3(), - title_bar: neutral().light().step_2(), - title_bar_inactive: neutral().light().step_1(), - window_border: hsl(240.0, 5.9, 78.0), - - border: neutral().light().step_6(), - border_variant: neutral().light().step_4(), - border_focused: brand().light().step_7(), - border_selected: brand().light().step_7(), - border_transparent: gpui::transparent_black(), - border_disabled: neutral().light().step_3(), - ring: brand().light().step_8(), - - text: neutral().light().step_12(), - text_muted: neutral().light().step_11(), - text_placeholder: neutral().light().step_10(), - text_accent: brand().light().step_11(), - text_danger: danger().light().step_9(), - text_warning: warning().light().step_9(), - - icon: neutral().light().step_11(), - icon_muted: neutral().light().step_10(), - icon_accent: brand().light().step_11(), - - element_foreground: brand().light().step_12(), - element_background: brand().light().step_9(), - element_hover: brand().light_alpha().step_10(), - element_active: brand().light().step_10(), - element_selected: brand().light().step_11(), - element_disabled: brand().light_alpha().step_3(), - - secondary_foreground: brand().light().step_11(), - secondary_background: brand().light().step_3(), - secondary_hover: brand().light_alpha().step_4(), - secondary_active: brand().light().step_5(), - secondary_selected: brand().light().step_5(), - secondary_disabled: brand().light_alpha().step_3(), - - danger_foreground: danger().light().step_12(), - danger_background: danger().light().step_3(), - danger_hover: danger().light_alpha().step_4(), - danger_active: danger().light().step_5(), - danger_selected: danger().light().step_5(), - danger_disabled: danger().light_alpha().step_3(), - - warning_foreground: warning().light().step_12(), - warning_background: warning().light().step_3(), - warning_hover: warning().light_alpha().step_4(), - warning_active: warning().light().step_5(), - warning_selected: warning().light().step_5(), - warning_disabled: warning().light_alpha().step_3(), - - ghost_element_background: gpui::transparent_black(), - ghost_element_background_alt: neutral().light().step_3(), - ghost_element_hover: neutral().light_alpha().step_4(), - ghost_element_active: neutral().light().step_5(), - ghost_element_selected: neutral().light().step_5(), - ghost_element_disabled: neutral().light_alpha().step_2(), - - tab_background: neutral().light().step_3(), - tab_foreground: neutral().light().step_11(), - tab_hover_background: neutral().light_alpha().step_4(), - tab_active_background: neutral().light().step_1(), - tab_active_foreground: neutral().light().step_12(), - - scrollbar_thumb_background: neutral().light_alpha().step_3(), - scrollbar_thumb_hover_background: neutral().light_alpha().step_4(), - scrollbar_thumb_border: gpui::transparent_black(), - scrollbar_track_background: gpui::transparent_black(), - scrollbar_track_border: gpui::transparent_black(), - - drop_target_background: brand().light_alpha().step_2(), - cursor: hsl(200., 100., 50.), - selection: hsl(200., 100., 50.).alpha(0.25), - } + palette::light() } /// Returns the default colors for dark themes. - /// - /// Themes that do not specify all colors are refined off of these defaults. pub(crate) fn dark() -> Self { + palette::dark() + } + + /// Returns the default colors for an appearance. + pub fn for_appearance(appearance: ThemeMode) -> Self { + palette::for_appearance(appearance) + } + + /// A placeholder instance whose legacy fields the palette overwrites. + pub(crate) fn legacy_default() -> Self { + let transparent = gpui::transparent_black(); Self { - background: neutral().dark().step_1(), - surface_background: neutral().dark().step_2(), - elevated_surface_background: neutral().dark().step_3(), - panel_background: neutral().dark().step_1(), - overlay: neutral().dark_alpha().step_3(), - title_bar: neutral().dark().step_2(), - title_bar_inactive: neutral().dark().step_1(), - window_border: hsl(240.0, 3.7, 28.0), + background: transparent, + surface_background: transparent, + elevated_surface_background: transparent, + panel_background: transparent, + overlay: transparent, + title_bar: transparent, + title_bar_inactive: transparent, + window_border: transparent, - border: neutral().dark().step_6(), - border_variant: neutral().dark().step_4(), - border_focused: brand().dark().step_7(), - border_selected: brand().dark().step_7(), - border_transparent: gpui::transparent_black(), - border_disabled: neutral().dark().step_3(), - ring: brand().dark().step_8(), + border: transparent, + border_variant: transparent, + border_focused: transparent, + border_selected: transparent, + border_transparent: transparent, + border_disabled: transparent, + ring: transparent, - text: neutral().dark().step_12(), - text_muted: neutral().dark().step_11(), - text_placeholder: neutral().dark().step_10(), - text_accent: brand().dark().step_11(), - text_danger: danger().dark().step_9(), - text_warning: warning().dark().step_9(), + text: transparent, + text_muted: transparent, + text_placeholder: transparent, + text_accent: transparent, + text_danger: transparent, + text_warning: transparent, - icon: neutral().dark().step_11(), - icon_muted: neutral().dark().step_10(), - icon_accent: brand().dark().step_11(), + icon: transparent, + icon_muted: transparent, + icon_accent: transparent, - element_foreground: brand().dark().step_1(), - element_background: brand().dark().step_9(), - element_hover: brand().dark_alpha().step_10(), - element_active: brand().dark().step_10(), - element_selected: brand().dark().step_11(), - element_disabled: brand().dark_alpha().step_3(), + element_foreground: transparent, + element_background: transparent, + element_hover: transparent, + element_active: transparent, + element_selected: transparent, + element_disabled: transparent, - secondary_foreground: brand().dark().step_12(), - secondary_background: brand().dark().step_3(), - secondary_hover: brand().dark_alpha().step_4(), - secondary_active: brand().dark().step_5(), - secondary_selected: brand().dark().step_5(), - secondary_disabled: brand().dark_alpha().step_3(), + secondary_foreground: transparent, + secondary_background: transparent, + secondary_hover: transparent, + secondary_active: transparent, + secondary_selected: transparent, + secondary_disabled: transparent, - danger_foreground: danger().dark().step_12(), - danger_background: danger().dark().step_3(), - danger_hover: danger().dark_alpha().step_4(), - danger_active: danger().dark().step_5(), - danger_selected: danger().dark().step_5(), - danger_disabled: danger().dark_alpha().step_3(), + danger_foreground: transparent, + danger_background: transparent, + danger_hover: transparent, + danger_active: transparent, + danger_selected: transparent, + danger_disabled: transparent, - warning_foreground: warning().dark().step_12(), - warning_background: warning().dark().step_3(), - warning_hover: warning().dark_alpha().step_4(), - warning_active: warning().dark().step_5(), - warning_selected: warning().dark().step_5(), - warning_disabled: warning().dark_alpha().step_3(), + warning_foreground: transparent, + warning_background: transparent, + warning_hover: transparent, + warning_active: transparent, + warning_selected: transparent, + warning_disabled: transparent, - ghost_element_background: gpui::transparent_black(), - ghost_element_background_alt: neutral().dark().step_3(), - ghost_element_hover: neutral().dark_alpha().step_4(), - ghost_element_active: neutral().dark().step_5(), - ghost_element_selected: neutral().dark().step_5(), - ghost_element_disabled: neutral().dark_alpha().step_2(), + ghost_element_background: transparent, + ghost_element_background_alt: transparent, + ghost_element_hover: transparent, + ghost_element_active: transparent, + ghost_element_selected: transparent, + ghost_element_disabled: transparent, - tab_background: neutral().dark().step_3(), - tab_foreground: neutral().dark().step_11(), - tab_hover_background: neutral().dark_alpha().step_4(), - tab_active_background: neutral().dark().step_1(), - tab_active_foreground: neutral().dark().step_12(), + tab_background: transparent, + tab_foreground: transparent, + tab_hover_background: transparent, + tab_active_background: transparent, + tab_active_foreground: transparent, - scrollbar_thumb_background: neutral().dark_alpha().step_3(), - scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(), - scrollbar_thumb_border: gpui::transparent_black(), - scrollbar_track_background: gpui::transparent_black(), - scrollbar_track_border: gpui::transparent_black(), + scrollbar_thumb_background: transparent, + scrollbar_thumb_hover_background: transparent, + scrollbar_thumb_border: transparent, + scrollbar_track_background: transparent, + scrollbar_track_border: transparent, - drop_target_background: brand().dark_alpha().step_2(), - cursor: hsl(200., 100., 50.), - selection: hsl(200., 100., 50.).alpha(0.25), + drop_target_background: transparent, + cursor: transparent, + selection: transparent, + + bg: transparent, + surface: transparent, + surface_raised: transparent, + surface_card: transparent, + surface_dialog: transparent, + surface_overlay: transparent, + + border_faint: transparent, + border_strong: transparent, + + text_faint: transparent, + text_dim: transparent, + + solid: transparent, + on_solid: transparent, + + accent: transparent, + accent_strong: transparent, + on_accent: transparent, + + danger: transparent, + danger_muted: transparent, + danger_strong: transparent, + warning: transparent, + warning_muted: transparent, + success: transparent, + success_muted: transparent, + busy: transparent, + + surface_raised_hover: transparent, + band: transparent, + input_bg: transparent, + caret: transparent, + drop_line: transparent, + drop_target: transparent, + + code_text: transparent, + code_wash: transparent, + syntax: crate::syntax::SyntaxPalette::dark(transparent, transparent, transparent), + diff_add: transparent, + diff_del: transparent, + diff_hunk_bg: transparent, } } -} -fn neutral() -> ColorScaleSet { - gray() -} + /// Derive the legacy fields from the tokens. + pub(crate) fn sync_legacy(&mut self, appearance: ThemeMode) { + // Surfaces + self.background = self.bg; + self.surface_background = self.surface; + self.elevated_surface_background = self.surface_card; + self.panel_background = self.surface; + self.overlay = ink_for(appearance, 0.10); + self.title_bar = gpui::transparent_black(); + self.title_bar_inactive = self.bg; + self.window_border = self.border; -fn brand() -> ColorScaleSet { - yellow() -} + // Borders + self.border_variant = self.border_faint; + self.border_focused = self.border_strong; + self.border_selected = self.border_strong; + self.border_transparent = gpui::transparent_black(); + self.border_disabled = self.border_faint; -fn danger() -> ColorScaleSet { - tomato() -} + // Text + self.text_placeholder = self.text_faint; + self.text_accent = self.accent; + self.text_danger = self.danger; + self.text_warning = self.warning; -fn warning() -> ColorScaleSet { - orange() -} + // Icons + self.icon = self.text_muted; + self.icon_muted = self.text_faint; + self.icon_accent = self.accent; -/// Make a [gpui::Hsla] color. -/// -/// - h: 0..360.0 -/// - s: 0.0..100.0 -/// - l: 0.0..100.0 -pub(crate) fn hsl(h: f32, s: f32, l: f32) -> Hsla { - hsla(h / 360., s / 100.0, l / 100.0, 1.0) -} + // Elements + self.element_foreground = self.on_solid; + self.element_background = self.solid; + self.element_selected = self.element_active; + self.element_disabled = wash_for(appearance, 0.06); -type StaticColorScale = [&'static str; 12]; + // Secondary + self.secondary_foreground = self.text; + self.secondary_background = self.surface_raised; + self.secondary_hover = self.element_hover; + self.secondary_active = self.element_active; + self.secondary_selected = self.element_active; + self.secondary_disabled = wash_for(appearance, 0.06); -struct StaticColorScaleSet { - light: StaticColorScale, - light_alpha: StaticColorScale, - dark: StaticColorScale, - dark_alpha: StaticColorScale, -} + // Danger + self.danger_foreground = self.on_accent; + self.danger_background = self.danger_strong; + self.danger_hover = plate_hover_for(appearance, self.danger_strong); + self.danger_active = plate_active_for(appearance, self.danger_strong); + self.danger_selected = self.danger_muted; + self.danger_disabled = wash_for(appearance, 0.06); -impl TryFrom for ColorScaleSet { - type Error = anyhow::Error; + // Warning + self.warning_foreground = self.text; + self.warning_background = self.warning_muted; + self.warning_hover = self.element_hover; + self.warning_active = self.element_active; + self.warning_selected = self.element_active; + self.warning_disabled = wash_for(appearance, 0.06); - fn try_from(value: StaticColorScaleSet) -> Result { - fn to_color_scale(scale: StaticColorScale) -> Result { - scale - .into_iter() - .map(|color| Rgba::try_from(color).map(Hsla::from)) - .collect::, _>>() - .map(ColorScale::from_iter) - } + // Ghost + self.ghost_element_background = gpui::transparent_black(); + self.ghost_element_background_alt = self.surface_raised; + self.ghost_element_hover = self.element_hover; + self.ghost_element_active = self.element_active; + self.ghost_element_selected = self.element_active; + self.ghost_element_disabled = wash_for(appearance, 0.06); - Ok(Self::new( - to_color_scale(value.light)?, - to_color_scale(value.light_alpha)?, - to_color_scale(value.dark)?, - to_color_scale(value.dark_alpha)?, - )) + // Tabs + self.tab_background = self.surface; + self.tab_foreground = self.text_muted; + self.tab_hover_background = self.element_hover; + self.tab_active_background = self.bg; + self.tab_active_foreground = self.text; + + // Scrollbar + self.scrollbar_thumb_background = ink_for(appearance, 0.20); + self.scrollbar_thumb_hover_background = ink_for(appearance, 0.30); + self.scrollbar_thumb_border = gpui::transparent_black(); + self.scrollbar_track_background = gpui::transparent_black(); + self.scrollbar_track_border = self.border_faint; + + // Interactive + self.drop_target_background = self.drop_target; + } + + /// Best-effort re-derivation of the tokens from a legacy palette, for + /// old-schema JSON themes loaded through the registry. Fields with no + /// legacy source (`success`, `busy`, the syntax palette, the diff colors) + /// keep the built-in palette's value for `appearance`. + pub fn rederive_tokens(&mut self, appearance: ThemeMode) { + let base = Self::for_appearance(appearance); + self.success = base.success; + self.success_muted = base.success_muted; + self.busy = base.busy; + self.syntax = base.syntax; + self.diff_add = base.diff_add; + self.diff_del = base.diff_del; + self.diff_hunk_bg = base.diff_hunk_bg; + + self.bg = self.background; + self.surface = self.surface_background; + self.surface_raised = self.elevated_surface_background; + self.surface_card = self.elevated_surface_background; + self.surface_dialog = self.elevated_surface_background; + self.surface_overlay = self.elevated_surface_background; + + self.border_faint = self.border_variant; + self.border_strong = self.border_focused; + + self.text_faint = self.text_placeholder; + self.text_dim = self.text_muted; + + self.solid = self.element_background; + self.on_solid = self.element_foreground; + + self.accent = self.text_accent; + self.accent_strong = self.element_background; + self.on_accent = self.element_foreground; + + self.danger = self.text_danger; + self.danger_muted = self.text_danger; + self.danger_strong = self.danger_background; + + self.warning = self.text_warning; + self.warning_muted = self.warning_background; + + self.surface_raised_hover = self.element_hover; + self.band = self.ghost_element_background_alt; + self.input_bg = self.surface_background; + self.caret = self.cursor; + self.drop_line = self.border_focused; + self.drop_target = self.drop_target_background; + + self.code_text = self.text; + self.code_wash = self.ghost_element_hover; } } - -pub(crate) fn gray() -> ColorScaleSet { - StaticColorScaleSet { - light: [ - "#fcfcfcff", - "#f9f9f9ff", - "#f0f0f0ff", - "#e8e8e8ff", - "#e0e0e0ff", - "#d9d9d9ff", - "#cececeff", - "#bbbbbbff", - "#8d8d8dff", - "#838383ff", - "#646464ff", - "#202020ff", - ], - light_alpha: [ - "#00000003", - "#00000006", - "#0000000f", - "#00000017", - "#0000001f", - "#00000026", - "#00000031", - "#00000044", - "#00000072", - "#0000007c", - "#0000009b", - "#000000df", - ], - dark: [ - "#111111ff", - "#191919ff", - "#222222ff", - "#2a2a2aff", - "#313131ff", - "#3a3a3aff", - "#484848ff", - "#606060ff", - "#6e6e6eff", - "#7b7b7bff", - "#b4b4b4ff", - "#eeeeeeff", - ], - dark_alpha: [ - "#00000000", - "#ffffff09", - "#ffffff12", - "#ffffff1b", - "#ffffff22", - "#ffffff2c", - "#ffffff3b", - "#ffffff55", - "#ffffff64", - "#ffffff72", - "#ffffffaf", - "#ffffffed", - ], - } - .try_into() - .unwrap() -} - -pub(crate) fn yellow() -> ColorScaleSet { - StaticColorScaleSet { - light: [ - "#fdfdf9ff", - "#fefce9ff", - "#fffab8ff", - "#fff394ff", - "#ffe770ff", - "#f3d768ff", - "#e4c767ff", - "#d5ae39ff", - "#ffe629ff", - "#ffdc00ff", - "#9e6c00ff", - "#473b1fff", - ], - light_alpha: [ - "#aaaa0006", - "#f4dd0016", - "#ffee0047", - "#ffe3016b", - "#ffd5008f", - "#ebbc0097", - "#d2a10098", - "#c99700c6", - "#ffe100d6", - "#ffdc00ff", - "#9e6c00ff", - "#2e2000e0", - ], - dark: [ - "#14120bff", - "#1b180fff", - "#2d2305ff", - "#362b00ff", - "#433500ff", - "#524202ff", - "#665417ff", - "#836a21ff", - "#ffe629ff", - "#ffff57ff", - "#f5e147ff", - "#f6eeb4ff", - ], - dark_alpha: [ - "#d1510004", - "#f9b4000b", - "#ffaa001e", - "#fdb70028", - "#febb0036", - "#fec40046", - "#fdcb225c", - "#fdca327b", - "#ffe629ff", - "#ffff57ff", - "#fee949f5", - "#fef6baf6", - ], - } - .try_into() - .unwrap() -} - -pub(crate) fn orange() -> ColorScaleSet { - StaticColorScaleSet { - light: [ - "#fefcfbff", - "#fff7edff", - "#ffefd6ff", - "#ffdfb5ff", - "#ffd19aff", - "#ffc182ff", - "#f5ae73ff", - "#ec9455ff", - "#f76b15ff", - "#ef5f00ff", - "#cc4e00ff", - "#582d1dff", - ], - light_alpha: [ - "#c0400004", - "#ff8e0012", - "#ff9c0029", - "#ff91014a", - "#ff8b0065", - "#ff81007d", - "#ed6c008c", - "#e35f00aa", - "#f65e00ea", - "#ef5f00ff", - "#cc4e00ff", - "#431200e2", - ], - dark: [ - "#17120eff", - "#1e160fff", - "#331e0bff", - "#462100ff", - "#562800ff", - "#66350cff", - "#7e451dff", - "#a35829ff", - "#f76b15ff", - "#ff801fff", - "#ffa057ff", - "#ffe0c2ff", - ], - dark_alpha: [ - "#ec360007", - "#fe6d000e", - "#fb6a0025", - "#ff590039", - "#ff61004a", - "#fd75045c", - "#ff832c75", - "#fe84389d", - "#fe6d15f7", - "#ff801fff", - "#ffa057ff", - "#ffe0c2ff", - ], - } - .try_into() - .unwrap() -} - -pub(crate) fn tomato() -> ColorScaleSet { - StaticColorScaleSet { - light: [ - "#fffcfcff", - "#fff8f7ff", - "#feebe7ff", - "#ffdcd3ff", - "#ffcdc2ff", - "#fdbdafff", - "#f5a898ff", - "#ec8e7bff", - "#e54d2eff", - "#dd4425ff", - "#d13415ff", - "#5c271fff", - ], - light_alpha: [ - "#ff000003", - "#ff200008", - "#f52b0018", - "#ff35002c", - "#ff2e003d", - "#f92d0050", - "#e7280067", - "#db250084", - "#df2600d1", - "#d72400da", - "#cd2200ea", - "#460900e0", - ], - dark: [ - "#181111ff", - "#1f1513ff", - "#391714ff", - "#4e1511ff", - "#5e1c16ff", - "#6e2920ff", - "#853a2dff", - "#ac4d39ff", - "#e54d2eff", - "#ec6142ff", - "#ff977dff", - "#fbd3cbff", - ], - dark_alpha: [ - "#f1121208", - "#ff55330f", - "#ff35232b", - "#fd201142", - "#fe332153", - "#ff4f3864", - "#fd644a7d", - "#fe6d4ea7", - "#fe5431e4", - "#ff6847eb", - "#ff977dff", - "#ffd6cefb", - ], - } - .try_into() - .unwrap() -} diff --git a/crates/theme/src/layout.rs b/crates/theme/src/layout.rs new file mode 100644 index 00000000..a2633922 --- /dev/null +++ b/crates/theme/src/layout.rs @@ -0,0 +1,87 @@ +//! Layout constants, ported from bezel +//! (`crates/theme/src/theme/layout.rs`, MIT License, Copyright (c) 2026 bezel +//! contributors). Numbers drive layout, colors are paint: these live as plain +//! numbers and never depend on which color is painted. + +use std::sync::atomic::{AtomicU32, Ordering}; + +/// The branded base radius behind every radius accessor, as raw `f32` bits. +static BASE: AtomicU32 = AtomicU32::new(BASE_RADIUS.to_bits()); + +/// Point the radius accessors at a base. Called when a palette is installed. +pub(crate) fn set_base_radius(radius: f32) { + BASE.store(radius.to_bits(), Ordering::Relaxed); +} + +/// The gap between siblings. Measured on macOS 26: `NSStackView().spacing`, +/// visual format's `-`, and `constraint(equalToSystemSpacingAfter:multiplier: 1)` +/// all report 8. +pub const SPACE: f32 = 8.0; + +/// The margin from content to its container's edge. Same measurement, +/// visual format's `|-`. +pub const CONTENT_MARGIN: f32 = 20.0; + +/// From a column's edge to the glyph of the control at the end of a row, +/// measured to the glyph rather than the button. Mirrored at the trailing end. +pub const EDGE: f32 = 12.0; + +/// Button, text field and select-trigger height. Measured: +/// `NSButton`, `NSTextField` and `NSPopUpButton` all report 24 at +/// `.regular` — the body line box with 4 above and below. +pub const BUTTON_HEIGHT: f32 = 24.0; + +/// The same controls at `.small`. +pub const CONTROL_HEIGHT_SMALL: f32 = 20.0; + +/// Every radius below is a ratio of this, so a brand radius moves the whole +/// set together and keeps the concentric relationships intact. +pub const BASE_RADIUS: f32 = 8.0; + +/// Message bubble corner radius (2x). +pub fn bubble_radius() -> f32 { + radius(2.0) +} + +/// Floating-surface corner radius — popovers, menus, dialogs (1.5x). +pub fn surface_radius() -> f32 { + radius(1.5) +} + +/// Panel / card corner radius (1.25x). +pub fn panel_radius() -> f32 { + radius(1.25) +} + +/// Button, text field and select-trigger radius (1x). +pub fn button_radius() -> f32 { + radius(1.0) +} + +/// Small control radius (chips, tags, steppers) — a size down from +/// [`button_radius`], for things that sit inside a control rather than being one +/// (0.75x). +pub fn control_radius() -> f32 { + radius(0.75) +} + +/// A corner as a multiple of the branded base radius. +/// +/// Read from a process-wide mirror rather than the theme global for the reason +/// [`crate::paint::current_appearance`] is: the element builders that round a +/// corner are free functions with no `cx` in scope, and a radius is one number +/// for the whole app. +fn radius(ratio: f32) -> f32 { + f32::from_bits(BASE.load(Ordering::Relaxed)) * ratio +} + +/// The concentric child of a surface: a row inset by `inset` inside a +/// container of radius `outer` keeps its corners parallel to the container's, +/// rather than looking pasted onto it. +/// +/// This is SwiftUI's `ContainerRelativeShape` rule done as arithmetic. gpui +/// has no container shape to inherit at paint time, so the relationship is +/// stated where the child is *defined* instead of resolved at runtime. +pub const fn inset_radius(outer: f32, inset: f32) -> f32 { + if outer > inset { outer - inset } else { 0.0 } +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 1bbaee9f..cef3d457 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -1,26 +1,53 @@ use std::ops::{Deref, DerefMut}; use std::rc::Rc; +use appearance::AppearanceMode; use gpui::{App, Global, Pixels, SharedString, Window, px}; +mod brand; +mod color; mod colors; mod geometry; +mod layout; mod notification; +mod paint; +mod palette; mod platform_kind; mod registry; mod scale; mod scrollbar_mode; +mod syntax; mod theme; +pub mod appearance; + +pub use brand::{BASE_COLORS, Brand, Ink, TextInk, Tint}; +pub use color::{ + contrast_ratio, flatten, grey, hsl_to_rgb, lightness, mix, neutral, oklch, oklch_to_srgb, + relative_luminance, rgb_to_hsl, tint, +}; pub use colors::*; pub use geometry::*; +pub use layout::{ + BASE_RADIUS, BUTTON_HEIGHT, CONTENT_MARGIN, CONTROL_HEIGHT_SMALL, EDGE, SPACE, bubble_radius, + button_radius, control_radius, inset_radius, panel_radius, surface_radius, +}; pub use notification::*; +pub use paint::{ + INK_FILL_SCALE, INK_HAIRLINE_SCALE, SCRIM_ALPHA_DARK, band, current_appearance, hairline, ink, + scrim, set_current_appearance, surface_shadows, wash, +}; pub use platform_kind::PlatformKind; pub use registry::*; pub use scale::*; pub use scrollbar_mode::*; +pub use syntax::{HighlightKind, SyntaxPalette}; pub use theme::*; +/// Which appearance the app is painting. Bezel names this enum `Appearance`; +/// coop's `ThemeMode` predates the port and carries the same two variants. +pub type Appearance = ThemeMode; + /// Defines window border radius for platforms that use client side decorations. pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0); @@ -41,24 +68,64 @@ pub const SIDEBAR_WIDTH: Pixels = px(240.); pub fn init(cx: &mut App) { registry::init(cx); - - Theme::sync_system_appearance(None, cx); + appearance::init(AppearanceMode::System, cx); Theme::sync_scrollbar_appearance(cx); } +/// An alternative palette source. Runs first on every appearance install, so +/// an override survives a light/dark switch. +pub type PaletteBuilder = fn(ThemeMode) -> ThemeColors; + +struct GlobalPaletteBuilder(Option); + +impl Global for GlobalPaletteBuilder {} + +/// Install a brand or an alternative palette on the app. +pub trait AppExt { + /// Install a brand and repaint. Call before [`appearance::init`] to have + /// the first frame already branded. + fn set_brand(&mut self, brand: Brand); + /// Install an alternative palette builder and repaint. The builder runs + /// first on every appearance install. + fn set_palette(&mut self, build: PaletteBuilder); + /// Change the appearance preference (`System`, `Light` or `Dark`). + fn set_appearance_mode(&mut self, mode: AppearanceMode); +} + +impl AppExt for App { + fn set_brand(&mut self, brand: Brand) { + self.set_global(brand); + reinstall(self); + } + + fn set_palette(&mut self, build: PaletteBuilder) { + self.set_global(GlobalPaletteBuilder(Some(build))); + reinstall(self); + } + + fn set_appearance_mode(&mut self, mode: AppearanceMode) { + appearance::set_mode(mode, self); + } +} + +/// Re-install the palette for the current appearance. +fn reinstall(cx: &mut App) { + let mode = cx + .try_global::() + .map(|theme| theme.mode) + .unwrap_or_default(); + Theme::change(mode, None, cx); + cx.refresh_windows(); +} + /// Mirror the active coop theme into the `gpui-base` global theme. /// /// Base paints a few things from its own tokens -- the focus ring, the wash /// behind selected text, scrollbars, and overlay backdrops -- so the two /// globals have to agree or those details drift away from the palette. /// -/// Only roles base can act on are projected. Radius, spacing, typography sizes, -/// shadows, and scrollbar geometry keep their base defaults: coop has a single -/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any -/// mapping would be invented rather than derived. -/// -/// This is a no-op before the coop theme global exists; [`Theme::change`] is the -/// authoritative hook that keeps the projection current. +/// This is a no-op before the coop theme global exists; [`Theme::change`] is +/// the authoritative hook that keeps the projection current. pub fn sync_base(cx: &mut App) { let Some(theme) = cx.try_global::() else { return; @@ -83,20 +150,20 @@ pub fn sync_base(cx: &mut App) { base.tokens.typography.sans = font_family; let tokens = &mut base.tokens.colors; - tokens.background = colors.background; + tokens.background = colors.bg; tokens.foreground = colors.text; - tokens.surface = colors.surface_background; + tokens.surface = colors.surface; tokens.surface_foreground = colors.text; - tokens.primary = colors.element_background; - tokens.primary_foreground = colors.element_foreground; - tokens.secondary = colors.secondary_background; - tokens.secondary_foreground = colors.secondary_foreground; - tokens.muted = colors.ghost_element_background_alt; + tokens.primary = colors.solid; + tokens.primary_foreground = colors.on_solid; + tokens.secondary = colors.surface_raised; + tokens.secondary_foreground = colors.text; + tokens.muted = colors.surface_card; tokens.muted_foreground = colors.text_muted; - tokens.accent = colors.ghost_element_hover; + tokens.accent = colors.element_hover; tokens.accent_foreground = colors.text; - tokens.destructive = colors.danger_background; - tokens.destructive_foreground = colors.danger_foreground; + tokens.destructive = colors.danger_strong; + tokens.destructive_foreground = colors.on_accent; tokens.border = colors.border; tokens.input = colors.border; tokens.ring = colors.ring; @@ -128,6 +195,13 @@ pub struct Theme { /// The font family for the application. pub font_family: SharedString, + /// The family prose is set in — what markdown shapes a document's body + /// with. Defaults to `font_family`. + pub font_body: SharedString, + + /// Monospace family for code and terminal. + pub font_mono: SharedString, + /// The root font size for the application, default is 15px. pub font_size: Pixels, @@ -181,12 +255,7 @@ impl Theme { /// Sync the theme with the system appearance pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) { - let appearance = window - .as_ref() - .map(|window| window.appearance()) - .unwrap_or_else(|| cx.window_appearance()); - - Self::change(appearance, window, cx); + appearance::record_system_appearance(window.as_deref(), cx); } /// Sync the Scrollbar showing behavior with the system @@ -221,23 +290,47 @@ impl Theme { } let mode = mode.into(); - let theme = cx.global_mut::(); + let builder = cx + .try_global::() + .and_then(|builder| builder.0); + let mut colors = match builder { + Some(build) => build(mode), + None => { + let family = Theme::global_mut(cx).theme.clone(); + if mode.is_dark() { + *family.dark() + } else { + *family.light() + } + } + }; + + // A default brand changes nothing, so a JSON theme's own colors are + // left alone; anything else rotates the tokens and re-derives the + // legacy fields from them. + let installed_brand = brand::installed(cx); + if !installed_brand.is_default() { + installed_brand.apply(mode, &mut colors); + colors.sync_legacy(mode); + } + + let theme = Theme::global_mut(cx); // Set the theme mode theme.mode = mode; // Set the theme colors - if mode.is_dark() { - theme.colors = *theme.theme.dark(); - } else { - theme.colors = *theme.theme.light(); - } + theme.colors = colors; // Refresh the window if available if let Some(window) = window { window.refresh(); } + // Keep the context-free paint helpers and the radius accessors in step + paint::set_current_appearance(mode); + layout::set_base_radius(installed_brand.radius); + // Keep the base-layer projection in step with the coop palette sync_base(cx); } @@ -263,8 +356,10 @@ impl From for Theme { Theme { font_size: px(15.), font_family: font_family.into(), - radius: px(6.), - radius_lg: px(10.), + font_body: font_family.into(), + font_mono: system_mono().into(), + radius: px(layout::button_radius()), + radius_lg: px(layout::surface_radius()), shadow: true, scrollbar_mode: ScrollbarMode::default(), notification: NotificationSettings::default(), @@ -274,3 +369,16 @@ impl From for Theme { } } } + +/// The mono face has no alias of its own, so each backend is named here. +fn system_mono() -> &'static str { + if cfg!(target_family = "wasm") { + ".ZedMono" + } else if cfg!(target_os = "macos") { + ".AppleSystemUIFontMonospaced" + } else if cfg!(target_os = "windows") { + "Cascadia Mono" + } else { + "DejaVu Sans Mono" + } +} diff --git a/crates/theme/src/paint.rs b/crates/theme/src/paint.rs new file mode 100644 index 00000000..22c6338e --- /dev/null +++ b/crates/theme/src/paint.rs @@ -0,0 +1,181 @@ +//! Context-free paint helpers, ported from bezel +//! (`crates/theme/src/paint.rs`, MIT License, Copyright (c) 2026 bezel +//! contributors): the process-wide appearance mirror and the free functions +//! ([`ink`], [`hairline`], [`wash`], …) that element builders call without a +//! `cx` in scope. + +use std::sync::atomic::{AtomicU8, Ordering}; + +use gpui::{BoxShadow, Hsla, hsla, point, px}; + +use crate::ThemeMode; + +/// Process-wide mirror of the installed theme's appearance. +/// +/// The paint helpers are free functions called from deep inside element +/// builders that have no `cx` in scope, so they read the appearance from here +/// instead of the gpui global. Appearance is genuinely process-wide — one +/// setting for every window — so a single mirror is sound; [`Theme::change`] +/// is the only writer outside tests. +static CURRENT_APPEARANCE: AtomicU8 = AtomicU8::new(0); + +/// The appearance the context-free paint helpers are painting for. +pub fn current_appearance() -> ThemeMode { + match CURRENT_APPEARANCE.load(Ordering::Relaxed) { + 1 => ThemeMode::Light, + _ => ThemeMode::Dark, + } +} + +/// Point the context-free paint helpers at an appearance. Called by +/// [`Theme::change`]; exposed for tests that build a palette without an `App`. +pub fn set_current_appearance(appearance: ThemeMode) { + let encoded = match appearance { + ThemeMode::Dark => 0, + ThemeMode::Light => 1, + }; + CURRENT_APPEARANCE.store(encoded, Ordering::Relaxed); +} + +/// Light-mode alpha multiplier for **fills** (hover/active washes, chip and pill +/// backgrounds). +/// +/// The established light-UI scales (Primer, Radix) land subtle ≈ 3–4%, hover ≈ 8%, +/// selected ≈ 14% black — which is where the dark palette's white alphas already +/// sit. So the honest multiplier is 1: the same number in both appearances, with +/// only the *tone* flipping. Any per-state correction belongs in that state's +/// token, not in a blanket multiplier. +pub const INK_FILL_SCALE: f32 = 1.0; + +/// Light-mode alpha multiplier for **hairlines** (borders, dividers, rings). +/// Opposite of fills: a 1px edge has to hold its own against a bright surround, +/// and the dark palette's white hairlines are deliberately faint. Scaling up +/// keeps separators legible instead of dissolving into the panel. +pub const INK_HAIRLINE_SCALE: f32 = 1.35; + +/// Lightness of [`hairline`] ink, dark then light. +const HAIRLINE_TONE: (f32, f32) = (0.75, 0.25); + +/// Translucent **fill** ink for interactive states and chip plates: soft-white on +/// dark, soft-black on light at [`INK_FILL_SCALE`] of the alpha. +/// +/// Alphas are quoted in *dark-mode terms* at every call site — the dark theme is +/// the tuned one — and the light value is derived. Callers keep one number and +/// both appearances stay in the relationship the dark tuning established. +pub fn ink(alpha: f32) -> Hsla { + ink_for(current_appearance(), alpha) +} + +pub(crate) fn ink_for(appearance: ThemeMode, alpha: f32) -> Hsla { + match appearance { + ThemeMode::Dark => hsla(0.0, 0.0, 1.0, alpha), + ThemeMode::Light => hsla(0.0, 0.0, 0.0, alpha * INK_FILL_SCALE), + } +} + +/// Translucent **hairline** ink for borders, dividers and rings: a light grey on +/// dark, a dark grey on light at [`INK_HAIRLINE_SCALE`] of the alpha. Never +/// pure white or black. +/// +/// Separate from [`ink`] because edges and fills scale in opposite directions +/// when the field brightens — a 1px line needs *more* ink on white, a plate needs +/// less. +pub fn hairline(alpha: f32) -> Hsla { + hairline_for(current_appearance(), alpha) +} + +pub(crate) fn hairline_for(appearance: ThemeMode, alpha: f32) -> Hsla { + match appearance { + ThemeMode::Dark => hsla(0.0, 0.0, HAIRLINE_TONE.0, alpha), + ThemeMode::Light => hsla( + 0.0, + 0.0, + HAIRLINE_TONE.1, + (alpha * INK_HAIRLINE_SCALE).min(0.5), + ), + } +} + +/// Interactive-state wash: a softened [`ink`] that stops short of pure black or +/// white so hover plates read as tinted glass rather than paint. +pub fn wash(alpha: f32) -> Hsla { + wash_for(current_appearance(), alpha) +} + +pub(crate) fn wash_for(appearance: ThemeMode, alpha: f32) -> Hsla { + match appearance { + ThemeMode::Dark => hsla(0.0, 0.0, 0.92, alpha), + ThemeMode::Light => hsla(0.0, 0.0, 0.10, alpha * INK_FILL_SCALE), + } +} + +/// Alpha of the standard modal backdrop in dark mode. Call sites that need a +/// heavier or lighter scrim pass their own dark-mode alpha to [`scrim`]. +pub const SCRIM_ALPHA_DARK: f32 = 0.60; + +/// Modal backdrop at `alpha_dark` (quoted, as everywhere, in dark-mode terms). +/// +/// Black in both appearances — a scrim's job is to darken what is behind it, and +/// a "light scrim" of white would wash the modal out rather than seat it. What +/// changes is strength: on a bright field a dark-mode-weight scrim reads as a +/// blackout, so light mode scales to roughly half. +pub fn scrim(alpha_dark: f32) -> Hsla { + scrim_for(current_appearance(), alpha_dark) +} + +pub(crate) fn scrim_for(appearance: ThemeMode, alpha_dark: f32) -> Hsla { + match appearance { + ThemeMode::Dark => hsla(0.0, 0.0, 0.0, alpha_dark), + ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.32 * (alpha_dark / SCRIM_ALPHA_DARK)), + } +} + +/// Recessed band behind a picker header or footer strip. +/// +/// A free function as well as a token so the chrome that paints it can be built +/// from context-free helpers; both resolve to the same value. +pub fn band() -> Hsla { + band_for(current_appearance()) +} + +pub(crate) fn band_for(appearance: ThemeMode) -> Hsla { + match appearance { + ThemeMode::Dark => hsla(0.0, 0.0, 0.0, 0.16), + // A recessed strip on white needs far less ink than on near-black; the + // dark 16% would read as a bruise. + ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.045), + } +} + +/// The elevation shadow a floating surface casts. Painted cut to outside the +/// shape, because the same drop shadow under a translucent fill reads as a grey +/// plate. +pub fn surface_shadows() -> Vec { + vec![ + BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.1), + offset: point(px(0.0), px(10.0)), + blur_radius: px(15.0), + spread_radius: px(-3.0), + inset: false, + }, + BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.1), + offset: point(px(0.0), px(4.0)), + blur_radius: px(6.0), + spread_radius: px(-4.0), + inset: false, + }, + ] +} + +/// Hover over an opaque plate: brighten the plate in dark mode, darken it in +/// light — never swap an opaque plate for a translucent wash. +pub(crate) fn plate_hover_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla { + crate::color::flatten(ink_for(appearance, 0.10), plate) +} + +/// Pressed over an opaque plate, one rung above [`plate_hover_for`]. +pub(crate) fn plate_active_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla { + crate::color::flatten(ink_for(appearance, 0.16), plate) +} diff --git a/crates/theme/src/palette.rs b/crates/theme/src/palette.rs new file mode 100644 index 00000000..b5b33b9d --- /dev/null +++ b/crates/theme/src/palette.rs @@ -0,0 +1,181 @@ +//! The two concrete palettes: dark and light. Ported from bezel +//! (`crates/theme/src/theme/palettes.rs`, MIT License, Copyright (c) 2026 +//! bezel contributors). +//! +//! Light is designed, not inverted — mirroring lightness reverses surface +//! order, elevation and accent contrast. Each light text token lands within +//! ~0.5 of its dark counterpart's ratio; `tests/contrast.rs` asserts it. + +use gpui::{hsla, rgb}; + +use crate::paint::{band_for, hairline_for}; +use crate::syntax::SyntaxPalette; +use crate::{Ink, TextInk, ThemeColors, ThemeMode, color}; + +/// Build the dark palette. The surface tones are sampled from bezel's +/// reference screenshots: main panel `#060606`, shell/sidebar `#0d0d0d`. +pub(crate) fn dark() -> ThemeColors { + let mut colors = ThemeColors { + // ---- neutral surfaces ---- + bg: color::grey(6), // main panel — #060606 + surface: color::grey(13), // shell / sidebar — #0d0d0d + surface_raised: color::neutral(0.235), + surface_card: color::grey(0x0e), + surface_dialog: color::grey(0x10), + surface_overlay: color::grey(0x16), + // The reference app's `--color-hover`, pure ink: white at 8%. + element_hover: hsla(0.0, 0.0, 1.0, 0.08), + // `--color-active`, a rung above the hover: white at 12%. + element_active: hsla(0.0, 0.0, 1.0, 0.12), + + // ---- hairlines ---- + border_faint: hairline_for(ThemeMode::Dark, 0.06), + border: hairline_for(ThemeMode::Dark, 0.08), + border_strong: hairline_for(ThemeMode::Dark, 0.14), + + // ---- text ---- + text: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.text), + text_muted: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.muted), + text_faint: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.faint), + text_dim: color::grey(0x98), + + // ---- high-contrast solid ---- + solid: color::neutral(0.922), + on_solid: color::grey(0x0e), + + // ---- accents, neutral by default ---- + accent: color::neutral(0.673), + accent_strong: color::neutral(0.922), + on_accent: color::grey(0x0e), + + // ---- semantic ---- + danger: color::oklch(0.704, 0.191, 22.216), // red-400 + danger_muted: color::oklch(0.808, 0.114, 19.571), // red-300 + danger_strong: color::oklch(0.58, 0.16, 25.0), + warning: color::oklch(0.828, 0.189, 84.429), // amber-400 + warning_muted: color::oklch(0.924, 0.12, 95.746), // amber-200 + success: color::oklch(0.765, 0.177, 163.223), // emerald-400 + success_muted: color::oklch(0.845, 0.143, 164.978), // emerald-300 + busy: color::oklch(0.718, 0.202, 349.761), // pink-400 + + // ---- components ---- + surface_raised_hover: color::neutral(0.29), + band: band_for(ThemeMode::Dark), + input_bg: hsla(0.0, 0.0, 1.0, 0.03), + selection: rgb(0x3f638b).into(), + cursor: color::neutral(0.94), + caret: color::neutral(0.673), + ring: hairline_for(ThemeMode::Dark, 0.35), + drop_line: hairline_for(ThemeMode::Dark, 0.35), + drop_target: hsla(0.0, 0.0, 1.0, 0.10), + + // ---- code & diff ---- + code_text: color::neutral(0.94), + code_wash: hsla(0.0, 0.0, 1.0, 0.08), + syntax: SyntaxPalette::dark( + color::neutral(0.922), + color::neutral(0.60), + color::oklch(0.704, 0.191, 22.216), + ), + diff_add: color::oklch(0.765, 0.177, 163.223), // emerald-400 + diff_del: color::oklch(0.704, 0.191, 22.216), // red-400 + diff_hunk_bg: hsla(0.6, 0.35, 0.6, 0.05), + + ..ThemeColors::legacy_default() + }; + colors.sync_legacy(ThemeMode::Dark); + colors +} + +/// Build the light palette. Neutrals are the same oklch scale read from the +/// other end, but the *roles* are reassigned rather than mirrored: content +/// plane white, chrome grey, raised surfaces white-plus-shadow. Text tones +/// reproduce the dark theme's contrast ratios, and accents drop from the 400 +/// to the 600 step at identical hue so they clear WCAG AA on white. +pub(crate) fn light() -> ThemeColors { + let mut colors = ThemeColors { + // ---- neutral surfaces ---- + bg: color::grey(0xff), // main panel — clean white + // Deeper than ~neutral-100 looks on paper: the content card is pure + // white and sits *inside* this surface, so too small a step leaves the + // whole window one flat sheet with a hairline drawn on it. + surface: color::neutral(0.968), + // A real grey, NOT white: this is the opaque-plate tone, and those sit + // directly on the white content plane with no border or shadow to save + // them. + surface_raised: color::neutral(0.940), + surface_card: color::grey(0xff), + surface_dialog: color::grey(0xff), + surface_overlay: color::grey(0xff), + // The same token in light: black at 4%. + element_hover: hsla(0.0, 0.0, 0.0, 0.04), + // The same rung in light: black at 6%. + element_active: hsla(0.0, 0.0, 0.0, 0.06), + + // ---- hairlines ---- + border_faint: hairline_for(ThemeMode::Light, 0.06), + border: hairline_for(ThemeMode::Light, 0.08), + border_strong: hairline_for(ThemeMode::Light, 0.14), + + // ---- text ---- + text: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.text), + text_muted: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.muted), + text_faint: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.faint), + text_dim: color::neutral(0.50), + + // ---- high-contrast solid ---- + solid: color::neutral(0.205), + on_solid: color::neutral(0.985), + + // ---- accents, neutral by default ---- + accent: color::neutral(0.511), + accent_strong: color::neutral(0.205), + on_accent: color::neutral(0.985), + + // ---- semantic ---- + danger: color::oklch(0.577, 0.245, 27.325), // red-600 + danger_muted: color::oklch(0.505, 0.213, 27.518), // red-700 + danger_strong: color::oklch(0.51, 0.20, 25.0), + warning: color::oklch(0.555, 0.163, 48.998), // amber-700 — carries 12px text + warning_muted: color::oklch(0.473, 0.137, 46.201), // amber-800 + success: color::oklch(0.596, 0.145, 163.225), // emerald-600 + success_muted: color::oklch(0.508, 0.118, 165.612), // emerald-700 + busy: color::oklch(0.592, 0.249, 0.584), // pink-600 + + // ---- components ---- + // Opaque pills darken on hover here rather than brighten — same + // "brighten the plate, don't wash it out" rule, read the other way. + surface_raised_hover: color::neutral(0.900), + band: band_for(ThemeMode::Light), + input_bg: color::grey(0xff), + selection: rgb(0xb3d7ff).into(), + cursor: color::neutral(0.205), + caret: color::neutral(0.511), + ring: hairline_for(ThemeMode::Light, 0.35), + drop_line: hairline_for(ThemeMode::Light, 0.35), + drop_target: hsla(0.0, 0.0, 0.0, 0.06), + + // ---- code & diff ---- + code_text: color::neutral(0.18), + code_wash: hsla(0.0, 0.0, 0.0, 0.06), + syntax: SyntaxPalette::light( + color::neutral(0.25), + color::neutral(0.48), + color::oklch(0.505, 0.213, 27.518), + ), + diff_add: color::oklch(0.596, 0.145, 163.225), // emerald-600 + diff_del: color::oklch(0.577, 0.245, 27.325), // red-600 + diff_hunk_bg: hsla(0.6, 0.35, 0.35, 0.07), + + ..ThemeColors::legacy_default() + }; + colors.sync_legacy(ThemeMode::Light); + colors +} + +pub(crate) fn for_appearance(appearance: ThemeMode) -> ThemeColors { + match appearance { + ThemeMode::Dark => dark(), + ThemeMode::Light => light(), + } +} diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs index 3cf3719d..5312b168 100644 --- a/crates/theme/src/registry.rs +++ b/crates/theme/src/registry.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::{Context as AnyhowContext, Error}; use gpui::{App, AppContext, AssetSource, Context, Entity, Global, SharedString}; -use crate::ThemeFamily; +use crate::{ThemeFamily, ThemeMode}; pub fn init(cx: &mut App) { ThemeRegistry::set_global(cx.new(ThemeRegistry::new), cx); @@ -57,8 +57,11 @@ impl ThemeRegistry { // Load the theme file from the assets let content = asset.load(path)?.context("Theme not found")?; - // Parse the JSON content into a Theme Family struct - let theme: ThemeFamily = serde_json::from_slice(&content)?; + // Parse the JSON content into a Theme Family struct, and re-derive the + // tokens from the legacy fields the file carries. + let mut theme: ThemeFamily = serde_json::from_slice(&content)?; + theme.light.rederive_tokens(ThemeMode::Light); + theme.dark.rederive_tokens(ThemeMode::Dark); Ok(theme) } diff --git a/crates/theme/src/scale.rs b/crates/theme/src/scale.rs index 4359a5bd..ef7c1e11 100644 --- a/crates/theme/src/scale.rs +++ b/crates/theme/src/scale.rs @@ -137,6 +137,10 @@ impl ColorScale { } /// Provides groups of [`ColorScale`]s for light and dark themes, as well as transparent versions of each scale. +/// +/// No longer used to build the built-in palettes; kept public until the +/// deprecation window closes (docs/ui-theme-bezel-migration.md, Phase 5). +#[allow(dead_code)] pub struct ColorScaleSet { light: ColorScale, dark: ColorScale, @@ -144,6 +148,7 @@ pub struct ColorScaleSet { dark_alpha: ColorScale, } +#[allow(dead_code)] impl ColorScaleSet { pub(crate) fn new( light: ColorScale, diff --git a/crates/theme/src/syntax.rs b/crates/theme/src/syntax.rs new file mode 100644 index 00000000..5dbaf064 --- /dev/null +++ b/crates/theme/src/syntax.rs @@ -0,0 +1,176 @@ +//! Paint-only syntax colors, ported from bezel +//! (`crates/theme/src/theme/syntax.rs`, MIT License, Copyright (c) 2026 bezel +//! contributors). The hues follow the Git history graph's lane palette +//! (indigo, pink, emerald, amber, red, neutral), while light-mode variants are +//! darkened enough to remain readable as text on white. + +use gpui::Hsla; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::color; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SyntaxPalette { + pub comment: Hsla, + pub keyword: Hsla, + pub string: Hsla, + pub string_special: Hsla, + pub escape: Hsla, + pub number: Hsla, + pub boolean: Hsla, + pub type_name: Hsla, + pub type_builtin: Hsla, + pub constructor: Hsla, + pub function: Hsla, + pub function_builtin: Hsla, + pub macro_name: Hsla, + pub property: Hsla, + pub constant: Hsla, + pub variable: Hsla, + pub variable_special: Hsla, + pub parameter: Hsla, + pub operator: Hsla, + pub punctuation: Hsla, + pub tag: Hsla, + pub attribute: Hsla, + pub label: Hsla, + pub invalid: Hsla, +} + +/// The token category a highlighter paints with, mirroring [`SyntaxPalette`] +/// field-for-field so classification can name a kind without knowing its color. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HighlightKind { + Comment, + Keyword, + String, + StringSpecial, + Escape, + Number, + Boolean, + TypeName, + TypeBuiltin, + Constructor, + Function, + FunctionBuiltin, + MacroName, + Property, + Constant, + Variable, + VariableSpecial, + Parameter, + Operator, + Punctuation, + Tag, + Attribute, + Label, + Invalid, +} + +impl SyntaxPalette { + /// The palette color for a token kind. + pub fn color(&self, kind: HighlightKind) -> Hsla { + match kind { + HighlightKind::Comment => self.comment, + HighlightKind::Keyword => self.keyword, + HighlightKind::String => self.string, + HighlightKind::StringSpecial => self.string_special, + HighlightKind::Escape => self.escape, + HighlightKind::Number => self.number, + HighlightKind::Boolean => self.boolean, + HighlightKind::TypeName => self.type_name, + HighlightKind::TypeBuiltin => self.type_builtin, + HighlightKind::Constructor => self.constructor, + HighlightKind::Function => self.function, + HighlightKind::FunctionBuiltin => self.function_builtin, + HighlightKind::MacroName => self.macro_name, + HighlightKind::Property => self.property, + HighlightKind::Constant => self.constant, + HighlightKind::Variable => self.variable, + HighlightKind::VariableSpecial => self.variable_special, + HighlightKind::Parameter => self.parameter, + HighlightKind::Operator => self.operator, + HighlightKind::Punctuation => self.punctuation, + HighlightKind::Tag => self.tag, + HighlightKind::Attribute => self.attribute, + HighlightKind::Label => self.label, + HighlightKind::Invalid => self.invalid, + } + } + + pub(crate) fn dark(text: Hsla, comment: Hsla, danger: Hsla) -> Self { + let indigo = git_graph_tone(color::oklch(0.673, 0.182, 276.935)); + let pink = git_graph_tone(color::oklch(0.718, 0.202, 349.761)); + let emerald = git_graph_tone(color::oklch(0.765, 0.177, 163.223)); + let amber = git_graph_tone(color::oklch(0.828, 0.189, 84.429)); + let red = git_graph_tone(danger); + Self { + comment, + keyword: indigo, + string: emerald, + string_special: pink, + escape: pink, + number: amber, + boolean: amber, + type_name: amber, + type_builtin: emerald, + constructor: amber, + function: indigo, + function_builtin: pink, + macro_name: pink, + property: amber, + constant: emerald, + variable: text, + variable_special: pink, + parameter: text, + operator: text, + punctuation: text, + tag: pink, + attribute: amber, + label: amber, + invalid: red, + } + } + + pub(crate) fn light(text: Hsla, comment: Hsla, danger: Hsla) -> Self { + let indigo = git_graph_tone(color::oklch(0.47, 0.20, 276.966)); + let pink = git_graph_tone(color::oklch(0.47, 0.17, 0.584)); + let emerald = git_graph_tone(color::oklch(0.46, 0.11, 163.225)); + let amber = git_graph_tone(color::oklch(0.47, 0.12, 48.998)); + let red = git_graph_tone(danger); + Self { + comment, + keyword: indigo, + string: emerald, + string_special: pink, + escape: pink, + number: amber, + boolean: amber, + type_name: amber, + type_builtin: emerald, + constructor: amber, + function: indigo, + function_builtin: pink, + macro_name: pink, + property: amber, + constant: emerald, + variable: text, + variable_special: pink, + parameter: text, + operator: text, + punctuation: text, + tag: pink, + attribute: amber, + label: amber, + invalid: red, + } + } +} + +/// Git history intentionally softens lane saturation so the graph remains +/// colorful without competing with content. Syntax uses the same treatment. +fn git_graph_tone(mut color: Hsla) -> Hsla { + color.s *= 0.72; + color +} diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 3d48f359..f366bc77 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -18,6 +18,15 @@ impl ThemeMode { matches!(self, Self::Dark) } + pub fn is_light(&self) -> bool { + matches!(self, Self::Light) + } + + /// Map a gpui window appearance onto ours. + pub fn from_window(appearance: WindowAppearance) -> Self { + Self::from(appearance) + } + /// Return theme name: `light`, `dark`. pub fn name(&self) -> &'static str { match self { @@ -117,9 +126,11 @@ impl ThemeFamily { /// # Ok(()) /// # } /// ``` - pub(crate) fn from_file>(path: P) -> anyhow::Result { + pub fn from_file>(path: P) -> anyhow::Result { let json_data = std::fs::read(path)?; - let theme_family = serde_json::from_slice(&json_data)?; + let mut theme_family: ThemeFamily = serde_json::from_slice(&json_data)?; + theme_family.light.rederive_tokens(ThemeMode::Light); + theme_family.dark.rederive_tokens(ThemeMode::Dark); Ok(theme_family) } diff --git a/crates/theme/tests/contrast.rs b/crates/theme/tests/contrast.rs new file mode 100644 index 00000000..7994d7f2 --- /dev/null +++ b/crates/theme/tests/contrast.rs @@ -0,0 +1,218 @@ +//! Palette contrast tests, mirroring bezel's `tests/theme.rs`: each light text +//! token must land within ~0.5 of its dark counterpart's contrast ratio +//! against its own background, and the primary text must keep the ratio the +//! palette was tuned for. + +use gpui::Hsla; +use theme::{Brand, ThemeColors, ThemeFamily, ThemeMode, Tint, contrast_ratio, flatten}; + +// The shipped palette's measured floors (AppKit-style ink, per bezel): +// text/bg is 14.4:1 dark and 15.1:1 light, muted lands near 6.3 dark and +// 4.0 light, and faint sits below 3:1 in both appearances by design. +const TEXT_RATIO: f32 = 13.9; +const MUTED_RATIO: f32 = 3.9; +const FAINT_RATIO: f32 = 1.8; +const TEXT_PARITY: f32 = 0.7; + +fn ratio(fg: Hsla, bg: Hsla) -> f32 { + contrast_ratio(flatten(fg, bg), bg) +} + +#[test] +fn text_keeps_its_contrast() { + for appearance in [ThemeMode::Dark, ThemeMode::Light] { + let colors = ThemeColors::for_appearance(appearance); + assert!( + ratio(colors.text, colors.bg) >= TEXT_RATIO, + "{appearance:?}: text on bg = {}", + ratio(colors.text, colors.bg) + ); + assert!( + ratio(colors.text, colors.surface) >= TEXT_RATIO, + "{appearance:?}: text on surface" + ); + assert!( + ratio(colors.text_muted, colors.bg) >= MUTED_RATIO, + "{appearance:?}: muted on bg = {}", + ratio(colors.text_muted, colors.bg) + ); + assert!( + ratio(colors.text_faint, colors.bg) >= FAINT_RATIO, + "{appearance:?}: faint on bg = {}", + ratio(colors.text_faint, colors.bg) + ); + } +} + +#[test] +fn light_matches_dark_contrast() { + let dark = ThemeColors::for_appearance(ThemeMode::Dark); + let light = ThemeColors::for_appearance(ThemeMode::Light); + + // Primary text keeps near-parity across appearances. Muted and faint do + // not (the light values sit further from the background by design, see + // Ink::APPKIT); their floors are asserted in `text_keeps_its_contrast`. + let pairs = [ + ("text/bg", dark.text, dark.bg, light.text, light.bg), + ( + "text/surface", + dark.text, + dark.surface, + light.text, + light.surface, + ), + ]; + for (name, dark_fg, dark_bg, light_fg, light_bg) in pairs { + let dark_ratio = ratio(dark_fg, dark_bg); + let light_ratio = ratio(light_fg, light_bg); + let delta = (dark_ratio - light_ratio).abs(); + assert!( + delta <= TEXT_PARITY, + "{name}: light deviates by {delta} (dark {dark_ratio:.3}, light {light_ratio:.3})" + ); + } +} + +#[test] +fn default_brand_is_a_no_op() { + let brand = Brand::default(); + assert!(brand.is_default()); + for appearance in [ThemeMode::Dark, ThemeMode::Light] { + let base = ThemeColors::for_appearance(appearance); + let branded = brand.branded(appearance); + assert_eq!( + base, branded, + "{appearance:?}: default brand must not move the palette" + ); + } +} + +#[test] +fn brand_tint_rotates_greys_and_keeps_semantics() { + let brand = Brand { + tint: Tint::new(257.417, 0.046), + accent: Tint::new(276.935, 0.182), + ..Brand::default() + }; + let base = ThemeColors::for_appearance(ThemeMode::Dark); + let branded = brand.branded(ThemeMode::Dark); + + assert_ne!(branded.bg, base.bg, "bg takes the tint"); + assert_ne!(branded.accent, base.accent, "accent takes the accent hue"); + assert_eq!( + branded.danger, base.danger, + "danger is semantic and keeps its hue" + ); + assert_eq!( + branded.warning, base.warning, + "warning is semantic and keeps its hue" + ); +} + +#[test] +fn legacy_aliases_track_tokens() { + for appearance in [ThemeMode::Dark, ThemeMode::Light] { + let colors = ThemeColors::for_appearance(appearance); + assert_eq!(colors.background, colors.bg); + assert_eq!(colors.surface_background, colors.surface); + assert_eq!(colors.text_placeholder, colors.text_faint); + assert_eq!(colors.element_background, colors.solid); + assert_eq!(colors.secondary_background, colors.surface_raised); + assert_eq!(colors.tab_active_background, colors.bg); + } +} + +#[test] +fn rederive_round_trips_a_legacy_theme() { + let json = br##"{ + "id": "test", "name": "Test", "author": "coop", "url": "", + "light": { + "background": "#ffffff", "surface_background": "#fafafa", + "elevated_surface_background": "#f5f5f5", "panel_background": "#ffffff", + "overlay": "#0000001a", "title_bar": "#00000000", + "title_bar_inactive": "#ffffff", "window_border": "#c7c7cf", + "border": "#dbdbdb", "border_variant": "#d1d1d1", + "border_focused": "#3366cc", "border_selected": "#3366cc", + "border_transparent": "#00000000", "border_disabled": "#e6e6e6", + "ring": "#4d79d6", "text": "#1a1a1a", "text_muted": "#4d4d4d", + "text_placeholder": "#808080", "text_accent": "#3366cc", + "text_danger": "#cc3333", "text_warning": "#cc9933", + "icon": "#4d4d4d", "icon_muted": "#808080", "icon_accent": "#3366cc", + "element_foreground": "#ffffff", "element_background": "#3366cc", + "element_hover": "#3366cce6", "element_active": "#2e5cb8", + "element_selected": "#2952a3", "element_disabled": "#3366cc4d", + "secondary_foreground": "#2952a3", "secondary_background": "#e6ecf5", + "secondary_hover": "#3366cc1a", "secondary_active": "#d9e2f0", + "secondary_selected": "#d9e2f0", "secondary_disabled": "#3366cc4d", + "danger_foreground": "#ffffff", "danger_background": "#f5e6e6", + "danger_hover": "#cc33331a", "danger_active": "#f0d9d9", + "danger_selected": "#f0d9d9", "danger_disabled": "#cc33334d", + "warning_foreground": "#1a1a1a", "warning_background": "#f5f0e6", + "warning_hover": "#cc99331a", "warning_active": "#f0ead9", + "warning_selected": "#f0ead9", "warning_disabled": "#cc99334d", + "ghost_element_background": "#00000000", + "ghost_element_background_alt": "#e6e6e6", + "ghost_element_hover": "#0000001a", "ghost_element_active": "#d9d9d9", + "ghost_element_selected": "#d9d9d9", "ghost_element_disabled": "#0000000d", + "tab_inactive_background": "#e6e6e6", "tab_hover_background": "#e0e0e0", + "tab_active_background": "#d9d9d9", + "scrollbar_thumb_background": "#00000033", + "scrollbar_thumb_hover_background": "#0000004d", + "scrollbar_thumb_border": "#00000000", + "scrollbar_track_background": "#00000000", + "scrollbar_track_border": "#d9d9d9", + "drop_target_background": "#3366cc1a", + "cursor": "#3399ff", "selection": "#3399ff40" + }, + "dark": { + "background": "#1a1a1a", "surface_background": "#1f1f1f", + "elevated_surface_background": "#242424", "panel_background": "#262626", + "overlay": "#ffffff1a", "title_bar": "#00000000", + "title_bar_inactive": "#1a1a1a", "window_border": "#404046", + "border": "#404040", "border_variant": "#383838", + "border_focused": "#4d79d6", "border_selected": "#4d79d6", + "border_transparent": "#00000000", "border_disabled": "#2e2e2e", + "ring": "#668cdf", "text": "#f2f2f2", "text_muted": "#b3b3b3", + "text_placeholder": "#808080", "text_accent": "#668cdf", + "text_danger": "#d64d4d", "text_warning": "#d6b34d", + "icon": "#b3b3b3", "icon_muted": "#808080", "icon_accent": "#668cdf", + "element_foreground": "#ffffff", "element_background": "#4d79d6", + "element_hover": "#4d79d6e6", "element_active": "#456dc1", + "element_selected": "#3e62ac", "element_disabled": "#4d79d64d", + "secondary_foreground": "#3e62ac", "secondary_background": "#2a3652", + "secondary_hover": "#4d79d61a", "secondary_active": "#303d5c", + "secondary_selected": "#303d5c", "secondary_disabled": "#4d79d64d", + "danger_foreground": "#ffffff", "danger_background": "#522a2a", + "danger_hover": "#d64d4d1a", "danger_active": "#5c3030", + "danger_selected": "#5c3030", "danger_disabled": "#d64d4d4d", + "warning_foreground": "#f2f2f2", "warning_background": "#52482a", + "warning_hover": "#d6b34d1a", "warning_active": "#5c5430", + "warning_selected": "#5c5430", "warning_disabled": "#d6b34d4d", + "ghost_element_background": "#00000000", + "ghost_element_background_alt": "#2e2e2e", + "ghost_element_hover": "#ffffff1a", "ghost_element_active": "#383838", + "ghost_element_selected": "#383838", "ghost_element_disabled": "#ffffff0d", + "tab_inactive_background": "#2e2e2e", "tab_hover_background": "#333333", + "tab_active_background": "#383838", + "scrollbar_thumb_background": "#ffffff33", + "scrollbar_thumb_hover_background": "#ffffff4d", + "scrollbar_thumb_border": "#00000000", + "scrollbar_track_background": "#00000000", + "scrollbar_track_border": "#383838", + "drop_target_background": "#4d79d61a", + "cursor": "#4db3ff", "selection": "#4db3ff40" + } + }"##; + + let family: ThemeFamily = serde_json::from_slice(json).expect("parse"); + let mut light = family.light; + light.rederive_tokens(ThemeMode::Light); + assert_eq!(light.bg, light.background, "bg tracks the JSON background"); + assert_eq!(light.surface, light.surface_background); + assert_eq!(light.solid, light.element_background); + // Fields with no legacy source keep the built-in palette's value. + assert_eq!( + light.success, + ThemeColors::for_appearance(ThemeMode::Light).success + ); +} diff --git a/docs/ui-theme-bezel-migration.md b/docs/ui-theme-bezel-migration.md index ba10d3ea..539f3c4c 100644 --- a/docs/ui-theme-bezel-migration.md +++ b/docs/ui-theme-bezel-migration.md @@ -9,6 +9,12 @@ Upstream: — docs at whole corpus). The source of truth for exact constants is bezel's repo: `crates/theme/src/theme/{mod,palettes,layout,typography,syntax}.rs`. +> **Status:** Phases 0–1 are done on branch `new-theme` (except the manual +> screenshot pass). The token core lives in `crates/theme/src/{color,palette, +> brand,paint,layout,syntax,appearance}.rs`; the legacy fields remain as +> derived aliases so the whole workspace still compiles. Divergences from the +> original plan are noted inline. + ## Goal - One semantic token set (bezel's) with two designed palettes (dark, light). @@ -30,12 +36,18 @@ whole corpus). The source of truth for exact constants is bezel's repo: ## Decisions -- **D1 — Adapt, do not depend.** bezel is built against `bezel-gpui` (its own - gpui fork); coop is on `gpui-pre` 0.3.8. Adding `bezel` as a crate dependency - puts two gpui forks in the graph — two type universes, the failure mode - bezel's own docs warn about first. So we port the theme *architecture* (token - names, palettes, brand rotation, layout constants, paint helpers, tests) into - `crates/theme` and keep gpui paths as they are. +- **D1 — Adapt, do not depend (verified 2026-10-09).** bezel 0.2.10 is built + against `bezel-gpui` 0.3.17, published from `crabtalk/zed`. coop is on + `gpui-pre` 0.3.8, a zed snapshot published by longbridge (huacnlee), and + `gpui-base` 0.7.1 (longbridge's gpui-kit) is built against that line with no + bezel-gpui build available. Adding `bezel` would put two gpui forks in the + graph — two type universes, the failure mode bezel's own docs warn about + first — and would strand `gpui-base` (BaseButton, component_traits, + scrollbars). So the theme *architecture* is ported: token names, palette + values, brand rotation, layout constants, and paint helpers are copied + verbatim from bezel's MIT-licensed source (each ported file carries an + attribution header), and gpui paths stay as they are. Revisit only if + `gpui-pre` and `bezel-gpui` ever unify. - **D2 — Keep component APIs.** `Button`, `Dialog`, `dock`, `menu`, `Sizable`, `Size`, `ButtonVariant`, ... keep their names and signatures. Variants are *re-derived* from the new tokens inside `crates/ui`. Callers only break where @@ -53,10 +65,11 @@ whole corpus). The source of truth for exact constants is bezel's repo: (`crates/theme/src/lib.rs`) keeps feeding the base global (focus ring, selection wash, scrollbars, overlay backdrops paint from it). The mapping is rewritten against the new token names. -- **D6 — Glass/material fields: port stubs, not the renderer.** Carry - `vibrancy_alpha`, `vibrancy`, `glass` as settings-shaped fields with bezel's - defaults so a later port is additive; skip `SurfaceSpec`/`MaterialSpec` - machinery until a surface needs it. +- **D6 — Glass/material fields: omitted, not stubbed.** Phase 1 carried no + `vibrancy`/`glass`/`SurfaceSpec` fields at all — coop has no backdrop-blur + surface, and inert fields would only rot. `Brand` carries `tint`, `accent`, + `radius` and `ink` only. A glass port later adds the fields and the + renderer in one move. - **D7 — App chrome heights stay.** bezel's `TITLEBAR_HEIGHT = 38`, `HEADER_HEIGHT = 44` are bezel-app values. coop keeps `TITLEBAR_HEIGHT = 36`, `TABBAR_HEIGHT = 36`, `SIDEBAR_WIDTH = 240` (`crates/theme/src/lib.rs`) — @@ -187,73 +200,80 @@ validation listed. Follow the PR hygiene in `AGENTS.md`. ### Phase 0 — Baseline +- [x] Record the token census (command above) as the migration checklist. +- [x] Branch: work happens on `new-theme`. - [ ] Capture light/dark screenshots of the main surfaces: chat panel, message hover/actions, relays & trace dialogs, settings dialog, group - (nip29) views, workspace tabs/dock, notifications. -- [ ] Record the token census (command above) as the migration checklist. -- [ ] Branch. No code changes. + (nip29) views, workspace tabs/dock, notifications. *(manual — run the + app before and after Phase 2/3)* -### Phase 1 — Port the theme core into `crates/theme` +### Phase 1 — Port the theme core into `crates/theme` (done) -Port in this order; after each step `cargo check --workspace` must pass because -old token names still resolve (they become computed aliases over the new -palette until Phase 3 removes them). +All steps keep the workspace compiling: the legacy token fields remain as +values derived from the tokens (`ThemeColors::sync_legacy`), so the ~370 call +sites build unchanged. -- [ ] **Palette generator.** New `palette.rs`: port bezel's oklch-derived - neutral scale and the two designed palettes. Copy the constants from - bezel's `crates/theme/src/theme/palettes.rs` — do not re-derive. Keep the - "light is designed, not inverted" structure: surface order, elevation - ladder, and accent contrast are per-appearance, not mirrored. -- [ ] **New `Theme` struct.** Rewrite `colors.rs` into the semantic token set - from the mapping above (flat fields on `Theme`, bezel-style; drop the - `Theme` → `ThemeColors` Deref). `Appearance` (Dark/Light) replaces - `ThemeMode` as the internal enum; keep `From`. - Ship both palettes as `Theme::for_appearance(appearance)`. -- [ ] **Appearance plumbing.** New `appearance.rs`: `init(mode, cx)` with - `AppearanceMode::{System, Light, Dark}` (serde), `observe_window(window, cx) - -> Subscription`, `set_mode`. Wire it so `Theme::change` / - `sync_system_appearance` delegate to it. Boot order rule: init before the - first window opens; observe every opened window (workspace already - syncs on window activation — keep that hook). -- [ ] **Brand.** New `brand.rs`: `Tint { hue, chroma }` (oklch), `Brand { - tint, accent, radius, vibrancy_alpha, vibrancy, glass }`, hue rotation - over the palette (semantic tokens keep their hue; translucent ink is - untouched; chroma 0 reproduces the shipped palette). `set_brand` / - `set_palette(builder)` on an `AppExt` trait; the builder runs first on - every appearance switch, then overrides from the registry, then brand - rotation. All of it before `appearance::init` resolves. -- [ ] **Layout constants.** New `layout.rs`: `SPACE = 8.0`, - `CONTENT_MARGIN = 20.0`, `BASE_RADIUS = 8.0`, and the ratio functions - `control_radius()` 0.75x, `button_radius()` 1x, `panel_radius()` 1.25x, - `surface_radius()` 1.5x, `bubble_radius()` 2x, plus - `inset_radius(outer, inset)`. Keep coop's chrome heights (D7). -- [ ] **Typography.** `font_sans` (platform default, as today), add - `font_body` (defaults to `font_sans`) and `font_mono`. Keep - `base_text_size()` at 15px. Keep the existing Tailwind-style text-size - helpers in `crates/ui/src/styled.rs`; sizes are named per call site, not - themed. -- [ ] **Paint helpers.** `ink(alpha)`, `hairline(alpha)`, `wash(alpha)`, - `scrim()`, plate-hover helper, `INK_FILL_SCALE` / `INK_HAIRLINE_SCALE`. -- [ ] **Old names as aliases.** Temporarily keep `ThemeColors` and the old - `Theme` fields as computed accessors over the new tokens (mapping table - above) so the workspace still compiles. Mark `#[deprecated]` to make - Phase 3 greppable. -- [ ] **`sync_base` rewrite.** Project the new tokens onto - `gpui_base::Theme`: `bg→background`, `text→foreground`, - `surface→surface`, `solid→primary`, `on_solid→primary_foreground`, - `surface_raised→secondary`, `text→secondary_foreground`, - `surface_card→muted`, `text_muted→muted_foreground`, - `element_hover→accent`, `text→accent_foreground`, - `danger_strong→destructive`, `on_accent→destructive_foreground`, - `border→border`, `border→input`, `ring→ring`, `selection→selection`. - Verify against `gpui-base`'s actual token names when writing the code. -- [ ] **Contrast tests.** Port bezel's `tests/theme.rs` idea: assert each - light token lands within ~0.5 of its dark counterpart's contrast ratio - against its background, and `text` on `bg` ≥ 16:1. This is the safety - net for every palette override that follows. -- [ ] Delete nothing yet. Validation: `cargo test -p theme`, workspace check, - screenshots must be pixel-equivalent (aliases map to equivalent values - or the mapping table records an intentional change). +- [x] **Palette generator.** `palette.rs`: bezel's two designed palettes, + constants copied verbatim from bezel's `palettes.rs` (oklch-derived + neutrals via `color.rs`). Light is designed, not inverted. +- [x] **Token set.** `colors.rs`: the bezel token fields are flat on + `ThemeColors` (bg, surface ladder, hairlines, text ladder, solid, + accent trio, semantic colors, components, code & diff, syntax palette). + Legacy names resolve through `sync_legacy`. Deserialization refines over + the built-in dark palette (`#[serde(default)]`), so partial JSON still + loads. +- [x] **Appearance plumbing.** `appearance.rs`: `AppearanceMode::{System, + Light, Dark}` (serde), `init`, `set_mode`, `apply`, `observe_window`. + `Theme::sync_system_appearance` routes through it. *Divergence: bezel's + macOS `NSAppearance` override syncing (so pinned themes restyle the + traffic lights) is not ported — needs an `objc` dependency; follow-up.* +- [x] **Brand.** `brand.rs`: `Tint`, `BASE_COLORS` (Tailwind's five neutral + families), `TextInk`/`Ink` (AppKit + Accessible), `Brand { tint, accent, + radius, ink }` with the grey-rotation rule (grey tokens take the tint, + semantic tokens keep their hue, translucent ink is skipped). + `AppExt::set_brand` / `set_palette` install globals; the palette builder + runs first on every appearance install. A default brand is a strict + no-op so JSON themes are untouched until an app brands itself. +- [x] **Layout constants.** `layout.rs`: `SPACE = 8`, `CONTENT_MARGIN = 20`, + `EDGE = 12`, `BASE_RADIUS = 8`, `BUTTON_HEIGHT = 24`, + `CONTROL_HEIGHT_SMALL = 20`, ratio functions (control 0.75x, button 1x, + panel 1.25x, surface 1.5x, bubble 2x), `inset_radius`. `Theme::radius` + is now 8 and `radius_lg` 12 (was 6/10) — intentional visual change. + coop's chrome heights stay (D7). +- [x] **Typography (partial).** `Theme` gains `font_body` (defaults to + `font_family`) and `font_mono` (per-backend system mono). *Divergence: + bezel's `TextStyle`/`Metrics`/`Typeset` ladder and `ControlSize`/ + `Sizing` are deferred to Phase 2, where the components that would use + them get reskinned.* +- [x] **Paint helpers.** `paint.rs`: process-wide appearance mirror, `ink`, + `hairline`, `wash`, `scrim`, `band`, `INK_FILL_SCALE`/ + `INK_HAIRLINE_SCALE`, `surface_shadows`, plate hover/active helpers. +- [x] **Legacy JSON bridge.** `ThemeFamily::from_file` and the registry's + loader call `ThemeColors::rederive_tokens`, which best-effort maps an + old-schema theme's fields back onto the tokens (fields with no legacy + source — `success`, `busy`, syntax, diff — keep the built-in palette's + value for that appearance). Phase 4 replaces the schema; this bridge is + what keeps the 8 shipped themes usable in the meantime. +- [x] **`sync_base` rewrite.** Projects the new tokens onto `gpui_base::Theme` + (bg→background, text→foreground, solid→primary, surface_raised→secondary, + surface_card→muted, element_hover→accent, danger_strong→destructive, + on_solid/on_accent→*_foreground, border→border/input, ring→ring, + selection→selection). +- [x] **Contrast tests.** `tests/contrast.rs`: text ≥ 13.9:1 on bg and + surface in both appearances (measured 14.4/15.1), muted ≥ 3.9:1, + faint ≥ 1.8:1 (AppKit ink lands below WCAG AA by design), light-vs-dark + parity for primary text within 0.7, default brand is a no-op, brand + rotation moves greys but not semantic hues, legacy aliases track + tokens, and a legacy-JSON round trip. *Note: bezel's docs claim ~0.5 + parity for every text role; the shipped AppKit ink only delivers that + for primary text, so the test asserts floors for muted/faint instead.* +- [x] `cargo test -p theme` (11 tests) and `cargo check --workspace` green; + clippy clean. +- [ ] Pixel review of the running app against Phase 0 screenshots (needs the + manual screenshot pass first). The palette swap is intentionally visible: + neutral `#060606`/`#0d0d0d` surfaces replace the grey scale, hover/active + washes are neutral ink, focus rings are hairlines, cursor/selection match + macOS defaults. ### Phase 2 — Reskin `crates/ui` @@ -317,6 +337,9 @@ deprecated accessors as the worklist — the goal is zero deprecated hits. `surface_card`; reactions/toggles to `element_*`; report banners to `warning_muted`/`danger_muted`. - [ ] `crates/workspace` — tabs, dock, settings dialog, theme picker. + Note: the settings dialog's Light/Dark buttons call `Theme::change` + directly; they should move to `AppExt::set_appearance_mode` (and gain a + System option) in Phase 4. - [ ] `crates/nip29_ui`, `crates/settings`, `crates/nip4e`, `desktop`, `web`. - [ ] Replace `radius`/`radius_lg` with the ratio function the element is (control inside control → 0.75x, control → 1x, card → 1.25x, floating → @@ -349,9 +372,10 @@ deprecated accessors as the worklist — the goal is zero deprecated hits. - [ ] Delete `ThemeColors`, the deprecated aliases, `ColorScale`/ `ColorScaleSet` public exports (`scale.rs` either goes private as the palette generator's internal or is deleted), `ThemeMode`. -- [ ] `crates/ui/src/lib.rs`: `init` becomes `appearance::init` + - `gpui_base::init` + `menu::init`; `theme::sync_base` is now called from - the appearance change path, not app boot. +- [ ] `crates/ui/src/lib.rs`: `theme::init` already runs `appearance::init`; + verify `ui::init`'s callers boot the theme before the first window, and + that every opened window observes appearance changes + (`theme::observe_window` or workspace's sync-on-activation hook). - [ ] Update `README`/docs mentions of theme customization; document `Brand` + override JSON as the two supported ways to theme coop. - [ ] Final census run: only new token names remain.