port bezel theme system into the theme crate

This commit is contained in:
2026-10-09 08:42:54 +07:00
parent 22f57def7d
commit 90377c8c76
14 changed files with 2121 additions and 566 deletions
+150
View File
@@ -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::<AppearanceState>()
.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::<AppearanceState>() {
return;
}
let state = cx.global_mut::<AppearanceState>();
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::<AppearanceState>().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::<AppearanceState>() {
return;
}
let state = cx.global_mut::<AppearanceState>();
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::<AppearanceState>() else {
return;
};
let wanted = resolve(state.mode, state.system);
let changed = !cx
.try_global::<Theme>()
.is_some_and(|theme| theme.mode == wanted);
if changed {
Theme::change(wanted, None, cx);
cx.refresh_windows();
}
}
+294
View File
@@ -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::<Brand>().copied().unwrap_or_default()
}
+215
View File
@@ -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),
)
}
+355 -453
View File
@@ -1,10 +1,21 @@
use gpui::{Hsla, Rgba, hsla}; use gpui::Hsla;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; 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 { pub struct ThemeColors {
// Surface colors // Surface colors
pub background: Hsla, pub background: Hsla,
@@ -96,488 +107,379 @@ pub struct ThemeColors {
pub drop_target_background: Hsla, pub drop_target_background: Hsla,
pub cursor: Hsla, pub cursor: Hsla,
pub selection: 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 { impl ThemeColors {
/// Returns the default colors for light themes. /// 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 { pub(crate) fn light() -> Self {
Self { palette::light()
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),
}
} }
/// Returns the default colors for dark themes. /// 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 { 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 { Self {
background: neutral().dark().step_1(), background: transparent,
surface_background: neutral().dark().step_2(), surface_background: transparent,
elevated_surface_background: neutral().dark().step_3(), elevated_surface_background: transparent,
panel_background: neutral().dark().step_1(), panel_background: transparent,
overlay: neutral().dark_alpha().step_3(), overlay: transparent,
title_bar: neutral().dark().step_2(), title_bar: transparent,
title_bar_inactive: neutral().dark().step_1(), title_bar_inactive: transparent,
window_border: hsl(240.0, 3.7, 28.0), window_border: transparent,
border: neutral().dark().step_6(), border: transparent,
border_variant: neutral().dark().step_4(), border_variant: transparent,
border_focused: brand().dark().step_7(), border_focused: transparent,
border_selected: brand().dark().step_7(), border_selected: transparent,
border_transparent: gpui::transparent_black(), border_transparent: transparent,
border_disabled: neutral().dark().step_3(), border_disabled: transparent,
ring: brand().dark().step_8(), ring: transparent,
text: neutral().dark().step_12(), text: transparent,
text_muted: neutral().dark().step_11(), text_muted: transparent,
text_placeholder: neutral().dark().step_10(), text_placeholder: transparent,
text_accent: brand().dark().step_11(), text_accent: transparent,
text_danger: danger().dark().step_9(), text_danger: transparent,
text_warning: warning().dark().step_9(), text_warning: transparent,
icon: neutral().dark().step_11(), icon: transparent,
icon_muted: neutral().dark().step_10(), icon_muted: transparent,
icon_accent: brand().dark().step_11(), icon_accent: transparent,
element_foreground: brand().dark().step_1(), element_foreground: transparent,
element_background: brand().dark().step_9(), element_background: transparent,
element_hover: brand().dark_alpha().step_10(), element_hover: transparent,
element_active: brand().dark().step_10(), element_active: transparent,
element_selected: brand().dark().step_11(), element_selected: transparent,
element_disabled: brand().dark_alpha().step_3(), element_disabled: transparent,
secondary_foreground: brand().dark().step_12(), secondary_foreground: transparent,
secondary_background: brand().dark().step_3(), secondary_background: transparent,
secondary_hover: brand().dark_alpha().step_4(), secondary_hover: transparent,
secondary_active: brand().dark().step_5(), secondary_active: transparent,
secondary_selected: brand().dark().step_5(), secondary_selected: transparent,
secondary_disabled: brand().dark_alpha().step_3(), secondary_disabled: transparent,
danger_foreground: danger().dark().step_12(), danger_foreground: transparent,
danger_background: danger().dark().step_3(), danger_background: transparent,
danger_hover: danger().dark_alpha().step_4(), danger_hover: transparent,
danger_active: danger().dark().step_5(), danger_active: transparent,
danger_selected: danger().dark().step_5(), danger_selected: transparent,
danger_disabled: danger().dark_alpha().step_3(), danger_disabled: transparent,
warning_foreground: warning().dark().step_12(), warning_foreground: transparent,
warning_background: warning().dark().step_3(), warning_background: transparent,
warning_hover: warning().dark_alpha().step_4(), warning_hover: transparent,
warning_active: warning().dark().step_5(), warning_active: transparent,
warning_selected: warning().dark().step_5(), warning_selected: transparent,
warning_disabled: warning().dark_alpha().step_3(), warning_disabled: transparent,
ghost_element_background: gpui::transparent_black(), ghost_element_background: transparent,
ghost_element_background_alt: neutral().dark().step_3(), ghost_element_background_alt: transparent,
ghost_element_hover: neutral().dark_alpha().step_4(), ghost_element_hover: transparent,
ghost_element_active: neutral().dark().step_5(), ghost_element_active: transparent,
ghost_element_selected: neutral().dark().step_5(), ghost_element_selected: transparent,
ghost_element_disabled: neutral().dark_alpha().step_2(), ghost_element_disabled: transparent,
tab_background: neutral().dark().step_3(), tab_background: transparent,
tab_foreground: neutral().dark().step_11(), tab_foreground: transparent,
tab_hover_background: neutral().dark_alpha().step_4(), tab_hover_background: transparent,
tab_active_background: neutral().dark().step_1(), tab_active_background: transparent,
tab_active_foreground: neutral().dark().step_12(), tab_active_foreground: transparent,
scrollbar_thumb_background: neutral().dark_alpha().step_3(), scrollbar_thumb_background: transparent,
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(), scrollbar_thumb_hover_background: transparent,
scrollbar_thumb_border: gpui::transparent_black(), scrollbar_thumb_border: transparent,
scrollbar_track_background: gpui::transparent_black(), scrollbar_track_background: transparent,
scrollbar_track_border: gpui::transparent_black(), scrollbar_track_border: transparent,
drop_target_background: brand().dark_alpha().step_2(), drop_target_background: transparent,
cursor: hsl(200., 100., 50.), cursor: transparent,
selection: hsl(200., 100., 50.).alpha(0.25), 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 { /// Derive the legacy fields from the tokens.
gray() 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 { // Borders
yellow() 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 { // Text
tomato() self.text_placeholder = self.text_faint;
} self.text_accent = self.accent;
self.text_danger = self.danger;
self.text_warning = self.warning;
fn warning() -> ColorScaleSet { // Icons
orange() self.icon = self.text_muted;
} self.icon_muted = self.text_faint;
self.icon_accent = self.accent;
/// Make a [gpui::Hsla] color. // Elements
/// self.element_foreground = self.on_solid;
/// - h: 0..360.0 self.element_background = self.solid;
/// - s: 0.0..100.0 self.element_selected = self.element_active;
/// - l: 0.0..100.0 self.element_disabled = wash_for(appearance, 0.06);
pub(crate) fn hsl(h: f32, s: f32, l: f32) -> Hsla {
hsla(h / 360., s / 100.0, l / 100.0, 1.0)
}
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 { // Danger
light: StaticColorScale, self.danger_foreground = self.on_accent;
light_alpha: StaticColorScale, self.danger_background = self.danger_strong;
dark: StaticColorScale, self.danger_hover = plate_hover_for(appearance, self.danger_strong);
dark_alpha: StaticColorScale, 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<StaticColorScaleSet> for ColorScaleSet { // Warning
type Error = anyhow::Error; 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<Self, Self::Error> { // Ghost
fn to_color_scale(scale: StaticColorScale) -> Result<ColorScale, anyhow::Error> { self.ghost_element_background = gpui::transparent_black();
scale self.ghost_element_background_alt = self.surface_raised;
.into_iter() self.ghost_element_hover = self.element_hover;
.map(|color| Rgba::try_from(color).map(Hsla::from)) self.ghost_element_active = self.element_active;
.collect::<Result<Vec<_>, _>>() self.ghost_element_selected = self.element_active;
.map(ColorScale::from_iter) self.ghost_element_disabled = wash_for(appearance, 0.06);
}
Ok(Self::new( // Tabs
to_color_scale(value.light)?, self.tab_background = self.surface;
to_color_scale(value.light_alpha)?, self.tab_foreground = self.text_muted;
to_color_scale(value.dark)?, self.tab_hover_background = self.element_hover;
to_color_scale(value.dark_alpha)?, 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()
}
+87
View File
@@ -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 }
}
+141 -33
View File
@@ -1,26 +1,53 @@
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::rc::Rc; use std::rc::Rc;
use appearance::AppearanceMode;
use gpui::{App, Global, Pixels, SharedString, Window, px}; use gpui::{App, Global, Pixels, SharedString, Window, px};
mod brand;
mod color;
mod colors; mod colors;
mod geometry; mod geometry;
mod layout;
mod notification; mod notification;
mod paint;
mod palette;
mod platform_kind; mod platform_kind;
mod registry; mod registry;
mod scale; mod scale;
mod scrollbar_mode; mod scrollbar_mode;
mod syntax;
mod theme; 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 colors::*;
pub use geometry::*; 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 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 platform_kind::PlatformKind;
pub use registry::*; pub use registry::*;
pub use scale::*; pub use scale::*;
pub use scrollbar_mode::*; pub use scrollbar_mode::*;
pub use syntax::{HighlightKind, SyntaxPalette};
pub use theme::*; 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. /// Defines window border radius for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0); 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) { pub fn init(cx: &mut App) {
registry::init(cx); registry::init(cx);
appearance::init(AppearanceMode::System, cx);
Theme::sync_system_appearance(None, cx);
Theme::sync_scrollbar_appearance(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<PaletteBuilder>);
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::<Theme>()
.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. /// 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 /// Base paints a few things from its own tokens -- the focus ring, the wash
/// behind selected text, scrollbars, and overlay backdrops -- so the two /// behind selected text, scrollbars, and overlay backdrops -- so the two
/// globals have to agree or those details drift away from the palette. /// globals have to agree or those details drift away from the palette.
/// ///
/// Only roles base can act on are projected. Radius, spacing, typography sizes, /// This is a no-op before the coop theme global exists; [`Theme::change`] is
/// shadows, and scrollbar geometry keep their base defaults: coop has a single /// the authoritative hook that keeps the projection current.
/// `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.
pub fn sync_base(cx: &mut App) { pub fn sync_base(cx: &mut App) {
let Some(theme) = cx.try_global::<Theme>() else { let Some(theme) = cx.try_global::<Theme>() else {
return; return;
@@ -83,20 +150,20 @@ pub fn sync_base(cx: &mut App) {
base.tokens.typography.sans = font_family; base.tokens.typography.sans = font_family;
let tokens = &mut base.tokens.colors; let tokens = &mut base.tokens.colors;
tokens.background = colors.background; tokens.background = colors.bg;
tokens.foreground = colors.text; tokens.foreground = colors.text;
tokens.surface = colors.surface_background; tokens.surface = colors.surface;
tokens.surface_foreground = colors.text; tokens.surface_foreground = colors.text;
tokens.primary = colors.element_background; tokens.primary = colors.solid;
tokens.primary_foreground = colors.element_foreground; tokens.primary_foreground = colors.on_solid;
tokens.secondary = colors.secondary_background; tokens.secondary = colors.surface_raised;
tokens.secondary_foreground = colors.secondary_foreground; tokens.secondary_foreground = colors.text;
tokens.muted = colors.ghost_element_background_alt; tokens.muted = colors.surface_card;
tokens.muted_foreground = colors.text_muted; tokens.muted_foreground = colors.text_muted;
tokens.accent = colors.ghost_element_hover; tokens.accent = colors.element_hover;
tokens.accent_foreground = colors.text; tokens.accent_foreground = colors.text;
tokens.destructive = colors.danger_background; tokens.destructive = colors.danger_strong;
tokens.destructive_foreground = colors.danger_foreground; tokens.destructive_foreground = colors.on_accent;
tokens.border = colors.border; tokens.border = colors.border;
tokens.input = colors.border; tokens.input = colors.border;
tokens.ring = colors.ring; tokens.ring = colors.ring;
@@ -128,6 +195,13 @@ pub struct Theme {
/// The font family for the application. /// The font family for the application.
pub font_family: SharedString, 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. /// The root font size for the application, default is 15px.
pub font_size: Pixels, pub font_size: Pixels,
@@ -181,12 +255,7 @@ impl Theme {
/// Sync the theme with the system appearance /// Sync the theme with the system appearance
pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) { pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
let appearance = window appearance::record_system_appearance(window.as_deref(), cx);
.as_ref()
.map(|window| window.appearance())
.unwrap_or_else(|| cx.window_appearance());
Self::change(appearance, window, cx);
} }
/// Sync the Scrollbar showing behavior with the system /// Sync the Scrollbar showing behavior with the system
@@ -221,23 +290,47 @@ impl Theme {
} }
let mode = mode.into(); let mode = mode.into();
let theme = cx.global_mut::<Theme>(); let builder = cx
.try_global::<GlobalPaletteBuilder>()
.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 // Set the theme mode
theme.mode = mode; theme.mode = mode;
// Set the theme colors // Set the theme colors
if mode.is_dark() { theme.colors = colors;
theme.colors = *theme.theme.dark();
} else {
theme.colors = *theme.theme.light();
}
// Refresh the window if available // Refresh the window if available
if let Some(window) = window { if let Some(window) = window {
window.refresh(); 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 // Keep the base-layer projection in step with the coop palette
sync_base(cx); sync_base(cx);
} }
@@ -263,8 +356,10 @@ impl From<ThemeFamily> for Theme {
Theme { Theme {
font_size: px(15.), font_size: px(15.),
font_family: font_family.into(), font_family: font_family.into(),
radius: px(6.), font_body: font_family.into(),
radius_lg: px(10.), font_mono: system_mono().into(),
radius: px(layout::button_radius()),
radius_lg: px(layout::surface_radius()),
shadow: true, shadow: true,
scrollbar_mode: ScrollbarMode::default(), scrollbar_mode: ScrollbarMode::default(),
notification: NotificationSettings::default(), notification: NotificationSettings::default(),
@@ -274,3 +369,16 @@ impl From<ThemeFamily> 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"
}
}
+181
View File
@@ -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<BoxShadow> {
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)
}
+181
View File
@@ -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(),
}
}
+6 -3
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::{Context as AnyhowContext, Error};
use gpui::{App, AppContext, AssetSource, Context, Entity, Global, SharedString}; use gpui::{App, AppContext, AssetSource, Context, Entity, Global, SharedString};
use crate::ThemeFamily; use crate::{ThemeFamily, ThemeMode};
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
ThemeRegistry::set_global(cx.new(ThemeRegistry::new), cx); ThemeRegistry::set_global(cx.new(ThemeRegistry::new), cx);
@@ -57,8 +57,11 @@ impl ThemeRegistry {
// Load the theme file from the assets // Load the theme file from the assets
let content = asset.load(path)?.context("Theme not found")?; let content = asset.load(path)?.context("Theme not found")?;
// Parse the JSON content into a Theme Family struct // Parse the JSON content into a Theme Family struct, and re-derive the
let theme: ThemeFamily = serde_json::from_slice(&content)?; // 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) Ok(theme)
} }
+5
View File
@@ -137,6 +137,10 @@ impl ColorScale {
} }
/// Provides groups of [`ColorScale`]s for light and dark themes, as well as transparent versions of each scale. /// 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 { pub struct ColorScaleSet {
light: ColorScale, light: ColorScale,
dark: ColorScale, dark: ColorScale,
@@ -144,6 +148,7 @@ pub struct ColorScaleSet {
dark_alpha: ColorScale, dark_alpha: ColorScale,
} }
#[allow(dead_code)]
impl ColorScaleSet { impl ColorScaleSet {
pub(crate) fn new( pub(crate) fn new(
light: ColorScale, light: ColorScale,
+176
View File
@@ -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
}
+13 -2
View File
@@ -18,6 +18,15 @@ impl ThemeMode {
matches!(self, Self::Dark) 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`. /// Return theme name: `light`, `dark`.
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self { match self {
@@ -117,9 +126,11 @@ impl ThemeFamily {
/// # Ok(()) /// # Ok(())
/// # } /// # }
/// ``` /// ```
pub(crate) fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> { pub fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let json_data = std::fs::read(path)?; 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) Ok(theme_family)
} }
+218
View File
@@ -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
);
}
+99 -75
View File
@@ -9,6 +9,12 @@ Upstream: <https://github.com/crabtalk/bezel> — docs at
whole corpus). The source of truth for exact constants is bezel's repo: whole corpus). The source of truth for exact constants is bezel's repo:
`crates/theme/src/theme/{mod,palettes,layout,typography,syntax}.rs`. `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 ## Goal
- One semantic token set (bezel's) with two designed palettes (dark, light). - 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 ## Decisions
- **D1 — Adapt, do not depend.** bezel is built against `bezel-gpui` (its own - **D1 — Adapt, do not depend (verified 2026-10-09).** bezel 0.2.10 is built
gpui fork); coop is on `gpui-pre` 0.3.8. Adding `bezel` as a crate dependency against `bezel-gpui` 0.3.17, published from `crabtalk/zed`. coop is on
puts two gpui forks in the graph — two type universes, the failure mode `gpui-pre` 0.3.8, a zed snapshot published by longbridge (huacnlee), and
bezel's own docs warn about first. So we port the theme *architecture* (token `gpui-base` 0.7.1 (longbridge's gpui-kit) is built against that line with no
names, palettes, brand rotation, layout constants, paint helpers, tests) into bezel-gpui build available. Adding `bezel` would put two gpui forks in the
`crates/theme` and keep gpui paths as they are. 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`, - **D2 — Keep component APIs.** `Button`, `Dialog`, `dock`, `menu`, `Sizable`,
`Size`, `ButtonVariant`, ... keep their names and signatures. Variants are `Size`, `ButtonVariant`, ... keep their names and signatures. Variants are
*re-derived* from the new tokens inside `crates/ui`. Callers only break where *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, (`crates/theme/src/lib.rs`) keeps feeding the base global (focus ring,
selection wash, scrollbars, overlay backdrops paint from it). The mapping is selection wash, scrollbars, overlay backdrops paint from it). The mapping is
rewritten against the new token names. rewritten against the new token names.
- **D6 — Glass/material fields: port stubs, not the renderer.** Carry - **D6 — Glass/material fields: omitted, not stubbed.** Phase 1 carried no
`vibrancy_alpha`, `vibrancy`, `glass` as settings-shaped fields with bezel's `vibrancy`/`glass`/`SurfaceSpec` fields at all — coop has no backdrop-blur
defaults so a later port is additive; skip `SurfaceSpec`/`MaterialSpec` surface, and inert fields would only rot. `Brand` carries `tint`, `accent`,
machinery until a surface needs it. `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`, - **D7 — App chrome heights stay.** bezel's `TITLEBAR_HEIGHT = 38`,
`HEADER_HEIGHT = 44` are bezel-app values. coop keeps `TITLEBAR_HEIGHT = 36`, `HEADER_HEIGHT = 44` are bezel-app values. coop keeps `TITLEBAR_HEIGHT = 36`,
`TABBAR_HEIGHT = 36`, `SIDEBAR_WIDTH = 240` (`crates/theme/src/lib.rs`) — `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 ### 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, - [ ] Capture light/dark screenshots of the main surfaces: chat panel,
message hover/actions, relays & trace dialogs, settings dialog, group message hover/actions, relays & trace dialogs, settings dialog, group
(nip29) views, workspace tabs/dock, notifications. (nip29) views, workspace tabs/dock, notifications. *(manual — run the
- [ ] Record the token census (command above) as the migration checklist. app before and after Phase 2/3)*
- [ ] Branch. No code changes.
### 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 All steps keep the workspace compiling: the legacy token fields remain as
old token names still resolve (they become computed aliases over the new values derived from the tokens (`ThemeColors::sync_legacy`), so the ~370 call
palette until Phase 3 removes them). sites build unchanged.
- [ ] **Palette generator.** New `palette.rs`: port bezel's oklch-derived - [x] **Palette generator.** `palette.rs`: bezel's two designed palettes,
neutral scale and the two designed palettes. Copy the constants from constants copied verbatim from bezel's `palettes.rs` (oklch-derived
bezel's `crates/theme/src/theme/palettes.rs` — do not re-derive. Keep the neutrals via `color.rs`). Light is designed, not inverted.
"light is designed, not inverted" structure: surface order, elevation - [x] **Token set.** `colors.rs`: the bezel token fields are flat on
ladder, and accent contrast are per-appearance, not mirrored. `ThemeColors` (bg, surface ladder, hairlines, text ladder, solid,
- [ ] **New `Theme` struct.** Rewrite `colors.rs` into the semantic token set accent trio, semantic colors, components, code & diff, syntax palette).
from the mapping above (flat fields on `Theme`, bezel-style; drop the Legacy names resolve through `sync_legacy`. Deserialization refines over
`Theme` → `ThemeColors` Deref). `Appearance` (Dark/Light) replaces the built-in dark palette (`#[serde(default)]`), so partial JSON still
`ThemeMode` as the internal enum; keep `From<WindowAppearance>`. loads.
Ship both palettes as `Theme::for_appearance(appearance)`. - [x] **Appearance plumbing.** `appearance.rs`: `AppearanceMode::{System,
- [ ] **Appearance plumbing.** New `appearance.rs`: `init(mode, cx)` with Light, Dark}` (serde), `init`, `set_mode`, `apply`, `observe_window`.
`AppearanceMode::{System, Light, Dark}` (serde), `observe_window(window, cx) `Theme::sync_system_appearance` routes through it. *Divergence: bezel's
-> Subscription`, `set_mode`. Wire it so `Theme::change` / macOS `NSAppearance` override syncing (so pinned themes restyle the
`sync_system_appearance` delegate to it. Boot order rule: init before the traffic lights) is not ported — needs an `objc` dependency; follow-up.*
first window opens; observe every opened window (workspace already - [x] **Brand.** `brand.rs`: `Tint`, `BASE_COLORS` (Tailwind's five neutral
syncs on window activation — keep that hook). families), `TextInk`/`Ink` (AppKit + Accessible), `Brand { tint, accent,
- [ ] **Brand.** New `brand.rs`: `Tint { hue, chroma }` (oklch), `Brand { radius, ink }` with the grey-rotation rule (grey tokens take the tint,
tint, accent, radius, vibrancy_alpha, vibrancy, glass }`, hue rotation semantic tokens keep their hue, translucent ink is skipped).
over the palette (semantic tokens keep their hue; translucent ink is `AppExt::set_brand` / `set_palette` install globals; the palette builder
untouched; chroma 0 reproduces the shipped palette). `set_brand` / runs first on every appearance install. A default brand is a strict
`set_palette(builder)` on an `AppExt` trait; the builder runs first on no-op so JSON themes are untouched until an app brands itself.
every appearance switch, then overrides from the registry, then brand - [x] **Layout constants.** `layout.rs`: `SPACE = 8`, `CONTENT_MARGIN = 20`,
rotation. All of it before `appearance::init` resolves. `EDGE = 12`, `BASE_RADIUS = 8`, `BUTTON_HEIGHT = 24`,
- [ ] **Layout constants.** New `layout.rs`: `SPACE = 8.0`, `CONTROL_HEIGHT_SMALL = 20`, ratio functions (control 0.75x, button 1x,
`CONTENT_MARGIN = 20.0`, `BASE_RADIUS = 8.0`, and the ratio functions panel 1.25x, surface 1.5x, bubble 2x), `inset_radius`. `Theme::radius`
`control_radius()` 0.75x, `button_radius()` 1x, `panel_radius()` 1.25x, is now 8 and `radius_lg` 12 (was 6/10) — intentional visual change.
`surface_radius()` 1.5x, `bubble_radius()` 2x, plus coop's chrome heights stay (D7).
`inset_radius(outer, inset)`. Keep coop's chrome heights (D7). - [x] **Typography (partial).** `Theme` gains `font_body` (defaults to
- [ ] **Typography.** `font_sans` (platform default, as today), add `font_family`) and `font_mono` (per-backend system mono). *Divergence:
`font_body` (defaults to `font_sans`) and `font_mono`. Keep bezel's `TextStyle`/`Metrics`/`Typeset` ladder and `ControlSize`/
`base_text_size()` at 15px. Keep the existing Tailwind-style text-size `Sizing` are deferred to Phase 2, where the components that would use
helpers in `crates/ui/src/styled.rs`; sizes are named per call site, not them get reskinned.*
themed. - [x] **Paint helpers.** `paint.rs`: process-wide appearance mirror, `ink`,
- [ ] **Paint helpers.** `ink(alpha)`, `hairline(alpha)`, `wash(alpha)`, `hairline`, `wash`, `scrim`, `band`, `INK_FILL_SCALE`/
`scrim()`, plate-hover helper, `INK_FILL_SCALE` / `INK_HAIRLINE_SCALE`. `INK_HAIRLINE_SCALE`, `surface_shadows`, plate hover/active helpers.
- [ ] **Old names as aliases.** Temporarily keep `ThemeColors` and the old - [x] **Legacy JSON bridge.** `ThemeFamily::from_file` and the registry's
`Theme` fields as computed accessors over the new tokens (mapping table loader call `ThemeColors::rederive_tokens`, which best-effort maps an
above) so the workspace still compiles. Mark `#[deprecated]` to make old-schema theme's fields back onto the tokens (fields with no legacy
Phase 3 greppable. source — `success`, `busy`, syntax, diff — keep the built-in palette's
- [ ] **`sync_base` rewrite.** Project the new tokens onto value for that appearance). Phase 4 replaces the schema; this bridge is
`gpui_base::Theme`: `bg→background`, `text→foreground`, what keeps the 8 shipped themes usable in the meantime.
`surface→surface`, `solid→primary`, `on_solid→primary_foreground`, - [x] **`sync_base` rewrite.** Projects the new tokens onto `gpui_base::Theme`
`surface_raised→secondary`, `text→secondary_foreground`, (bg→background, text→foreground, solid→primary, surface_raised→secondary,
`surface_card→muted`, `text_muted→muted_foreground`, surface_card→muted, element_hover→accent, danger_strong→destructive,
`element_hover→accent`, `text→accent_foreground`, on_solid/on_accent→*_foreground, border→border/input, ring→ring,
`danger_strong→destructive`, `on_accent→destructive_foreground`, selection→selection).
`border→border`, `border→input`, `ring→ring`, `selection→selection`. - [x] **Contrast tests.** `tests/contrast.rs`: text ≥ 13.9:1 on bg and
Verify against `gpui-base`'s actual token names when writing the code. surface in both appearances (measured 14.4/15.1), muted ≥ 3.9:1,
- [ ] **Contrast tests.** Port bezel's `tests/theme.rs` idea: assert each faint ≥ 1.8:1 (AppKit ink lands below WCAG AA by design), light-vs-dark
light token lands within ~0.5 of its dark counterpart's contrast ratio parity for primary text within 0.7, default brand is a no-op, brand
against its background, and `text` on `bg` ≥ 16:1. This is the safety rotation moves greys but not semantic hues, legacy aliases track
net for every palette override that follows. tokens, and a legacy-JSON round trip. *Note: bezel's docs claim ~0.5
- [ ] Delete nothing yet. Validation: `cargo test -p theme`, workspace check, parity for every text role; the shipped AppKit ink only delivers that
screenshots must be pixel-equivalent (aliases map to equivalent values for primary text, so the test asserts floors for muted/faint instead.*
or the mapping table records an intentional change). - [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` ### 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 `surface_card`; reactions/toggles to `element_*`; report banners to
`warning_muted`/`danger_muted`. `warning_muted`/`danger_muted`.
- [ ] `crates/workspace` — tabs, dock, settings dialog, theme picker. - [ ] `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`. - [ ] `crates/nip29_ui`, `crates/settings`, `crates/nip4e`, `desktop`, `web`.
- [ ] Replace `radius`/`radius_lg` with the ratio function the element is - [ ] Replace `radius`/`radius_lg` with the ratio function the element is
(control inside control → 0.75x, control → 1x, card → 1.25x, floating → (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`/ - [ ] Delete `ThemeColors`, the deprecated aliases, `ColorScale`/
`ColorScaleSet` public exports (`scale.rs` either goes private as the `ColorScaleSet` public exports (`scale.rs` either goes private as the
palette generator's internal or is deleted), `ThemeMode`. palette generator's internal or is deleted), `ThemeMode`.
- [ ] `crates/ui/src/lib.rs`: `init` becomes `appearance::init` + - [ ] `crates/ui/src/lib.rs`: `theme::init` already runs `appearance::init`;
`gpui_base::init` + `menu::init`; `theme::sync_base` is now called from verify `ui::init`'s callers boot the theme before the first window, and
the appearance change path, not app boot. 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 - [ ] Update `README`/docs mentions of theme customization; document
`Brand` + override JSON as the two supported ways to theme coop. `Brand` + override JSON as the two supported ways to theme coop.
- [ ] Final census run: only new token names remain. - [ ] Final census run: only new token names remain.