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}