theme/brand.rs
1//! Brand: one hue for the greys, one for the accent, one radius, and the
2//! text ink.
3//!
4//! Apart from the text ink, a brand moves hue alone: every other lightness in
5//! `palettes.rs` stays as the palette set it.
6
7use gpui::{App, Global, Hsla};
8
9use crate::{Appearance, color, theme::Theme};
10
11/// A hue and how much of it, in oklch terms. `chroma: 0.0` is the shipped
12/// neutral, so [`Brand::default`] reproduces the built-in palette exactly.
13#[derive(Debug, Clone, Copy, PartialEq, Default)]
14pub struct Tint {
15 /// oklch hue, in degrees.
16 pub hue: f32,
17 /// oklch chroma. Neutral ramps live near 0.01–0.05; an accent carries more.
18 pub chroma: f32,
19}
20
21impl Tint {
22 pub const NONE: Self = Self {
23 hue: 0.0,
24 chroma: 0.0,
25 };
26
27 pub const fn new(hue: f32, chroma: f32) -> Self {
28 Self { hue, chroma }
29 }
30}
31
32/// The greys a UI is built on, as oklch hue and chroma.
33///
34/// Tailwind's five neutral families at their 500 step (tailwindcss.com/docs/colors,
35/// read 2026-08-24) — the same list shadcn offers as its base colour, and the
36/// reason these are quoted rather than invented: a neutral that carries hue is
37/// a judgement someone else has already made five times.
38pub const BASE_COLORS: [(&str, Tint); 5] = [
39 ("Neutral", Tint::NONE),
40 ("Stone", Tint::new(58.071, 0.013)),
41 ("Zinc", Tint::new(285.938, 0.016)),
42 ("Gray", Tint::new(264.364, 0.027)),
43 ("Slate", Tint::new(257.417, 0.046)),
44];
45
46/// The alpha of each text role over the palette's ink — the colour of
47/// [`Theme::text`] at full opacity. The shipped palettes' ink is white in dark
48/// and black in light.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct TextInk {
51 /// [`Theme::text`].
52 pub text: f32,
53 /// [`Theme::text_muted`].
54 pub muted: f32,
55 /// [`Theme::text_faint`].
56 pub faint: f32,
57}
58
59impl TextInk {
60 /// The role at `alpha` over `appearance`'s ink.
61 pub fn color(appearance: Appearance, alpha: f32) -> Hsla {
62 match appearance {
63 Appearance::Dark => gpui::hsla(0.0, 0.0, 1.0, alpha),
64 Appearance::Light => gpui::hsla(0.0, 0.0, 0.0, alpha),
65 }
66 }
67
68 /// Writes [`Theme::text`], [`Theme::text_muted`] and [`Theme::text_faint`]
69 /// at these alphas over the colour `theme.text` already carries.
70 pub fn paint(&self, theme: &mut Theme) {
71 let ink = theme.text;
72 theme.text = ink.alpha(self.text);
73 theme.text_muted = ink.alpha(self.muted);
74 theme.text_faint = ink.alpha(self.faint);
75 }
76}
77
78/// Text ink for both appearances.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct Ink {
81 pub dark: TextInk,
82 pub light: TextInk,
83}
84
85impl Ink {
86 /// AppKit's label, secondary and tertiary label colours. Muted is under
87 /// 4.5:1 on white, and faint is under 3:1 in both appearances.
88 pub const APPKIT: Self = Self {
89 dark: TextInk {
90 text: 0.85,
91 muted: 0.55,
92 faint: 0.25,
93 },
94 light: TextInk {
95 text: 0.85,
96 muted: 0.50,
97 faint: 0.26,
98 },
99 };
100
101 /// Text and muted at 4.5:1 or more and faint at 3:1 or more, on
102 /// [`Theme::bg`] and [`Theme::surface`] in both appearances.
103 pub const ACCESSIBLE: Self = Self {
104 dark: TextInk {
105 text: 0.85,
106 muted: 0.52,
107 faint: 0.40,
108 },
109 light: TextInk {
110 text: 0.85,
111 muted: 0.56,
112 faint: 0.43,
113 },
114 };
115
116 pub fn for_appearance(&self, appearance: Appearance) -> TextInk {
117 match appearance {
118 Appearance::Dark => self.dark,
119 Appearance::Light => self.light,
120 }
121 }
122}
123
124/// Whether the window composites translucent — AppKit's vibrancy, and Mica.
125///
126/// Three-valued rather than a bool because the honest answer depends on the
127/// appearance, and the appearance moves under the app: the OS switches at
128/// sunset. A bool would have to be rewritten by whoever noticed, and nothing
129/// outside this crate is told when a palette is installed.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub enum Vibrancy {
132 /// Frost where the palette is built for it, which is dark alone.
133 ///
134 /// Light's glass tokens sit near 85% opacity — see [`Theme::glass_overlay`],
135 /// [`Theme::card_glass_bg`] and [`Theme::input_glass_bg`], each tuned there
136 /// because a translucent white tint left text ghosting over whatever sat
137 /// behind it. Light pays for the blur and shows almost none of it, so it is
138 /// not asked for unless an app says so.
139 #[default]
140 Auto,
141 /// Frost in both appearances. What an app asks for when it has tuned its
142 /// own light palette to carry one.
143 On,
144 /// Opaque in both. What a Reduce-transparency switch sets.
145 Off,
146}
147
148impl Vibrancy {
149 /// Whether to composite translucent for `appearance`.
150 ///
151 /// The platform gate rides on [`Vibrancy::Auto`] rather than on the caller:
152 /// it asks for a material only where the compositor blends one behind the
153 /// window ([`behind_window_blending`](crate::behind_window_blending)), since a window that is merely transparent
154 /// shows raw desktop through the sidebar. An app that knows its compositor
155 /// says [`Vibrancy::On`] and gets it anywhere.
156 pub fn on(self, appearance: Appearance) -> bool {
157 match self {
158 Self::Auto => crate::behind_window_blending() && matches!(appearance, Appearance::Dark),
159 Self::On => true,
160 Self::Off => false,
161 }
162 }
163}
164
165/// What an app changes about the shipped palette without redesigning it.
166///
167/// Installed as a gpui [`Global`]; [`Theme::install`] applies it to whatever
168/// palette is registered, so it survives a light/dark switch and composes with
169/// [`crate::AppExt::set_palette`](crate::AppExt::set_palette) rather than competing with it.
170#[derive(Debug, Clone, Copy, PartialEq)]
171pub struct Brand {
172 /// The hue every grey in the palette carries. Opaque grey tokens take it;
173 /// a token that carries a hue of its own keeps it, and translucent ink is
174 /// left as it is.
175 pub tint: Tint,
176 /// The emphasis hue. Left neutral, the accent follows [`Self::tint`] like
177 /// any other grey — which is what the shipped palette already is. Set, it
178 /// moves [`Theme::accent`] and [`Theme::accent_strong`], and
179 /// [`Theme::on_accent`] becomes whichever label holds on that plate.
180 pub accent: Tint,
181 /// The base corner radius; every other corner is a ratio of it. See
182 /// [`Theme::BASE_RADIUS`].
183 pub radius: f32,
184 /// How opaque the tint over the blurred window is. See
185 /// [`Theme::VIBRANCY_ALPHA`], where it starts, and [`Theme::vibrancy_tint`].
186 pub vibrancy_alpha: f32,
187 /// How much of the chrome a floating content card covers. See
188 /// [`Theme::CONTENT_OPACITY`], where it starts, and [`Theme::content_bg`].
189 pub content_opacity: f32,
190 /// The blur behind the translucent window. See [`Theme::window_blur`].
191 pub window_blur: f32,
192 /// Whether the window composites translucent, which is a question about
193 /// the appearance as much as about the app — see [`Vibrancy`].
194 pub vibrancy: Vibrancy,
195 /// Whether components paint glass. Separate from [`Self::vibrancy`]:
196 /// SwiftUI's material blends within the window, so an opaque window can
197 /// still carry glass — which is what a Reduce-transparency setting asks
198 /// for and the system's own does not do. The converse is a platform
199 /// answer: a Mica window carries no glass, since the blur a card lays over
200 /// the content it covers is [`LENSED`](crate::LENSED) and Mica is not.
201 pub glass: bool,
202 /// The text roles' alphas. [`Ink::APPKIT`] is what the palettes ship.
203 pub ink: Ink,
204}
205
206impl Global for Brand {}
207
208impl Default for Brand {
209 fn default() -> Self {
210 Self {
211 tint: Tint::NONE,
212 accent: Tint::NONE,
213 radius: Theme::BASE_RADIUS,
214 vibrancy_alpha: Theme::VIBRANCY_ALPHA,
215 content_opacity: Theme::CONTENT_OPACITY,
216 window_blur: Theme::WINDOW_BLUR,
217 vibrancy: Vibrancy::Auto,
218 glass: crate::LENSED,
219 ink: Ink::APPKIT,
220 }
221 }
222}
223
224/// The accent's lightness in each appearance — indigo-400's and indigo-600's,
225/// the two steps `palettes.rs` picked so an accent clears WCAG AA on its own
226/// background rather than glowing on one and vanishing on the other.
227const ACCENT_L: (f32, f32) = (0.673, 0.511);
228
229/// The lightness of a *plate* carrying [`Theme::on_accent`], taken from
230/// `danger_strong` — the palette's existing chromatic plate, already tuned to
231/// hold a label in both appearances.
232const PLATE_L: (f32, f32) = (0.58, 0.51);
233
234impl Theme {
235 /// The shipped palette for an appearance, rotated onto a brand. What
236 /// [`Theme::install`] builds, without installing it — for previewing the
237 /// appearance you are not currently painting.
238 pub fn branded(brand: &Brand, appearance: Appearance) -> Self {
239 let mut theme = Self::for_appearance(appearance);
240 brand.apply(&mut theme);
241 theme
242 }
243}
244
245impl Brand {
246 /// Rotate a palette onto this brand's hues.
247 pub fn apply(&self, theme: &mut Theme) {
248 // The one place the appearance and the brand are both in hand, and it
249 // already runs on every install — which is every light/dark switch,
250 // from the OS or from a person. Resolving here is what keeps vibrancy
251 // right across a sunset without anyone outside watching for one.
252 theme.vibrancy = self.vibrancy.on(theme.appearance);
253 // Left where the brand put it, even when the window is opaque: every
254 // reader of the tint is behind a `vibrancy` gate already
255 // ([`Theme::window_bg`]), and an app that turns the frost back on
256 // wants the coverage it tuned, not a value that was overwritten while
257 // nothing was looking at it.
258 theme.vibrancy_alpha = self.vibrancy_alpha;
259 theme.content_opacity = self.content_opacity;
260 theme.window_blur = self.window_blur;
261 theme.glass = self.glass;
262 self.ink.for_appearance(theme.appearance).paint(theme);
263 // Every colour token, with the rule doing the choosing: a token that is
264 // already grey takes the tint, and one that already carries a hue —
265 // danger, warning, success — is semantic and keeps it. Translucent ink
266 // is skipped because it paints over whatever is beneath it, which is
267 // tinted already.
268 let tint = match theme.family {
269 None => self.tint,
270 Some(_) => Tint::NONE,
271 };
272 for (_, slot) in theme.tokens_mut() {
273 if slot.a == 1.0 && slot.s <= f32::EPSILON {
274 *slot = color::tint(*slot, tint.hue, tint.chroma);
275 }
276 }
277
278 if self.accent.chroma > 0.0 {
279 let light = theme.appearance == Appearance::Light;
280 let (accent_l, plate_l) = if light {
281 (ACCENT_L.1, PLATE_L.1)
282 } else {
283 (ACCENT_L.0, PLATE_L.0)
284 };
285 theme.accent = color::oklch(accent_l, self.accent.chroma, self.accent.hue);
286 theme.accent_strong = color::oklch(plate_l, self.accent.chroma, self.accent.hue);
287 // Whichever label the plate can actually hold. The shipped accent is
288 // the maximum-contrast neutral, where the answer is always the
289 // inverse; a chromatic plate at a yellow hue is bright enough that
290 // the inverse would be the unreadable one.
291 theme.on_accent = label_on(theme.accent_strong, theme);
292 }
293 }
294}
295
296/// Whichever of the palette's two extremes the plate can actually hold.
297fn label_on(plate: Hsla, theme: &Theme) -> Hsla {
298 color::legible_on(plate, theme.solid, theme.on_solid)
299}
300
301/// Read the installed brand (the default before one is set).
302pub(crate) fn brand(cx: &App) -> Brand {
303 cx.try_global::<Brand>().copied().unwrap_or_default()
304}
305
306/// Install a brand and repaint every window with it.
307///
308/// Colours are read imperatively at paint time, so nothing observes the theme
309/// global — the same reason [`appearance::apply`](crate::appearance::apply)
310/// refreshes rather than notifies.
311pub(crate) fn set_brand(brand: Brand, cx: &mut App) {
312 cx.set_global(brand);
313 Theme::install(crate::paint::current_appearance(), cx);
314 // Crossing 1.0 is what puts the `NSVisualEffectView` in or takes it out,
315 // and nothing else does it — a repaint alone leaves a window that was
316 // opaque at boot opaque forever.
317 crate::appearance::reapply_window_background(cx);
318 cx.refresh_windows();
319}