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