frus_widgets/theme.rs
1//! The theme: *design tokens* (colors, radius, spacing) injected into rendering.
2//!
3//! The theme is handed to [`crate::build_ui`] and passed on to `Widget::paint`;
4//! widgets use it for their default values (text color, text fields, scrollbars…),
5//! without preventing an explicit override.
6
7use frus_core::{Color, FontWeight, TextDirection, TextStyle};
8
9use crate::interaction::Status;
10use crate::media::Brightness;
11
12/// **Which of an application's themes is on display** (`app.dart:57`).
13///
14/// An application supplies a light theme and, if it has one, a dark theme; this says
15/// which of the two the framework picks. It is a *question about the application*, not
16/// about the device: [`Brightness`](crate::Brightness) is what the platform reports, and
17/// [`System`](Self::System) is the mode that agrees to follow it.
18///
19/// The framework resolves this once a frame and fades between the answers, so an
20/// application never has to read the platform's brightness or write a crossfade of its
21/// own.
22#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
23pub enum ThemeMode {
24 /// Follow the platform: the dark theme where the system asks for a dark interface,
25 /// the light one otherwise. **The default**, and the answer most applications want.
26 #[default]
27 System,
28 /// The light theme, whatever the platform says.
29 Light,
30 /// The dark theme, whatever the platform says — falling back to the light one when
31 /// the application has no dark theme to give.
32 Dark,
33}
34
35impl ThemeMode {
36 /// Does this mode follow the platform?
37 pub const fn is_system(self) -> bool {
38 matches!(self, ThemeMode::System)
39 }
40
41 /// Does this mode pin the light theme?
42 pub const fn is_light(self) -> bool {
43 matches!(self, ThemeMode::Light)
44 }
45
46 /// Does this mode pin the dark theme?
47 pub const fn is_dark(self) -> bool {
48 matches!(self, ThemeMode::Dark)
49 }
50
51 /// **Does this mode want a dark interface** on a platform reporting `brightness`?
52 ///
53 /// The one line the whole light/dark decision comes down to (`app.dart:1000`), and
54 /// the reason it lives here rather than in the shell: an application that shows a
55 /// *theme* setting with a *System* entry needs the same answer to tick the right row.
56 pub const fn wants_dark(self, brightness: Brightness) -> bool {
57 match self {
58 ThemeMode::Dark => true,
59 ThemeMode::Light => false,
60 ThemeMode::System => matches!(brightness, Brightness::Dark),
61 }
62 }
63}
64
65/// The **named** typographic scale (Material 3's 15 steps). Widgets pick a step
66/// (`theme.text.title_medium`), never a hardcoded size — changing the scale
67/// retypesets the whole app. The colors stay inherited (`None` → resolved against
68/// the theme at paint time).
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub struct TextTheme {
71 /// The largest step of the display group: short, prominent text such as a hero figure.
72 pub display_large: TextStyle,
73 /// The middle step of the display group: short, prominent text such as a hero figure.
74 pub display_medium: TextStyle,
75 /// The smallest step of the display group: short, prominent text such as a hero figure.
76 pub display_small: TextStyle,
77 /// The largest step of the headline group: the heading of a screen or of a large section.
78 pub headline_large: TextStyle,
79 /// The middle step of the headline group: the heading of a screen or of a large section.
80 pub headline_medium: TextStyle,
81 /// The smallest step of the headline group: the heading of a screen or of a large section.
82 pub headline_small: TextStyle,
83 /// The largest step of the title group: the title of a smaller piece of content: a card, a dialog, a row.
84 pub title_large: TextStyle,
85 /// The middle step of the title group: the title of a smaller piece of content: a card, a dialog, a row.
86 pub title_medium: TextStyle,
87 /// The smallest step of the title group: the title of a smaller piece of content: a card, a dialog, a row.
88 pub title_small: TextStyle,
89 /// The largest step of the body group: running text meant to be read.
90 pub body_large: TextStyle,
91 /// The middle step of the body group: running text meant to be read.
92 pub body_medium: TextStyle,
93 /// The smallest step of the body group: running text meant to be read.
94 pub body_small: TextStyle,
95 /// The largest step of the label group: the text on a control: a button, a tab, a caption.
96 pub label_large: TextStyle,
97 /// The middle step of the label group: the text on a control: a button, a tab, a caption.
98 pub label_medium: TextStyle,
99 /// The smallest step of the label group: the text on a control: a button, a tab, a caption.
100 pub label_small: TextStyle,
101}
102
103impl TextTheme {
104 /// The reference Material 3 scale (sizes in logical pixels; the title and label
105 /// steps carry a medium weight, as the spec has it).
106 ///
107 /// A **const**, and that matters: a widget measured with no theme in hand — the
108 /// un-themed [`Widget::style`](crate::Widget::style) path the transparent wrappers
109 /// take — reads its step from *this* rather than from a private constant of its own.
110 /// Twelve widgets used to carry their own number, and one of them had drifted two
111 /// pixels from the reference without anybody being able to see it.
112 pub const M3: Self = Self {
113 display_large: TextStyle::new(57.0),
114 display_medium: TextStyle::new(45.0),
115 display_small: TextStyle::new(36.0),
116 headline_large: TextStyle::new(32.0),
117 headline_medium: TextStyle::new(28.0),
118 headline_small: TextStyle::new(24.0),
119 title_large: TextStyle::new(22.0),
120 title_medium: TextStyle::new(16.0).weight(FontWeight::Medium),
121 title_small: TextStyle::new(14.0).weight(FontWeight::Medium),
122 body_large: TextStyle::new(16.0),
123 body_medium: TextStyle::new(14.0),
124 body_small: TextStyle::new(12.0),
125 label_large: TextStyle::new(14.0).weight(FontWeight::Medium),
126 label_medium: TextStyle::new(12.0).weight(FontWeight::Medium),
127 label_small: TextStyle::new(11.0).weight(FontWeight::Medium),
128 };
129}
130
131impl Default for TextTheme {
132 fn default() -> Self {
133 Self::M3
134 }
135}
136
137/// The type scale a widget measures with: the theme's when it has one, the framework's
138/// own when it does not.
139///
140/// `None` is the un-themed [`Widget::style`](crate::Widget::style) path. It answers from
141/// the *same* scale as the themed one — the point of milestone 413 being that a widget
142/// never decides its own type, and a fallback constant beside the scale would be that
143/// decision taken back.
144pub(crate) fn type_scale(theme: Option<&Theme>) -> TextTheme {
145 theme.map_or(TextTheme::M3, |t| t.text)
146}
147
148/// The **color roles** (Material 3) — the **source of truth** for the theme's
149/// colors. Widgets reference roles, never literal colors: changing scheme recolors
150/// the whole app and guarantees the contrast of the `X`/`on_X` pairs. Written by
151/// hand for light and dark; `from_seed` (HCT) comes after.
152#[derive(Clone, Copy, Debug, PartialEq)]
153pub struct ColorScheme {
154 /// **Whether this scheme is a light one or a dark one** (`color_scheme.dart`).
155 ///
156 /// It is written down rather than worked out. The surface's luminance is *usually*
157 /// the same answer, and "usually" is the problem: a dim scheme built from a dark seed
158 /// can sit either side of the halfway line, and a widget picking its opacities from a
159 /// guess picks differently on two schemes a designer considers equally dark.
160 ///
161 /// The reference's scrollbar reads exactly this field to choose the opacity of its
162 /// thumb (`scrollbar.dart:232`); this framework's was guessing at it.
163 pub brightness: Brightness,
164 /// The app's main accent: the colour of prominent controls, such as a filled button, and
165 /// of active states.
166 pub primary: Color,
167 /// What is legible on [`primary`](Self::primary).
168 pub on_primary: Color,
169 /// A quieter fill in the primary hue, for larger surfaces such as a tonal button.
170 pub primary_container: Color,
171 /// What is legible on [`primary_container`](Self::primary_container).
172 pub on_primary_container: Color,
173 /// **A container that does not change when the theme does** (`color_scheme.dart:90`).
174 ///
175 /// Every other accent role is answered twice: once for a light scheme and once for a
176 /// dark one. These four are answered **once**, and both schemes give the same answer.
177 ///
178 /// That is not a shortcut, it is the whole point. A card carrying a brand colour, a
179 /// header the marketing department chose, an onboarding page whose illustration was
180 /// drawn against one particular green — anything that has to keep its colour when
181 /// the reader turns the lights off has nowhere to sit in a scheme where every role
182 /// moves. `primary_container` is the right *emphasis* for those and the wrong
183 /// *promise*. This is the same emphasis with the promise attached.
184 ///
185 /// Tone 90 of the primary palette, which is where a light scheme's
186 /// `primary_container` already sits — so in a light theme it costs nothing, and in
187 /// a dark one it is the thing that stayed put.
188 pub primary_fixed: Color,
189 /// The **stronger** of the pair, for what needs more emphasis than
190 /// [`primary_fixed`](Self::primary_fixed) without giving up the promise. Tone 80,
191 /// which is a dark scheme's `primary`.
192 pub primary_fixed_dim: Color,
193 /// What is legible on both of them — tone 10, dark enough to read on the dimmer of
194 /// the two.
195 pub on_primary_fixed: Color,
196 /// The quieter of the two things that can be written there: a subtitle where
197 /// [`on_primary_fixed`](Self::on_primary_fixed) is the title. Tone 30.
198 pub on_primary_fixed_variant: Color,
199 /// The supporting accent, for the less prominent controls: a filter chip, say.
200 pub secondary: Color,
201 /// What is legible on [`secondary`](Self::secondary).
202 pub on_secondary: Color,
203 /// A quieter fill in the secondary hue, for larger surfaces.
204 pub secondary_container: Color,
205 /// What is legible on [`secondary_container`](Self::secondary_container).
206 pub on_secondary_container: Color,
207 /// The supporting accent's fixed container {em} see
208 /// [`primary_fixed`](Self::primary_fixed) for what "fixed" promises.
209 pub secondary_fixed: Color,
210 /// The stronger of that pair.
211 pub secondary_fixed_dim: Color,
212 /// What is legible on both.
213 pub on_secondary_fixed: Color,
214 /// The quieter thing that can be written there.
215 pub on_secondary_fixed_variant: Color,
216 /// A **third** accent, for what is neither the app's main colour nor its supporting
217 /// one: a highlight that has to stand apart from both. Generated a sixth of the way
218 /// round the wheel from the seed, which is what keeps it from reading as either.
219 pub tertiary: Color,
220 /// What is legible on [`tertiary`](Self::tertiary).
221 pub on_tertiary: Color,
222 /// A quieter fill in the tertiary hue, for larger surfaces.
223 pub tertiary_container: Color,
224 /// What is legible on [`tertiary_container`](Self::tertiary_container).
225 pub on_tertiary_container: Color,
226 /// The third accent's fixed container {em} see
227 /// [`primary_fixed`](Self::primary_fixed) for what "fixed" promises.
228 pub tertiary_fixed: Color,
229 /// The stronger of that pair.
230 pub tertiary_fixed_dim: Color,
231 /// What is legible on both.
232 pub on_tertiary_fixed: Color,
233 /// The quieter thing that can be written there.
234 pub on_tertiary_fixed_variant: Color,
235 /// The colour of the page behind everything.
236 pub background: Color,
237 /// The base colour of the surfaces that sit on the page: cards, sheets, dialogs.
238 pub surface: Color,
239 /// What is legible on [`surface`](Self::surface) and on the background: the main text and
240 /// icon colour.
241 pub on_surface: Color,
242 /// A discreet tonal surface (zone backgrounds, tracks).
243 pub surface_variant: Color,
244 /// Secondary content on surfaces (the historical `muted`).
245 pub on_surface_variant: Color,
246 /// The container ladder's **lowest** rung: the least emphasis against the
247 /// surface. Its neighbour rather than its opposite — in a dark scheme it is
248 /// *darker* than the surface, as the reference's is.
249 pub surface_container_lowest: Color,
250 /// Less emphasis than [`Self::surface_container`]: cards off the page, banners,
251 /// drawers, sheets.
252 pub surface_container_low: Color,
253 /// A distinct area **within** the surface: menus, navigation bars.
254 pub surface_container: Color,
255 /// More emphasis than [`Self::surface_container`]: dialogs, search views.
256 pub surface_container_high: Color,
257 /// The **most** emphasis against the surface: filled cards, filled fields.
258 pub surface_container_highest: Color,
259 /// An inverted surface (toasts and snackbars that stand out from the background).
260 pub inverse_surface: Color,
261 /// What is legible on [`inverse_surface`](Self::inverse_surface).
262 pub on_inverse_surface: Color,
263 /// Outlines at rest.
264 pub outline: Color,
265 /// Discreet outlines (thin separators).
266 pub outline_variant: Color,
267 /// The colour of errors: an invalid field, a failure.
268 pub error: Color,
269 /// What is legible on [`error`](Self::error).
270 pub on_error: Color,
271 /// The **quiet** form of `error`: a field's error surface, a warning that has to be
272 /// read rather than shouted. `on_error_container` is what is legible on it — and it
273 /// is what an errored field's border, label and helper take, not `error` itself
274 /// (`input_decorator.dart:5981`).
275 pub error_container: Color,
276 /// What is legible on [`error_container`](Self::error_container).
277 pub on_error_container: Color,
278 /// The accent as it must be drawn **on `inverse_surface`**: a snack bar's action
279 /// (`snack_bar.dart:954`). `primary` on an inverted surface is the pair the scheme
280 /// guarantees nothing about, which is exactly why this role exists.
281 pub inverse_primary: Color,
282 /// What a raised surface is tinted **towards** as it lifts — `primary` in Material 3.
283 /// The elevation model there is a tint, not a shadow, and this is the colour it tints
284 /// with (`bottom_app_bar.dart:301`).
285 pub surface_tint: Color,
286 /// The **darkest** surface in either theme, and the **lightest** — the two ends the
287 /// container ladder runs between.
288 pub surface_dim: Color,
289 /// The **lightest** surface in either theme — the other end of the ladder from
290 /// [`surface_dim`](Self::surface_dim).
291 pub surface_bright: Color,
292 /// The scrim for modals and drawers (the alpha is applied at the point of use).
293 pub scrim: Color,
294 /// The color of drop shadows (the alpha is applied at the point of use).
295 pub shadow: Color,
296}
297
298impl ColorScheme {
299 /// The dark scheme.
300 pub fn dark() -> Self {
301 Self {
302 brightness: Brightness::Dark,
303 primary: Color::rgb8(96, 200, 130),
304 on_primary: Color::rgb8(16, 28, 20),
305 primary_container: Color::rgb8(30, 64, 44),
306 on_primary_container: Color::rgb8(178, 240, 200),
307 // The four fixed roles: tones 90, 80, 10 and 30 of the primary's own hue
308 // and chroma, read off this crate's HCT. They are byte-for-byte the same
309 // in the light scheme — that is what "fixed" means, and a test holds
310 // the two together so a later touch-up of one cannot quietly break it.
311 primary_fixed: Color::rgb8(139, 249, 175),
312 primary_fixed_dim: Color::rgb8(111, 220, 149),
313 on_primary_fixed: Color::rgb8(0, 33, 14),
314 on_primary_fixed_variant: Color::rgb8(0, 82, 43),
315 secondary: Color::rgb8(150, 170, 200),
316 on_secondary: Color::rgb8(20, 26, 36),
317 // Tone 30 of its own hue and chroma, where the reference puts a dark
318 // secondary container and where the light scheme's already sat. This one was
319 // at tone 22 — under the disabled fill it has to beat, so a slider's rail and
320 // a selected segment read as unavailable. Only checkable once milestone 329
321 // resolved that fill in sRGB; before, it was 14 tones adrift.
322 secondary_container: Color::rgb8(63, 71, 88),
323 on_secondary_container: Color::rgb8(205, 220, 240),
324 secondary_fixed: Color::rgb8(215, 226, 255),
325 secondary_fixed_dim: Color::rgb8(178, 199, 243),
326 on_secondary_fixed: Color::rgb8(2, 27, 62),
327 on_secondary_fixed_variant: Color::rgb8(50, 71, 108),
328 // The tertiary family, a sixth of the wheel from the primary at the chroma
329 // the reference's tonal-spot scheme uses (24), read off this crate's own HCT
330 // rather than picked by eye.
331 tertiary: Color::rgb8(162, 206, 217),
332 on_tertiary: Color::rgb8(1, 54, 63),
333 tertiary_container: Color::rgb8(32, 77, 86),
334 on_tertiary_container: Color::rgb8(190, 234, 246),
335 tertiary_fixed: Color::rgb8(190, 234, 247),
336 tertiary_fixed_dim: Color::rgb8(162, 205, 218),
337 on_tertiary_fixed: Color::rgb8(1, 31, 38),
338 on_tertiary_fixed_variant: Color::rgb8(33, 76, 87),
339 background: Color::rgb8(18, 20, 24),
340 surface: Color::rgb8(30, 33, 40),
341 on_surface: Color::rgb8(230, 232, 236),
342 surface_variant: Color::rgb8(38, 42, 52),
343 on_surface_variant: Color::rgb8(150, 156, 168),
344 surface_container_lowest: Color::rgb8(26, 29, 35),
345 surface_container_low: Color::rgb8(34, 37, 45),
346 surface_container: Color::rgb8(36, 40, 48),
347 surface_container_high: Color::rgb8(44, 48, 58),
348 surface_container_highest: Color::rgb8(52, 56, 68),
349 inverse_surface: Color::rgb8(226, 228, 234),
350 on_inverse_surface: Color::rgb8(28, 32, 38),
351 // Tones 60 and 30 of this palette's neutral-variant family, which is where
352 // the reference puts them in a dark scheme. Anything darker collides with a
353 // disabled outline — `on_surface` at 12 % over this surface is tone 24.
354 outline: Color::rgb8(141, 145, 153),
355 outline_variant: Color::rgb8(67, 71, 78),
356 error: Color::rgb8(224, 108, 108),
357 on_error: Color::rgb8(38, 12, 12),
358 error_container: Color::rgb8(130, 37, 41),
359 on_error_container: Color::rgb8(255, 218, 216),
360 inverse_primary: Color::rgb8(2, 109, 56),
361 surface_tint: Color::rgb8(96, 200, 130),
362 surface_dim: Color::rgb8(30, 33, 40),
363 surface_bright: Color::rgb8(57, 61, 74),
364 scrim: Color::BLACK,
365 shadow: Color::BLACK,
366 }
367 }
368
369 /// The light scheme.
370 pub fn light() -> Self {
371 Self {
372 brightness: Brightness::Light,
373 primary: Color::rgb8(46, 160, 96),
374 on_primary: Color::rgb8(255, 255, 255),
375 primary_container: Color::rgb8(200, 238, 214),
376 on_primary_container: Color::rgb8(10, 64, 36),
377 // The four fixed roles: tones 90, 80, 10 and 30 of the primary's own hue
378 // and chroma, read off this crate's HCT. They are byte-for-byte the same
379 // in the dark scheme — that is what "fixed" means, and a test holds
380 // the two together so a later touch-up of one cannot quietly break it.
381 primary_fixed: Color::rgb8(139, 249, 175),
382 primary_fixed_dim: Color::rgb8(111, 220, 149),
383 on_primary_fixed: Color::rgb8(0, 33, 14),
384 on_primary_fixed_variant: Color::rgb8(0, 82, 43),
385 secondary: Color::rgb8(90, 110, 150),
386 on_secondary: Color::rgb8(255, 255, 255),
387 secondary_container: Color::rgb8(220, 228, 244),
388 on_secondary_container: Color::rgb8(30, 42, 66),
389 secondary_fixed: Color::rgb8(215, 226, 255),
390 secondary_fixed_dim: Color::rgb8(178, 199, 243),
391 on_secondary_fixed: Color::rgb8(2, 27, 62),
392 on_secondary_fixed_variant: Color::rgb8(50, 71, 108),
393 tertiary: Color::rgb8(58, 100, 111),
394 on_tertiary: Color::rgb8(255, 255, 255),
395 tertiary_container: Color::rgb8(190, 234, 246),
396 on_tertiary_container: Color::rgb8(1, 31, 38),
397 tertiary_fixed: Color::rgb8(190, 234, 247),
398 tertiary_fixed_dim: Color::rgb8(162, 205, 218),
399 on_tertiary_fixed: Color::rgb8(1, 31, 38),
400 on_tertiary_fixed_variant: Color::rgb8(33, 76, 87),
401 background: Color::rgb8(245, 246, 248),
402 surface: Color::rgb8(255, 255, 255),
403 on_surface: Color::rgb8(28, 32, 38),
404 surface_variant: Color::rgb8(238, 240, 244),
405 on_surface_variant: Color::rgb8(110, 116, 126),
406 surface_container_lowest: Color::rgb8(255, 255, 255),
407 surface_container_low: Color::rgb8(250, 250, 252),
408 surface_container: Color::rgb8(244, 245, 248),
409 surface_container_high: Color::rgb8(238, 240, 244),
410 surface_container_highest: Color::rgb8(232, 235, 240),
411 inverse_surface: Color::rgb8(45, 50, 58),
412 on_inverse_surface: Color::rgb8(240, 242, 246),
413 // Tones 50 and 80, the reference's light-scheme positions. A disabled
414 // outline here is tone 91, so both must sit well below it.
415 outline: Color::rgb8(115, 119, 127),
416 outline_variant: Color::rgb8(195, 198, 207),
417 error: Color::rgb8(200, 64, 64),
418 on_error: Color::rgb8(255, 255, 255),
419 error_container: Color::rgb8(255, 218, 215),
420 on_error_container: Color::rgb8(65, 0, 5),
421 inverse_primary: Color::rgb8(111, 220, 149),
422 surface_tint: Color::rgb8(46, 160, 96),
423 surface_dim: Color::rgb8(223, 228, 234),
424 surface_bright: Color::rgb8(255, 255, 255),
425 scrim: Color::BLACK,
426 shadow: Color::BLACK,
427 }
428 }
429
430 /// Generates a complete scheme from **a seed color** (Material 3 "dynamic
431 /// color", through [HCT](frus_core::Hct)). The seed's hue feeds five tonal
432 /// palettes (primary, secondary, tertiary — not exposed for now — and the
433 /// neutrals); each role is a precise **tone** of its palette, which guarantees
434 /// the contrast of the `X`/`on_X` pairs.
435 ///
436 /// Deliberate departures from the M3 spec: `surface` sits slightly apart from
437 /// `background` (our cards lay a surface over the background, tones 12/6 in
438 /// dark, 100/98 in light) — the 2023 spec conflates them.
439 ///
440 /// The **container ladder** is anchored on *that* surface rather than on the
441 /// spec's. Its five rungs keep the reference's own tonal steps — going toward
442 /// more emphasis, −4, −2, −2, −2 in light and +6, +2, +5, +5 in dark — measured
443 /// from `surface_container`, so every rung stands off this scheme's surface by
444 /// what it stands off the reference's. In light the top rung lands on tone 100,
445 /// which is where this scheme's `surface` already is: the departure showing
446 /// through.
447 pub fn from_seed(seed: Color, dark: bool) -> Self {
448 use frus_core::{Hct, TonalPalette};
449
450 let hct = Hct::from_color(seed);
451 // M3 chromas: the primary keeps the seed's chroma (with a floor of 48), and
452 // the other palettes are muted variations on the hue.
453 let primary = TonalPalette::new(hct.hue, hct.chroma.max(48.0));
454 let secondary = TonalPalette::new(hct.hue, 16.0);
455 // A sixth of the wheel away, at the chroma the reference's tonal-spot scheme
456 // gives a tertiary: far enough from the primary to read as a third thing, close
457 // enough to belong to the same palette.
458 let tertiary = TonalPalette::new(hct.hue + 60.0, 24.0);
459 let neutral = TonalPalette::new(hct.hue, 4.0);
460 let neutral_variant = TonalPalette::new(hct.hue, 8.0);
461 let error = TonalPalette::new(25.0, 84.0);
462
463 let p = |tone: f64| primary.tone(tone);
464 let s = |tone: f64| secondary.tone(tone);
465 let ter = |tone: f64| tertiary.tone(tone);
466 let n = |tone: f64| neutral.tone(tone);
467 let nv = |tone: f64| neutral_variant.tone(tone);
468 let e = |tone: f64| error.tone(tone);
469
470 if dark {
471 Self {
472 brightness: Brightness::Dark,
473 primary: p(80.0),
474 on_primary: p(20.0),
475 primary_container: p(30.0),
476 on_primary_container: p(90.0),
477 // The fixed four. **The same tones in both branches** — they are read
478 // off the palettes, which do not know which theme is being built, so a
479 // seed gives one answer for a light scheme and a dark one and the
480 // promise holds by construction.
481 primary_fixed: p(90.0),
482 primary_fixed_dim: p(80.0),
483 on_primary_fixed: p(10.0),
484 on_primary_fixed_variant: p(30.0),
485 secondary: s(80.0),
486 on_secondary: s(20.0),
487 secondary_container: s(30.0),
488 on_secondary_container: s(90.0),
489 secondary_fixed: s(90.0),
490 secondary_fixed_dim: s(80.0),
491 on_secondary_fixed: s(10.0),
492 on_secondary_fixed_variant: s(30.0),
493 tertiary: ter(80.0),
494 on_tertiary: ter(20.0),
495 tertiary_container: ter(30.0),
496 on_tertiary_container: ter(90.0),
497 tertiary_fixed: ter(90.0),
498 tertiary_fixed_dim: ter(80.0),
499 on_tertiary_fixed: ter(10.0),
500 on_tertiary_fixed_variant: ter(30.0),
501 background: n(6.0),
502 surface: n(12.0),
503 on_surface: n(90.0),
504 surface_variant: nv(20.0),
505 on_surface_variant: nv(80.0),
506 surface_container_lowest: n(9.0),
507 surface_container_low: n(15.0),
508 surface_container: n(17.0),
509 surface_container_high: n(22.0),
510 surface_container_highest: n(27.0),
511 inverse_surface: n(90.0),
512 on_inverse_surface: n(20.0),
513 outline: nv(60.0),
514 outline_variant: nv(30.0),
515 error: e(80.0),
516 on_error: e(20.0),
517 error_container: e(30.0),
518 on_error_container: e(90.0),
519 // The accent as it reads on the inverted surface: the *other* theme's
520 // tone of the same palette, which is what an inverted surface is.
521 inverse_primary: p(40.0),
522 surface_tint: p(80.0),
523 surface_dim: n(12.0),
524 surface_bright: n(30.0),
525 scrim: n(0.0),
526 shadow: n(0.0),
527 }
528 } else {
529 Self {
530 brightness: Brightness::Light,
531 primary: p(40.0),
532 on_primary: p(100.0),
533 primary_container: p(90.0),
534 on_primary_container: p(10.0),
535 primary_fixed: p(90.0),
536 primary_fixed_dim: p(80.0),
537 on_primary_fixed: p(10.0),
538 on_primary_fixed_variant: p(30.0),
539 secondary: s(40.0),
540 on_secondary: s(100.0),
541 secondary_container: s(90.0),
542 on_secondary_container: s(10.0),
543 secondary_fixed: s(90.0),
544 secondary_fixed_dim: s(80.0),
545 on_secondary_fixed: s(10.0),
546 on_secondary_fixed_variant: s(30.0),
547 tertiary: ter(40.0),
548 on_tertiary: ter(100.0),
549 tertiary_container: ter(90.0),
550 on_tertiary_container: ter(10.0),
551 tertiary_fixed: ter(90.0),
552 tertiary_fixed_dim: ter(80.0),
553 on_tertiary_fixed: ter(10.0),
554 on_tertiary_fixed_variant: ter(30.0),
555 background: n(98.0),
556 surface: n(100.0),
557 on_surface: n(10.0),
558 surface_variant: nv(94.0),
559 on_surface_variant: nv(30.0),
560 surface_container_lowest: n(100.0),
561 surface_container_low: n(98.0),
562 surface_container: n(96.0),
563 surface_container_high: n(94.0),
564 surface_container_highest: n(92.0),
565 inverse_surface: n(20.0),
566 on_inverse_surface: n(95.0),
567 outline: nv(50.0),
568 outline_variant: nv(80.0),
569 error: e(40.0),
570 on_error: e(100.0),
571 error_container: e(90.0),
572 on_error_container: e(10.0),
573 inverse_primary: p(80.0),
574 surface_tint: p(40.0),
575 surface_dim: n(89.0),
576 surface_bright: n(100.0),
577 scrim: n(0.0),
578 shadow: n(0.0),
579 }
580 }
581 }
582
583 /// Interpolates role by role toward `other` (the light/dark switch fade).
584 pub fn lerp(&self, other: &ColorScheme, t: f32) -> ColorScheme {
585 let c = |a: Color, b: Color| a.lerp(b, t);
586 ColorScheme {
587 // Discrete, like the text direction: halfway between light and dark is not a
588 // third brightness, and a widget reading it mid-crossing wants the answer it
589 // will still be right about when the crossing ends.
590 brightness: other.brightness,
591 primary: c(self.primary, other.primary),
592 on_primary: c(self.on_primary, other.on_primary),
593 primary_container: c(self.primary_container, other.primary_container),
594 on_primary_container: c(self.on_primary_container, other.on_primary_container),
595 // The fixed four take part like any other role. A light/dark crossing moves
596 // them nowhere, because both ends hold the same colour — but a **palette**
597 // crossing does, and there they have to travel with everything else.
598 primary_fixed: c(self.primary_fixed, other.primary_fixed),
599 primary_fixed_dim: c(self.primary_fixed_dim, other.primary_fixed_dim),
600 on_primary_fixed: c(self.on_primary_fixed, other.on_primary_fixed),
601 on_primary_fixed_variant: c(
602 self.on_primary_fixed_variant,
603 other.on_primary_fixed_variant,
604 ),
605 secondary: c(self.secondary, other.secondary),
606 on_secondary: c(self.on_secondary, other.on_secondary),
607 secondary_container: c(self.secondary_container, other.secondary_container),
608 on_secondary_container: c(self.on_secondary_container, other.on_secondary_container),
609 secondary_fixed: c(self.secondary_fixed, other.secondary_fixed),
610 secondary_fixed_dim: c(self.secondary_fixed_dim, other.secondary_fixed_dim),
611 on_secondary_fixed: c(self.on_secondary_fixed, other.on_secondary_fixed),
612 on_secondary_fixed_variant: c(
613 self.on_secondary_fixed_variant,
614 other.on_secondary_fixed_variant,
615 ),
616 tertiary: c(self.tertiary, other.tertiary),
617 on_tertiary: c(self.on_tertiary, other.on_tertiary),
618 tertiary_container: c(self.tertiary_container, other.tertiary_container),
619 on_tertiary_container: c(self.on_tertiary_container, other.on_tertiary_container),
620 tertiary_fixed: c(self.tertiary_fixed, other.tertiary_fixed),
621 tertiary_fixed_dim: c(self.tertiary_fixed_dim, other.tertiary_fixed_dim),
622 on_tertiary_fixed: c(self.on_tertiary_fixed, other.on_tertiary_fixed),
623 on_tertiary_fixed_variant: c(
624 self.on_tertiary_fixed_variant,
625 other.on_tertiary_fixed_variant,
626 ),
627 background: c(self.background, other.background),
628 surface: c(self.surface, other.surface),
629 on_surface: c(self.on_surface, other.on_surface),
630 surface_variant: c(self.surface_variant, other.surface_variant),
631 on_surface_variant: c(self.on_surface_variant, other.on_surface_variant),
632 surface_container_lowest: c(
633 self.surface_container_lowest,
634 other.surface_container_lowest,
635 ),
636 surface_container_low: c(self.surface_container_low, other.surface_container_low),
637 surface_container: c(self.surface_container, other.surface_container),
638 surface_container_high: c(self.surface_container_high, other.surface_container_high),
639 surface_container_highest: c(
640 self.surface_container_highest,
641 other.surface_container_highest,
642 ),
643 inverse_surface: c(self.inverse_surface, other.inverse_surface),
644 on_inverse_surface: c(self.on_inverse_surface, other.on_inverse_surface),
645 outline: c(self.outline, other.outline),
646 outline_variant: c(self.outline_variant, other.outline_variant),
647 error: c(self.error, other.error),
648 on_error: c(self.on_error, other.on_error),
649 error_container: c(self.error_container, other.error_container),
650 on_error_container: c(self.on_error_container, other.on_error_container),
651 inverse_primary: c(self.inverse_primary, other.inverse_primary),
652 surface_tint: c(self.surface_tint, other.surface_tint),
653 surface_dim: c(self.surface_dim, other.surface_dim),
654 surface_bright: c(self.surface_bright, other.surface_bright),
655 scrim: c(self.scrim, other.scrim),
656 shadow: c(self.shadow, other.shadow),
657 }
658 }
659}
660
661/// **The smallest box a control that a finger works reserves for it**, in pixels
662/// (`constants.dart:27`).
663///
664/// Not a look: a target. Forty-eight is the number the accessibility scanners on both
665/// mobile platforms check for, and it is what the reference reserves by default for
666/// every switch, checkbox, radio and icon button it draws — whatever those controls
667/// actually paint inside it.
668pub const MIN_TAP_TARGET: f32 = 48.0;
669
670/// The same for a control told to reserve only what the specification requires: the
671/// minimum less eight (`checkbox.dart:522`, `switch.dart:2090`).
672pub const SHRUNK_TAP_TARGET: f32 = 40.0;
673
674/// **How much room a small control reserves for the finger that works it.**
675///
676/// A switch paints a track 32 pixels tall; a checkbox paints a box of 20. Neither is
677/// something a finger can be asked to hit. The reference lays both out inside a
678/// 48-pixel square and paints the small thing in the middle of it, and it makes that a
679/// theme-wide setting with a per-widget override, because it is the kind of decision an
680/// application makes once (`theme_data.dart:172`).
681///
682/// This is a **layout** answer, not a visual one. What the control paints does not
683/// change; the room around it does, and so does the area a click may land in.
684#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
685pub enum TapTarget {
686 /// At least [`MIN_TAP_TARGET`] on both sides — the default, as it is over there.
687 #[default]
688 Padded,
689 /// Only [`SHRUNK_TAP_TARGET`]: for the dense interface that has measured its own
690 /// reach and decided, which is a decision rather than an oversight.
691 ShrinkWrap,
692}
693
694impl TapTarget {
695 /// The smallest side, in pixels, this answer reserves.
696 pub fn min_side(self) -> f32 {
697 match self {
698 TapTarget::Padded => MIN_TAP_TARGET,
699 TapTarget::ShrinkWrap => SHRUNK_TAP_TARGET,
700 }
701 }
702}
703
704/// A set of style tokens.
705///
706/// The [`ColorScheme`] (`theme.scheme`) is the **source of truth** for the colors;
707/// the "flat" fields (`background`, `surface`, `primary`, …) are **convenience
708/// views** of the most used roles, derived from the scheme — the widgets'
709/// historical API stays intact. `focus`/`selection` are interaction accents
710/// specific to frus (outside the M3 roles).
711#[derive(Clone, Debug, PartialEq)]
712pub struct Theme {
713 /// The color roles (the source of truth).
714 pub scheme: ColorScheme,
715 /// The application's background (= `scheme.background`).
716 pub background: Color,
717 /// The surfaces' background (= `scheme.surface`).
718 pub surface: Color,
719 /// The accent color (= `scheme.primary`).
720 pub primary: Color,
721 /// Text and content on `primary` (= `scheme.on_primary`).
722 pub on_primary: Color,
723 /// The default text on surfaces (= `scheme.on_surface`).
724 pub on_surface: Color,
725 /// Secondary text and discreet elements (= `scheme.on_surface_variant`).
726 pub muted: Color,
727 /// Borders at rest (= `scheme.outline`).
728 pub border: Color,
729 /// The focus accent (a frus interaction accent, outside the scheme).
730 pub focus: Color,
731 /// The text selection highlight (likewise).
732 pub selection: Color,
733 /// The tonal accent container (= `scheme.primary_container`).
734 pub primary_container: Color,
735 /// Content on `primary_container` (= `scheme.on_primary_container`).
736 pub on_primary_container: Color,
737 /// The error or danger color (= `scheme.error`).
738 pub error: Color,
739 /// Content on `error` (= `scheme.on_error`).
740 pub on_error: Color,
741 /// The discreet outline variant (= `scheme.outline_variant`).
742 pub outline_variant: Color,
743 /// The named typographic scale (Material's 15 steps).
744 pub text: TextTheme,
745 /// The default corner radius.
746 pub radius: f32,
747 /// The base spacing unit.
748 pub spacing: f32,
749 /// The ambient **reading and layout direction** (LTR by default). In RTL, the
750 /// driver mirrors the layout horizontally. Carried here (an ambient context
751 /// threaded down to paint) pending a dedicated `Env` (§2).
752 pub direction: TextDirection,
753 /// **Per-widget defaults**: what a widget looks like when the caller has not said,
754 /// resolved as `caller ?? theme ?? framework`. Empty by default — a theme that sets
755 /// nothing behaves exactly as if there were none. See
756 /// [`WidgetThemes`](crate::WidgetThemes).
757 pub widgets: crate::widgettheme::WidgetThemes,
758 /// **How much room the small controls reserve for a finger** ([`TapTarget`]).
759 /// [`Padded`](TapTarget::Padded) by default, as it is over there: a switch, a
760 /// checkbox, a radio and an icon button each lay out inside at least
761 /// [`MIN_TAP_TARGET`] and paint what they paint in the middle of it.
762 pub tap_target: TapTarget,
763}
764
765impl Theme {
766 /// Builds a theme from a scheme: the flat fields are **derived** from the roles,
767 /// so there is a single source of truth.
768 pub fn from_scheme(scheme: ColorScheme, focus: Color, selection: Color) -> Self {
769 Self {
770 scheme,
771 background: scheme.background,
772 surface: scheme.surface,
773 primary: scheme.primary,
774 on_primary: scheme.on_primary,
775 on_surface: scheme.on_surface,
776 muted: scheme.on_surface_variant,
777 border: scheme.outline,
778 focus,
779 selection,
780 primary_container: scheme.primary_container,
781 on_primary_container: scheme.on_primary_container,
782 error: scheme.error,
783 on_error: scheme.on_error,
784 outline_variant: scheme.outline_variant,
785 text: TextTheme::default(),
786 radius: 10.0,
787 spacing: 8.0,
788 direction: TextDirection::Ltr,
789 widgets: crate::widgettheme::WidgetThemes::default(),
790 tap_target: TapTarget::default(),
791 }
792 }
793
794 /// The same theme in **right-to-left** (Arabic, Hebrew…).
795 pub fn rtl(mut self) -> Self {
796 self.direction = TextDirection::Rtl;
797 self
798 }
799
800 /// The dark theme.
801 pub fn dark() -> Self {
802 Self::from_scheme(
803 ColorScheme::dark(),
804 Color::rgb8(90, 158, 242),
805 Color::rgba(0.35, 0.62, 0.95, 0.40),
806 )
807 }
808
809 /// The light theme.
810 pub fn light() -> Self {
811 Self::from_scheme(
812 ColorScheme::light(),
813 Color::rgb8(40, 120, 220),
814 Color::rgba(0.20, 0.50, 0.90, 0.30),
815 )
816 }
817
818 /// A theme generated from a **seed color** (see [`ColorScheme::from_seed`]). The
819 /// focus ring and the selection derive from the scheme's primary (interaction
820 /// roles specific to frus, outside the M3 scheme).
821 pub fn from_seed(seed: Color, dark: bool) -> Self {
822 let scheme = ColorScheme::from_seed(seed, dark);
823 let focus = scheme.primary;
824 let selection = scheme.primary.with_alpha(if dark { 0.40 } else { 0.30 });
825 Self::from_scheme(scheme, focus, selection)
826 }
827
828 /// **Whether this theme is a light one or a dark one** (`ThemeData.brightness`).
829 ///
830 /// The scheme is the source of truth for the colours, so it is the source of truth
831 /// for this too — a theme cannot be dark and hold a light scheme.
832 ///
833 /// Widgets that have two sets of numbers, one per brightness, ask this. They used to
834 /// compare the surface's luminance against a half, which agrees with it nearly always
835 /// and is a guess either way.
836 pub fn brightness(&self) -> crate::media::Brightness {
837 self.scheme.brightness
838 }
839
840 /// Applies the Material **state layer** over `base`: it overlays the content
841 /// color `on` at low opacity according to the interaction state — hover 8%,
842 /// focus 10%, press 12% — taking the animated progressions into account
843 /// (`hover_progress`/`focus_progress`/`press_progress`). This is the state rule
844 /// **baked** into the theme: widgets stay declarative (they pass their base color and
845 /// their content color, and the theme decides on the overlay).
846 ///
847 /// All three terms are **progressions**, the press included. It read the flag until
848 /// milestone 441, which meant that term could only ever be 0 or 12%: the layer
849 /// arrived whole under a finger and vanished whole when it left. Reading
850 /// `press_progress` is what lets it fade, and reading the flag *as well* would defeat
851 /// that — the term would reach full on the first frame and the fade would never run.
852 pub fn state_layer(&self, base: Color, on: Color, status: &Status) -> Color {
853 let overlay = 0.08 * status.hover_progress.clamp(0.0, 1.0)
854 + 0.10 * status.focus_progress.clamp(0.0, 1.0)
855 + 0.12 * status.press_progress.clamp(0.0, 1.0);
856 base.lerp(on, overlay.min(1.0))
857 }
858}
859
860impl Theme {
861 /// Interpolates toward `other` at progress `t` (`0` = `self`, `1` = `other`).
862 /// Used for the theme fade when switching light and dark. The **scheme** is
863 /// interpolated role by role and the flat fields are re-derived from it (a
864 /// single source of truth, even mid-fade).
865 pub fn lerp(&self, other: &Theme, t: f32) -> Theme {
866 let t = t.clamp(0.0, 1.0);
867 let f = |a: f32, b: f32| a + (b - a) * t;
868 let mut out = Theme::from_scheme(
869 self.scheme.lerp(&other.scheme, t),
870 self.focus.lerp(other.focus, t),
871 self.selection.lerp(other.selection, t),
872 );
873 // Typography takes no part in the fade (it is identical light and dark).
874 out.text = self.text;
875 out.radius = f(self.radius, other.radius);
876 out.spacing = f(self.spacing, other.spacing);
877 // Direction is discrete: keep the fade target's.
878 out.direction = other.direction;
879 // So is the tap target — and so are the per-widget defaults, which the fade used
880 // to **drop**: `from_scheme` starts from an empty set and nothing put them back,
881 // so every override an application had written disappeared for the length of a
882 // light/dark crossing and came back when it ended.
883 out.tap_target = other.tap_target;
884 out.widgets = other.widgets.clone();
885 out
886 }
887}
888
889impl Default for Theme {
890 fn default() -> Self {
891 Self::dark()
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 /// **A theme is eight kilobytes, and it used to be `Copy`.** Every `*theme` in the
898 /// crate was a silent eight-kilobyte memcpy — in the layout walk, in every themed
899 /// subtree, once per overlay. Milestone 448 dropped `Copy`, which turned nine of
900 /// those into `clone()` calls that say what they cost.
901 ///
902 /// The number itself is not a promise; the assertion is that it is **large**, since
903 /// that is the whole argument for the change.
904 #[test]
905 fn a_theme_is_far_too_big_to_copy_by_accident() {
906 assert!(
907 std::mem::size_of::<super::Theme>() > 4096,
908 "a theme is {} bytes",
909 std::mem::size_of::<super::Theme>()
910 );
911 }
912
913 use super::*;
914 use crate::disabled::DISABLED_CONTAINER_OPACITY;
915
916 /// **A scheme says what it is instead of being measured.**
917 ///
918 /// The reference carries `brightness` on both `ColorScheme` and `ThemeData`, and its
919 /// scrollbar reads it (`scrollbar.dart:232`). This framework had neither, so the one
920 /// widget that needed the answer compared the surface's luminance against a half.
921 ///
922 /// That agrees on the four schemes this crate builds — which is exactly why it
923 /// survived. It does not agree on a scheme an application writes for itself.
924 #[test]
925 fn a_scheme_says_whether_it_is_dark_rather_than_being_measured() {
926 assert!(ColorScheme::dark().brightness.is_dark());
927 assert!(ColorScheme::light().brightness.is_light());
928 let seed = Color::rgb8(0x42, 0x85, 0xF4);
929 assert!(ColorScheme::from_seed(seed, true).brightness.is_dark());
930 assert!(ColorScheme::from_seed(seed, false).brightness.is_light());
931 assert_eq!(Theme::dark().brightness(), Brightness::Dark);
932 assert_eq!(Theme::light().brightness(), Brightness::Light);
933
934 // A dimmed **light** scheme — a reading theme, a low-light one — whose surface
935 // sits below the halfway line. The guess called this dark; it is not.
936 let dim = ColorScheme {
937 surface: Color::rgb8(110, 110, 110),
938 ..ColorScheme::light()
939 };
940 assert!(
941 dim.surface.compute_luminance() < 0.5,
942 "the measurement says dark"
943 );
944 assert!(dim.brightness.is_light(), "and the scheme says light");
945 }
946
947 /// **A brightness does not interpolate.** Halfway between light and dark is not a
948 /// third brightness, and a widget reading one mid-crossing wants the answer it will
949 /// still be right about when the crossing ends — the same rule as the text
950 /// direction, and for the same reason.
951 #[test]
952 fn a_brightness_does_not_interpolate() {
953 let half = Theme::light().lerp(&Theme::dark(), 0.5);
954 assert_eq!(half.brightness(), Brightness::Dark);
955 let back = Theme::dark().lerp(&Theme::light(), 0.01);
956 assert_eq!(back.brightness(), Brightness::Light);
957 }
958
959 /// The WCAG contrast between two colors (a ratio ≥ 1).
960 fn contrast(a: Color, b: Color) -> f32 {
961 let (la, lb) = (a.compute_luminance() + 0.05, b.compute_luminance() + 0.05);
962 if la > lb {
963 la / lb
964 } else {
965 lb / la
966 }
967 }
968
969 #[test]
970 fn from_seed_generates_contrasting_pairs() {
971 // Every X / on_X pair must stay legible (≥ 4.5:1, the AA requirement), for
972 // any seed at all — even a barely chromatic one.
973 for seed in [
974 Color::rgb8(0x42, 0x85, 0xF4), // Google blue
975 Color::rgb8(0x9C, 0x27, 0xB0), // violet
976 Color::rgb8(0x80, 0x80, 0x80), // grey — very nearly no chroma at all
977 ] {
978 for dark in [false, true] {
979 let s = ColorScheme::from_seed(seed, dark);
980 for (name, base, on) in [
981 ("primary", s.primary, s.on_primary),
982 ("secondary", s.secondary, s.on_secondary),
983 ("surface", s.surface, s.on_surface),
984 ("tertiary", s.tertiary, s.on_tertiary),
985 ("error", s.error, s.on_error),
986 ("inverse", s.inverse_surface, s.on_inverse_surface),
987 // The containers carry text too — an errored field's helper line is
988 // `on_error_container` on `error_container`, and it has to be read.
989 (
990 "primary_container",
991 s.primary_container,
992 s.on_primary_container,
993 ),
994 (
995 "secondary_container",
996 s.secondary_container,
997 s.on_secondary_container,
998 ),
999 (
1000 "tertiary_container",
1001 s.tertiary_container,
1002 s.on_tertiary_container,
1003 ),
1004 ("error_container", s.error_container, s.on_error_container),
1005 // The fixed pair carries text as well, and it carries the **same**
1006 // text over two surfaces: whatever is written on `primary_fixed`
1007 // has to still be legible when a caller reaches for the dim one.
1008 ("primary_fixed", s.primary_fixed, s.on_primary_fixed),
1009 ("primary_fixed_dim", s.primary_fixed_dim, s.on_primary_fixed),
1010 ("secondary_fixed", s.secondary_fixed, s.on_secondary_fixed),
1011 (
1012 "secondary_fixed_dim",
1013 s.secondary_fixed_dim,
1014 s.on_secondary_fixed,
1015 ),
1016 ("tertiary_fixed", s.tertiary_fixed, s.on_tertiary_fixed),
1017 (
1018 "tertiary_fixed_dim",
1019 s.tertiary_fixed_dim,
1020 s.on_tertiary_fixed,
1021 ),
1022 ] {
1023 let ratio = contrast(base, on);
1024 assert!(
1025 ratio >= 4.5,
1026 "{name} does not contrast enough ({ratio:.2}) — seed {seed:?}, \
1027 dark={dark}"
1028 );
1029 }
1030 }
1031 }
1032 }
1033
1034 /// The twelve roles that promise to stay put, as `(name, light, dark)`.
1035 fn fixed_roles(light: &ColorScheme, dark: &ColorScheme) -> Vec<(&'static str, Color, Color)> {
1036 vec![
1037 ("primary_fixed", light.primary_fixed, dark.primary_fixed),
1038 (
1039 "primary_fixed_dim",
1040 light.primary_fixed_dim,
1041 dark.primary_fixed_dim,
1042 ),
1043 (
1044 "on_primary_fixed",
1045 light.on_primary_fixed,
1046 dark.on_primary_fixed,
1047 ),
1048 (
1049 "on_primary_fixed_variant",
1050 light.on_primary_fixed_variant,
1051 dark.on_primary_fixed_variant,
1052 ),
1053 (
1054 "secondary_fixed",
1055 light.secondary_fixed,
1056 dark.secondary_fixed,
1057 ),
1058 (
1059 "secondary_fixed_dim",
1060 light.secondary_fixed_dim,
1061 dark.secondary_fixed_dim,
1062 ),
1063 (
1064 "on_secondary_fixed",
1065 light.on_secondary_fixed,
1066 dark.on_secondary_fixed,
1067 ),
1068 (
1069 "on_secondary_fixed_variant",
1070 light.on_secondary_fixed_variant,
1071 dark.on_secondary_fixed_variant,
1072 ),
1073 ("tertiary_fixed", light.tertiary_fixed, dark.tertiary_fixed),
1074 (
1075 "tertiary_fixed_dim",
1076 light.tertiary_fixed_dim,
1077 dark.tertiary_fixed_dim,
1078 ),
1079 (
1080 "on_tertiary_fixed",
1081 light.on_tertiary_fixed,
1082 dark.on_tertiary_fixed,
1083 ),
1084 (
1085 "on_tertiary_fixed_variant",
1086 light.on_tertiary_fixed_variant,
1087 dark.on_tertiary_fixed_variant,
1088 ),
1089 ]
1090 }
1091
1092 /// **A fixed role does not move when the lights go out** — the one promise the
1093 /// twelve of them make (`color_scheme.dart:90`).
1094 ///
1095 /// Every other accent role is answered twice, once per brightness. These are
1096 /// answered once. A brand colour on a card, an onboarding illustration drawn against
1097 /// one particular green, a header somebody signed off in a design review: they have
1098 /// nowhere to sit in a scheme where every role moves. `primary_container` is the
1099 /// right emphasis for them and the wrong promise.
1100 ///
1101 /// This holds it for **both** ways a scheme is built — the pair written out by
1102 /// hand, where the promise is two literals that have to agree, and the pair a seed
1103 /// generates, where it holds by construction because the tone is read off a palette
1104 /// that does not know which theme is being built.
1105 #[test]
1106 fn a_fixed_role_does_not_move_when_the_lights_go_out() {
1107 for (name, light, dark) in fixed_roles(&ColorScheme::light(), &ColorScheme::dark()) {
1108 assert_eq!(light, dark, "the written schemes disagree about {name}");
1109 }
1110
1111 for seed in [
1112 Color::rgb8(0x42, 0x85, 0xF4),
1113 Color::rgb8(0x9C, 0x27, 0xB0),
1114 Color::rgb8(0x80, 0x80, 0x80),
1115 ] {
1116 let l = ColorScheme::from_seed(seed, false);
1117 let d = ColorScheme::from_seed(seed, true);
1118 for (name, light, dark) in fixed_roles(&l, &d) {
1119 assert_eq!(light, dark, "seed {seed:?} disagrees about {name}");
1120 }
1121 }
1122 }
1123
1124 /// **The dim one is the stronger one**, and what is written on either is legible on
1125 /// **both** — which is the reason `on_*_fixed` is a single role and not two.
1126 ///
1127 /// A caller that paints `primary_fixed` today and swaps to `primary_fixed_dim` for
1128 /// emphasis tomorrow does not get to re-pick its text colour, so tone 10 has to
1129 /// clear the bar against the darker of the pair as well.
1130 #[test]
1131 fn the_dim_half_is_the_stronger_half() {
1132 for scheme in [
1133 ColorScheme::light(),
1134 ColorScheme::dark(),
1135 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), false),
1136 ColorScheme::from_seed(Color::rgb8(0x9C, 0x27, 0xB0), true),
1137 ] {
1138 for (name, fixed, dim, on, variant) in [
1139 (
1140 "primary",
1141 scheme.primary_fixed,
1142 scheme.primary_fixed_dim,
1143 scheme.on_primary_fixed,
1144 scheme.on_primary_fixed_variant,
1145 ),
1146 (
1147 "secondary",
1148 scheme.secondary_fixed,
1149 scheme.secondary_fixed_dim,
1150 scheme.on_secondary_fixed,
1151 scheme.on_secondary_fixed_variant,
1152 ),
1153 (
1154 "tertiary",
1155 scheme.tertiary_fixed,
1156 scheme.tertiary_fixed_dim,
1157 scheme.on_tertiary_fixed,
1158 scheme.on_tertiary_fixed_variant,
1159 ),
1160 ] {
1161 assert!(
1162 dim.compute_luminance() < fixed.compute_luminance(),
1163 "{name}_fixed_dim is not the stronger of the pair"
1164 );
1165 for (surface, label) in [(fixed, "fixed"), (dim, "fixed_dim")] {
1166 let ratio = contrast(surface, on);
1167 assert!(
1168 ratio >= 4.5,
1169 "on_{name}_fixed does not read on {name}_{label} ({ratio:.2})"
1170 );
1171 }
1172 // The variant is the quieter of the two, so it is allowed less — but
1173 // it is still text, and 3:1 is the floor for large text.
1174 assert!(
1175 contrast(fixed, variant) >= 3.0,
1176 "on_{name}_fixed_variant does not read on {name}_fixed"
1177 );
1178 assert!(
1179 variant.compute_luminance() > on.compute_luminance(),
1180 "on_{name}_fixed_variant is not the quieter of the two"
1181 );
1182 }
1183 }
1184 }
1185
1186 #[test]
1187 fn from_seed_light_and_dark_share_the_hue() {
1188 // Both modes decline the same hue (the dark primary is the tone-80 version
1189 // of the tone-40 light primary).
1190 let seed = Color::rgb8(0x42, 0x85, 0xF4);
1191 let light = ColorScheme::from_seed(seed, false);
1192 let dark = ColorScheme::from_seed(seed, true);
1193 let hue = |c: Color| frus_core::Hct::from_color(c).hue;
1194 let delta = (hue(light.primary) - hue(dark.primary)).abs();
1195 let delta = delta.min(360.0 - delta);
1196 assert!(
1197 delta < 12.0,
1198 "teintes clair/sombre divergentes ({delta:.1}°)"
1199 );
1200 // And the dark one is indeed… dark.
1201 assert!(dark.background.compute_luminance() < 0.1);
1202 assert!(light.background.compute_luminance() > 0.85);
1203 }
1204
1205 #[test]
1206 fn an_outline_is_never_the_colour_of_a_disabled_one() {
1207 // Since milestone 322 a disabled control's outline is `on_surface` at 12 % over
1208 // the surface. If a scheme puts a *live* outline at the same tone, then every
1209 // outlined control in the framework looks available whether it is or not — which
1210 // is what a device found on the dark palette. This is the guard against it.
1211 //
1212 // The bar is the reference's own margin at its narrowest: its baseline schemes
1213 // separate `outline_variant` from a disabled outline by about ten tones, and
1214 // `outline` by nearly forty.
1215 let tone = |c: Color| frus_core::Hct::from_color(c).tone;
1216 let mut checked = 0;
1217 for (name, s) in [
1218 ("dark", ColorScheme::dark()),
1219 ("light", ColorScheme::light()),
1220 (
1221 "seeded dark",
1222 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), true),
1223 ),
1224 (
1225 "seeded light",
1226 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), false),
1227 ),
1228 (
1229 "grey seeded dark",
1230 ColorScheme::from_seed(Color::rgb8(0x80, 0x80, 0x80), true),
1231 ),
1232 ] {
1233 // Opaque over opaque: the lerp is the composite `fade(0.12)` resolves to.
1234 let disabled = tone(s.surface.lerp(s.on_surface, DISABLED_CONTAINER_OPACITY));
1235 for (role, colour, floor) in [
1236 ("outline", s.outline, 24.0),
1237 ("outline_variant", s.outline_variant, 6.0),
1238 ] {
1239 let gap = (tone(colour) - disabled).abs();
1240 assert!(
1241 gap >= floor,
1242 "{name}: {role} is {gap:.1} tones from a disabled outline \
1243 (needs {floor}) — a live control and an unavailable one \
1244 would be drawn the same"
1245 );
1246 checked += 1;
1247 }
1248 }
1249 assert_eq!(checked, 10);
1250 }
1251
1252 /// The same question for a control that is **filled** rather than outlined: it must
1253 /// be tellable from the disabled fill, or the filled half of the disabled rule
1254 /// inverts. A slider's rail was fainter than its own disabled twin in both schemes,
1255 /// which is what sent us looking.
1256 ///
1257 /// Two ways to be tellable, and a fill needs one of them. **Tone**, measured as a
1258 /// distance from the surface rather than from each other — what the eye reads is how
1259 /// far a fill sits from what it lies on, and the disabled fill is by construction a
1260 /// fixed step from it. Or **chroma**: the disabled fill is `on_surface` at 12 %, so
1261 /// it is nearly grey, and a fill carrying real colour is told apart from it at any
1262 /// tone. `primary_container` in the dark scheme is tone 24 against a disabled tone 24
1263 /// and nobody would confuse them, because one is green.
1264 ///
1265 /// What the rail had was neither: a near-neutral of the surface's own hue, sitting
1266 /// *closer* to the surface than the disabled fill.
1267 ///
1268 /// This guard could not be written before milestone 329. It models the disabled fill
1269 /// as an sRGB blend, which was a fiction while the GPU blended the token in linear
1270 /// light — 14 tones adrift in the dark scheme. Now [`crate::disabled::over_surface`]
1271 /// resolves exactly this arithmetic, so the model is the painted truth.
1272 #[test]
1273 fn a_live_container_is_never_quieter_than_a_disabled_fill() {
1274 let tone = |c: Color| frus_core::Hct::from_color(c).tone;
1275 let chroma = |c: Color| frus_core::Hct::from_color(c).chroma;
1276 /// A fill this much more colourful than the disabled one is told apart by its
1277 /// colour whatever its tone. Set where a *saturated* container sits — the two
1278 /// `primary_container`s clear it by a third — and well above the rail that
1279 /// failed, which was only 8 more colourful and would have reached for this
1280 /// escape without deserving it.
1281 const CHROMA_MARGIN: f64 = 15.0;
1282 let mut checked = 0;
1283 for (name, s) in [
1284 ("dark", ColorScheme::dark()),
1285 ("light", ColorScheme::light()),
1286 (
1287 "seeded dark",
1288 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), true),
1289 ),
1290 (
1291 "seeded light",
1292 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), false),
1293 ),
1294 (
1295 "grey seeded dark",
1296 ColorScheme::from_seed(Color::rgb8(0x80, 0x80, 0x80), true),
1297 ),
1298 ] {
1299 let surface = tone(s.surface);
1300 let fill = s.surface.lerp(s.on_surface, DISABLED_CONTAINER_OPACITY);
1301 let dead = (tone(fill) - surface).abs();
1302 // The roles a live control is filled with. `secondary_container` carries a
1303 // slider's rail, a selected chip, a selected segment and a tonal button;
1304 // `primary_container` the louder selections.
1305 for (role, colour) in [
1306 ("secondary_container", s.secondary_container),
1307 ("primary_container", s.primary_container),
1308 ] {
1309 let live = (tone(colour) - surface).abs();
1310 let colourful = chroma(colour) - chroma(fill) >= CHROMA_MARGIN;
1311 assert!(
1312 live >= dead || colourful,
1313 "{name}: {role} is {live:.1} tones off the surface where a disabled \
1314 fill is {dead:.1}, and only {:.1} more colourful — a live control \
1315 would read as an unavailable one",
1316 chroma(colour) - chroma(fill)
1317 );
1318 checked += 1;
1319 }
1320 }
1321 assert_eq!(checked, 10);
1322 }
1323
1324 /// The five container rungs are a **ladder**: each stands further from the
1325 /// surface than the one below it, in whichever direction the scheme's own
1326 /// brightness sends them. A rung out of order — or two on the same tone — is two
1327 /// widgets that cannot be told apart while their roles say they should be.
1328 #[test]
1329 fn the_container_ladder_climbs_in_one_direction() {
1330 let tone = |c: Color| frus_core::Hct::from_color(c).tone;
1331 /// Two rungs closer than this read as the same colour.
1332 const RUNG: f64 = 1.0;
1333 let mut checked = 0;
1334 for (name, s) in [
1335 ("dark", ColorScheme::dark()),
1336 ("light", ColorScheme::light()),
1337 (
1338 "seeded dark",
1339 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), true),
1340 ),
1341 (
1342 "seeded light",
1343 ColorScheme::from_seed(Color::rgb8(0x42, 0x85, 0xF4), false),
1344 ),
1345 ] {
1346 // A dark scheme's containers grow lighter as they take emphasis, a light
1347 // scheme's darker.
1348 let up = if tone(s.surface) < 50.0 { 1.0 } else { -1.0 };
1349 let rungs = [
1350 ("lowest", s.surface_container_lowest),
1351 ("low", s.surface_container_low),
1352 ("container", s.surface_container),
1353 ("high", s.surface_container_high),
1354 ("highest", s.surface_container_highest),
1355 ];
1356 // `surface_dim` and `surface_bright` bracket the **surface**, in either
1357 // theme — "always the darkest" and "always the lightest"
1358 // (`color_scheme.dart:1236`, `:1241`).
1359 //
1360 // They do not bracket the *containers*, and the first draft of this test
1361 // asserted that they did. The reference's own dark scheme puts
1362 // `surfaceContainerLowest` at tone 4 and `surfaceDim` at 6, so the ladder's
1363 // bottom rung is darker than the darkest surface: the two are separate
1364 // families, and dim/bright are a claim about the surface alone.
1365 assert!(
1366 tone(s.surface_dim) <= tone(s.surface) + 1e-6,
1367 "surface_dim is not the darker end"
1368 );
1369 assert!(
1370 tone(s.surface) <= tone(s.surface_bright) + 1e-6,
1371 "surface_bright is not the lighter one"
1372 );
1373 checked += 2;
1374 for pair in rungs.windows(2) {
1375 let ((below, b), (above, a)) = (pair[0], pair[1]);
1376 let step = (tone(a) - tone(b)) * up;
1377 assert!(
1378 step >= RUNG,
1379 "{name}: the {above} rung is {step:.1} tones of emphasis above the \
1380 {below} one — a ladder cannot go back down"
1381 );
1382 checked += 1;
1383 }
1384 }
1385 assert_eq!(checked, 24);
1386 }
1387
1388 #[test]
1389 fn dark_and_light_differ() {
1390 assert_ne!(Theme::dark().background, Theme::light().background);
1391 assert_ne!(Theme::dark().on_surface, Theme::light().on_surface);
1392 }
1393
1394 #[test]
1395 fn flat_fields_mirror_the_scheme() {
1396 // The flat fields are views derived from the scheme — including mid-fade
1397 // (the lerp goes through the scheme).
1398 for theme in [
1399 Theme::dark(),
1400 Theme::light(),
1401 Theme::dark().lerp(&Theme::light(), 0.37),
1402 ] {
1403 assert_eq!(theme.background, theme.scheme.background);
1404 assert_eq!(theme.surface, theme.scheme.surface);
1405 assert_eq!(theme.primary, theme.scheme.primary);
1406 assert_eq!(theme.on_primary, theme.scheme.on_primary);
1407 assert_eq!(theme.on_surface, theme.scheme.on_surface);
1408 assert_eq!(theme.muted, theme.scheme.on_surface_variant);
1409 assert_eq!(theme.border, theme.scheme.outline);
1410 assert_eq!(theme.primary_container, theme.scheme.primary_container);
1411 assert_eq!(theme.error, theme.scheme.error);
1412 assert_eq!(theme.outline_variant, theme.scheme.outline_variant);
1413 }
1414 }
1415
1416 #[test]
1417 fn state_layer_darkens_toward_content_on_interaction() {
1418 let theme = Theme::dark();
1419 let base = Color::rgb(0.4, 0.4, 0.4);
1420 let on = Color::BLACK;
1421
1422 // At rest: no overlay.
1423 let idle = Status::default();
1424 assert_eq!(theme.state_layer(base, on, &idle), base);
1425
1426 // Fully hovered: the base is pulled 8% toward `on` (darker here).
1427 let hovered = Status {
1428 hover_progress: 1.0,
1429 ..Default::default()
1430 };
1431 let h = theme.state_layer(base, on, &hovered);
1432 assert!(h.r < base.r && (base.r - h.r - 0.4 * 0.08).abs() < 1e-4);
1433
1434 // Pressed: a stronger overlay than hover alone.
1435 let pressed = Status {
1436 press_progress: 1.0,
1437 ..Default::default()
1438 };
1439 assert!(theme.state_layer(base, on, &pressed).r < h.r);
1440 }
1441
1442 /// **A press lights by degrees** (milestone 441). The term read
1443 /// `Interaction::Pressed`, a flag, so it could only ever be 0 or 12 %: the layer
1444 /// arrived whole under a finger and left whole with it.
1445 #[test]
1446 fn a_press_lights_by_degrees() {
1447 let theme = Theme::dark();
1448 let base = Color::rgb(0.4, 0.4, 0.4);
1449 let on = Color::BLACK;
1450 let at = |p: f32| {
1451 theme.state_layer(
1452 base,
1453 on,
1454 &Status {
1455 press_progress: p,
1456 ..Default::default()
1457 },
1458 )
1459 };
1460 let (rest, half, full) = (at(0.0), at(0.5), at(1.0));
1461 assert_eq!(rest, base, "nothing at rest");
1462 assert!(full.r < half.r && half.r < rest.r, "half way is half way");
1463 assert!(
1464 (half.r - (rest.r + full.r) * 0.5).abs() < 1e-4,
1465 "and it is the overlay that is halved, not the colour"
1466 );
1467
1468 // And the flag alone no longer lights it. If it were read as well the term would
1469 // reach full on the very first frame, and the fade above could never run.
1470 let flagged = Status {
1471 interaction: crate::interaction::Interaction::Pressed,
1472 ..Default::default()
1473 };
1474 assert_eq!(theme.state_layer(base, on, &flagged), base);
1475 }
1476
1477 /// **A tap target is a theme-wide answer with a per-widget override** (milestone
1478 /// 442), and the default is the reference's: at least 48 pixels for anything a
1479 /// finger works.
1480 #[test]
1481 fn a_theme_reserves_a_tap_target_by_default() {
1482 assert_eq!(Theme::dark().tap_target, TapTarget::Padded);
1483 assert_eq!(Theme::light().tap_target, TapTarget::Padded);
1484 // Read through the enum, which is what a widget asking would get.
1485 let sides: Vec<f32> = [TapTarget::Padded, TapTarget::ShrinkWrap]
1486 .iter()
1487 .map(|t| t.min_side())
1488 .collect();
1489 assert_eq!(sides, vec![MIN_TAP_TARGET, SHRUNK_TAP_TARGET]);
1490 assert!(
1491 sides[1] < sides[0],
1492 "shrink-wrapping is a smaller answer, not a different one"
1493 );
1494 }
1495
1496 /// **A fade used to drop every per-widget default it crossed** (milestone 442).
1497 ///
1498 /// `lerp` rebuilds the theme from the interpolated scheme, and `from_scheme` starts
1499 /// from an empty set of widget defaults — so every override an application had
1500 /// written disappeared for the length of a light/dark crossing and came back when it
1501 /// ended. Discrete, like the direction beside it: a corner radius is not a colour and
1502 /// has no half-way.
1503 #[test]
1504 fn a_fade_keeps_what_it_cannot_interpolate() {
1505 let mut target = Theme::light();
1506 target.widgets.checkbox.radius = Some(3.0);
1507 target.tap_target = TapTarget::ShrinkWrap;
1508
1509 let mid = Theme::dark().lerp(&target, 0.5);
1510 assert_eq!(
1511 mid.widgets.checkbox.radius,
1512 Some(3.0),
1513 "the defaults survive"
1514 );
1515 assert_eq!(mid.tap_target, TapTarget::ShrinkWrap);
1516 // And the colours are still half way across, which is what the fade is for.
1517 assert_ne!(mid.background, Theme::dark().background);
1518 assert_ne!(mid.background, target.background);
1519 }
1520
1521 #[test]
1522 fn lerp_hits_endpoints() {
1523 let d = Theme::dark();
1524 let l = Theme::light();
1525 assert_eq!(d.lerp(&l, 0.0).background, d.background);
1526 assert_eq!(d.lerp(&l, 1.0).background, l.background);
1527 // In the middle: neither one nor the other.
1528 let mid = d.lerp(&l, 0.5).background;
1529 assert_ne!(mid, d.background);
1530 assert_ne!(mid, l.background);
1531 }
1532}