Skip to main content

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