Skip to main content

theme/theme/
palettes.rs

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