Skip to main content

theme/theme/
palettes.rs

1//! The two concrete palettes: dark and light.
2
3use gpui::{hsla, rgb};
4
5use crate::{
6    Appearance, Ink, TextInk, color, paint,
7    theme::{
8        Glass, Material, MaterialSpec, SurfaceSpec, SurfaceStyle, Theme, syntax::SyntaxPalette,
9    },
10};
11
12impl Theme {
13    /// Build the dark theme. The surface tones are sampled straight from the
14    /// reference screenshots of the original app (docs/reference): main panel
15    /// `#060606`, shell/sidebar `#0d0d0d`.
16    pub fn dark() -> Self {
17        Self {
18            appearance: Appearance::Dark,
19            family: None,
20            bg: color::grey(6),       // main panel — sampled #060606
21            surface: color::grey(13), // shell / sidebar — sampled #0d0d0d
22            surface_raised: color::neutral(0.235),
23            surface_card: color::grey(0x0e),
24            surface_dialog: color::grey(0x10),
25            surface_overlay: color::grey(0x16),
26            // `../desktop`'s `--color-hover`, pure ink: white at 8%.
27            element_hover: hsla(0.0, 0.0, 1.0, 0.08),
28            // `--color-active`, a rung above the hover: white at 12%.
29            element_active: hsla(0.0, 0.0, 1.0, 0.12),
30            border_faint: paint::hairline_for(Appearance::Dark, 0.06),
31            border: paint::hairline_for(Appearance::Dark, 0.08),
32            border_strong: paint::hairline_for(Appearance::Dark, 0.14),
33            text: TextInk::color(Appearance::Dark, Ink::APPKIT.dark.text),
34            text_muted: TextInk::color(Appearance::Dark, Ink::APPKIT.dark.muted),
35            text_faint: TextInk::color(Appearance::Dark, Ink::APPKIT.dark.faint),
36            text_dim: color::grey(0x98),
37            solid: color::neutral(0.922),         // near-white plate
38            on_solid: color::grey(0x0e),          // near-black label
39            accent: color::neutral(0.673),        // indigo-400's lightness, no chroma
40            accent_strong: color::neutral(0.922), // the solid plate
41            on_accent: color::grey(0x0e),         // its inverse label
42            danger: color::oklch(0.704, 0.191, 22.216), // red-400
43            danger_muted: color::oklch(0.808, 0.114, 19.571), // red-300
44            warning: color::oklch(0.828, 0.189, 84.429), // amber-400
45            warning_muted: color::oklch(0.924, 0.12, 95.746), // amber-200
46            success: color::oklch(0.765, 0.177, 163.223), // emerald-400
47            busy: color::oklch(0.718, 0.202, 349.761), // pink-400
48            success_muted: color::oklch(0.845, 0.143, 164.978), // emerald-300
49            surface_raised_hover: color::neutral(0.29),
50            band: paint::band_for(Appearance::Dark),
51            input_bg: hsla(0.0, 0.0, 1.0, 0.03),
52            selection: rgb(0x3f638b).into(),
53            cursor: color::neutral(0.94), // near-white, opaque
54            caret: color::neutral(0.673), // the accent
55            ring: paint::hairline_for(Appearance::Dark, 0.35), // ~2.5× border_strong
56            drop_line: paint::hairline_for(Appearance::Dark, 0.35),
57            drop_target: hsla(0.0, 0.0, 1.0, 0.10),
58            danger_strong: color::oklch(0.58, 0.16, 25.0),
59            code_text: color::neutral(0.94), // near-white, a shade above body text
60            code_wash: hsla(0.0, 0.0, 1.0, 0.08), // white/8
61            syntax: SyntaxPalette::dark(
62                color::neutral(0.922),
63                color::neutral(0.60),
64                color::oklch(0.704, 0.191, 22.216),
65            ),
66            diff_add: color::oklch(0.765, 0.177, 163.223), // emerald-400
67            diff_del: color::oklch(0.704, 0.191, 22.216),  // red-400
68            diff_hunk_bg: hsla(0.6, 0.35, 0.6, 0.05),
69            // One small step up from `bg`: the terminal reads as its own pane
70            // without becoming a lighter box.
71            terminal_bg: color::grey(0x09),
72            terminal_ansi: ansi(ANSI_DARK),
73            vibrancy_alpha: Self::VIBRANCY_ALPHA,
74            // SwiftUI's material tone, below.
75            content_background: color::grey(47),
76            content_opacity: Self::CONTENT_OPACITY,
77            // Darker than `surface`: the reference vibrancy scrim, `hsl(0 0% 3%)`.
78            vibrancy_tone: color::grey(8),
79            window_blur: Self::WINDOW_BLUR,
80            vibrancy: crate::behind_window_blending(),
81            glass: crate::LENSED,
82            // SwiftUI's frost, measured 2026-08-31: `tint / (1 - gain)` implies
83            // one tone across all five thicknesses (49.8 down to 45.3), and the
84            // sigma does not move with them. The rim is bezel's, not Apple's —
85            // SwiftUI's material has no lit edge at all, and the popover card
86            // used to draw this hairline itself.
87            material: MaterialSpec {
88                tone: color::grey(47),
89                saturation: 2.1,
90                blur: 21.0,
91                edge: 0.10,
92                edge_width: 1.0,
93                edge_aa: 0.5,
94            },
95            // Measured 2026-08-31 off a real NSGlassEffectView, one
96            // whole-canvas tone at a time: a fill the size of the probe cannot
97            // be contaminated by a 10pt blur, which is what every earlier
98            // reading of this look got wrong. Six greys give the line at rms
99            // 0.9 levels, four saturated tones agree on the saturation to 0.1,
100            // and the sigma is the gaussian that best fits a 70pt bar edge at
101            // the centre of a 320pt glass, rms 0.4 levels. Over a backdrop that
102            // is NOT locally flat the real material saturates less than these
103            // numbers reproduce, so its saturation is not the per-pixel one
104            // this models. Rim and lit edge are `Clear`'s.
105            glass_regular: SurfaceSpec {
106                gain: 0.311,
107                saturation: 2.55,
108                tint: gpui::hsla(0.0, 0.0, 1.0, 11.0 / 255.0),
109                // An on-screen choice, 2026-09-01. The measurement above reads
110                // 10.8, at which the interior is a flat wash and every seam in
111                // the lens shows as a step in it.
112                blur: 4.0,
113                rim: 18.75,
114                reach: 47.0,
115                // Measured 2026-08-31 over a black canvas, where the coverage
116                // blend can only pull down, so anything above the interior is
117                // rim light: +25 levels at the boundary and gone by 1pt, the
118                // same in both appearances. Clear's 0.26 was read over a bright
119                // backdrop, where the blend toward a brighter outside inflates
120                // it — at that value the rim reads as a drawn border. It scales
121                // with what is behind it (+25 over black, +36 over mid grey)
122                // where this is a constant, so it is one point on their curve.
123                edge: 0.10,
124                edge_width: 1.0,
125                edge_aa: 0.5,
126                shadow: false,
127            },
128            // `Clear` refit 2026-08-30 over the gallery's own backdrops, rms
129            // 0.4 levels on backdrop 0..212. A window that is not key carries a
130            // different material, and this is the key one. The sigma is off a
131            // 2pt rule, which a 48pt band is too wide to resolve. The rim is
132            // off the position-coded backdrop, pooled over four shapes from
133            // 96pt to 320pt and r24 to r84: one curve, rms 1.8pt.
134            glass_clear: SurfaceSpec {
135                gain: 1.029,
136                saturation: 1.0,
137                tint: gpui::hsla(0.0, 0.0, 1.0, 16.0 / 255.0),
138                blur: 1.2,
139                rim: 18.75,
140                reach: 47.0,
141                edge: 0.26,
142                edge_width: 1.4,
143                edge_aa: 0.5,
144                shadow: false,
145            },
146            // Matched on screen against a real NSGlassEffectView, which is a
147            // higher bar than the dome's algebra: fitting the formula to their
148            // measured curve lands ~15% short of what the shader then renders.
149            popover_surface: SurfaceStyle::Glass(Glass::Regular),
150            drop_preview: SurfaceStyle::Material(Material::Thin),
151            carried_surface: SurfaceStyle::Glass(Glass::Regular),
152            glass_magnify: 1.1,
153            glass_dispersion: 0.005,
154            font_sans: SYSTEM_SANS.into(),
155            font_body: SYSTEM_SANS.into(),
156            font_mono: system_mono().into(),
157        }
158    }
159
160    /// Build the light theme.
161    ///
162    /// Neutrals are the same oklch scale read from the other end, but the *roles*
163    /// are reassigned rather than mirrored (see the module docs): content plane
164    /// white, chrome grey, raised surfaces white-plus-shadow. Text tones are
165    /// picked to reproduce the dark theme's contrast ratios, and accents drop
166    /// from the 400 to the 600 step at identical hue so they clear WCAG AA on
167    /// white instead of glowing.
168    pub fn light() -> Self {
169        Self {
170            appearance: Appearance::Light,
171            family: None,
172            bg: color::grey(0xff), // main panel — clean white
173            // Deeper than ~neutral-100 looks on paper: the content card is pure
174            // white and sits *inside* this surface, so too small a step leaves the
175            // whole window one flat sheet with a hairline drawn on it.
176            surface: color::neutral(0.968),
177            // A real grey, NOT white. This is the opaque-plate tone — user
178            // message bubbles, the jump-to-bottom pill — and those sit directly
179            // on the white content plane with no border or shadow to save them.
180            // White here made the user's own messages vanish into the page.
181            // Popovers do not use this; they have their own ladder below.
182            surface_raised: color::neutral(0.940),
183            surface_card: color::grey(0xff),
184            surface_dialog: color::grey(0xff),
185            surface_overlay: color::grey(0xff),
186            // The same token in light: black at 4%.
187            element_hover: hsla(0.0, 0.0, 0.0, 0.04),
188            // The same rung in light: black at 6%.
189            element_active: hsla(0.0, 0.0, 0.0, 0.06),
190            border_faint: paint::hairline_for(Appearance::Light, 0.06),
191            border: paint::hairline_for(Appearance::Light, 0.08),
192            border_strong: paint::hairline_for(Appearance::Light, 0.14),
193            text: TextInk::color(Appearance::Light, Ink::APPKIT.light.text),
194            text_muted: TextInk::color(Appearance::Light, Ink::APPKIT.light.muted),
195            text_faint: TextInk::color(Appearance::Light, Ink::APPKIT.light.faint),
196            text_dim: color::neutral(0.50),
197            solid: color::neutral(0.205), // near-black plate, deeper than body text
198            on_solid: color::neutral(0.985), // near-white label
199            accent: color::neutral(0.511), // indigo-600's lightness, no chroma
200            accent_strong: color::neutral(0.205), // the solid plate
201            on_accent: color::neutral(0.985), // its inverse label
202            danger: color::oklch(0.577, 0.245, 27.325), // red-600
203            danger_muted: color::oklch(0.505, 0.213, 27.518), // red-700
204            warning: color::oklch(0.555, 0.163, 48.998), // amber-700 — carries 12px text
205            warning_muted: color::oklch(0.473, 0.137, 46.201), // amber-800
206            success: color::oklch(0.596, 0.145, 163.225), // emerald-600
207            busy: color::oklch(0.592, 0.249, 0.584), // pink-600
208            success_muted: color::oklch(0.508, 0.118, 165.612), // emerald-700
209            // Opaque pills darken on hover here rather than brighten — same
210            // "brighten the plate, don't wash it out" rule, read the other way.
211            surface_raised_hover: color::neutral(0.900),
212            // A recessed strip on white needs far less ink than on near-black;
213            // the dark 16% would read as a bruise.
214            band: paint::band_for(Appearance::Light),
215            input_bg: color::grey(0xff),
216            selection: rgb(0xb3d7ff).into(),
217            cursor: color::neutral(0.205), // near-black, opaque
218            caret: color::neutral(0.511),  // the accent
219            ring: paint::hairline_for(Appearance::Light, 0.35),
220            drop_line: paint::hairline_for(Appearance::Light, 0.35),
221            drop_target: hsla(0.0, 0.0, 0.0, 0.06),
222            danger_strong: color::oklch(0.51, 0.20, 25.0),
223            code_text: color::neutral(0.18), // near-black, a shade under body text
224            code_wash: hsla(0.0, 0.0, 0.0, 0.06), // black/6
225            syntax: SyntaxPalette::light(
226                color::neutral(0.25),
227                color::neutral(0.48),
228                color::oklch(0.505, 0.213, 27.518),
229            ),
230            diff_add: color::oklch(0.596, 0.145, 163.225), // emerald-600
231            diff_del: color::oklch(0.577, 0.245, 27.325),  // red-600
232            diff_hunk_bg: hsla(0.6, 0.35, 0.35, 0.07),
233            // One step down from the white `bg`. Larger than dark's 3/255: a
234            // near-white delta that separates on near-black vanishes on white.
235            terminal_bg: color::grey(0xfa),
236            terminal_ansi: ansi(ANSI_LIGHT),
237            vibrancy_alpha: Self::VIBRANCY_ALPHA,
238            // SwiftUI's material tone, below.
239            content_background: color::grey(235),
240            content_opacity: Self::CONTENT_OPACITY,
241            // The material's own measured tone.
242            vibrancy_tone: color::grey(235),
243            window_blur: Self::WINDOW_BLUR,
244            vibrancy: crate::behind_window_blending(),
245            glass: crate::LENSED,
246            // Measured 2026-08-30, macOS 26.3 LIGHT, same instruments. The
247            // material is not a tone-flip of dark: `regular` keeps its opacity
248            // (86%) and swaps a 19% grey base for a 97% white one, which is why
249            // it reads as ordinary frost here. `clear` stops compressing
250            // altogether — it is very nearly a pure lift.
251            // Material: the menu surface before glass, kept as a look of its own
252            // because it stays readable over content a lens would only bend.
253            // Light's own tone, same instrument: gain 0.378 at `Regular`, so
254            // opacity 0.622 against dark's 0.638 — the scale is shared and only
255            // the tone moves. The sigma is dark's; it is a SwiftUI constant,
256            // not an appearance choice.
257            material: MaterialSpec {
258                tone: color::grey(235),
259                saturation: 2.1,
260                blur: 21.0,
261                edge: 0.10,
262                edge_width: 1.0,
263                edge_aa: 0.5,
264            },
265            // The real material measured 2026-08-31, same instrument as dark:
266            // gain 0.139, tint white at 214/255, saturation 4.27 (within 0.1
267            // over four hues). 84% of its output is tint.
268            //
269            // Shipped off that pair since 2026-09-23: a third of the tint
270            // traded for the backdrop it was covering, holding the product
271            // `tint.a / (1 - gain)` that [`SurfaceSpec::flat`] reads, so a
272            // build with no lens keeps the white panel it had. Saturation
273            // comes down with it — 4.27 was fit at 13.9% transmission and
274            // oversaturates at 40%. Rim and lit edge are dark's, unmeasured
275            // here.
276            glass_regular: SurfaceSpec {
277                gain: 0.40,
278                saturation: 2.2,
279                tint: gpui::hsla(0.0, 0.0, 1.0, 150.0 / 255.0),
280                blur: 8.9,
281                rim: 18.75,
282                reach: 47.0,
283                edge: 0.10,
284                edge_width: 1.0,
285                edge_aa: 0.5,
286                shadow: false,
287            },
288            // Carries dark's rim, sigma and edge: light has not been
289            // re-measured since the instrument learned to hold the window key.
290            glass_clear: SurfaceSpec {
291                gain: 1.041,
292                saturation: 1.0,
293                tint: gpui::hsla(0.0, 0.0, 1.0, 18.8 / 255.0),
294                blur: 1.2,
295                rim: 18.75,
296                reach: 47.0,
297                edge: 0.26,
298                edge_width: 1.4,
299                edge_aa: 0.5,
300                shadow: false,
301            },
302            // At 1-3pt inside the rim the real material drags the backdrop 26pt
303            // or more, and lets go by 5.5pt; on the shader's profile that is 8.
304            popover_surface: SurfaceStyle::Glass(Glass::Regular),
305            drop_preview: SurfaceStyle::Material(Material::Thin),
306            carried_surface: SurfaceStyle::Glass(Glass::Regular),
307            glass_magnify: 1.1,
308            glass_dispersion: 0.005,
309            font_sans: SYSTEM_SANS.into(),
310            font_body: SYSTEM_SANS.into(),
311            font_mono: system_mono().into(),
312        }
313    }
314
315    /// Build the theme for an appearance.
316    pub fn for_appearance(appearance: Appearance) -> Self {
317        match appearance {
318            Appearance::Dark => Self::dark(),
319            Appearance::Light => Self::light(),
320        }
321    }
322}
323
324/// The 16 ANSI colours on the dark terminal background.
325const ANSI_DARK: [u32; 16] = [
326    0x242424, // black — visible against #090909
327    0xf87171, // red
328    0x4ade80, // green
329    0xfacc15, // yellow
330    0x60a5fa, // blue
331    0xc084fc, // magenta
332    0x22d3ee, // cyan
333    0xd4d4d8, // white
334    0x52525b, // bright black
335    0xfca5a5, // bright red
336    0x86efac, // bright green
337    0xfde047, // bright yellow
338    0x93c5fd, // bright blue
339    0xd8b4fe, // bright magenta
340    0x67e8f9, // bright cyan
341    0xfafafa, // bright white
342];
343
344/// The same slots on the light background: the dark table's hue families at
345/// their 600/700 steps. "Bright" is darker here, not lighter, so it stays the
346/// more prominent half; bright black steps lighter than black in both tables.
347const ANSI_LIGHT: [u32; 16] = [
348    0x1f1f1f, // black
349    0xdc2626, // red — red-600
350    0x16a34a, // green — green-600
351    // Amber-700, not yellow-600: yellow-600 is 2.8:1 on white.
352    0xb45309, // yellow — amber-700
353    0x2563eb, // blue — blue-600
354    0x9333ea, // magenta — purple-600
355    0x0e7490, // cyan — cyan-700 (600 is too pale on white)
356    0x3f3f46, // white — the body-text tone, zinc-700
357    0x71717a, // bright black — zinc-500
358    0xb91c1c, // bright red — red-700
359    0x15803d, // bright green — green-700
360    0x92400e, // bright yellow — amber-800
361    0x1d4ed8, // bright blue — blue-700
362    0x7e22ce, // bright magenta — purple-700
363    0x155e75, // bright cyan — cyan-800
364    0x18181b, // bright white — max emphasis, zinc-900
365];
366
367fn ansi(table: [u32; 16]) -> [gpui::Hsla; 16] {
368    table.map(|c| rgb(c).into())
369}
370
371/// gpui's alias for whatever the platform calls its UI font, resolved per
372/// backend by `font_name_with_fallbacks`.
373const SYSTEM_SANS: &str = ".SystemUIFont";
374
375/// The mono face has no alias of its own, so each backend is named here.
376/// macOS wants the dot-name — "SF Mono" does not resolve. wasm has only what
377/// gpui-web bundles, which `.ZedMono` names.
378fn system_mono() -> &'static str {
379    if cfg!(target_family = "wasm") {
380        ".ZedMono"
381    } else if cfg!(target_os = "macos") {
382        ".AppleSystemUIFontMonospaced"
383    } else if cfg!(target_os = "windows") {
384        // Ships with Windows 11 and with Terminal. Nothing names a second
385        // choice behind it — `font_name_with_fallbacks` resolves the dot-names
386        // and nothing else — so a system without it gets whatever DirectWrite
387        // substitutes.
388        "Cascadia Mono"
389    } else {
390        "DejaVu Sans Mono"
391    }
392}