Skip to main content

compose_rust/
tokens.rs

1//! Design token tables.
2//!
3//! The tables are authored here, in Rust, because Rust is the single source for everything
4//! that has two representations, and because values written in Kotlin by hand would go
5//! unverified. They are *executed* in the Renderer: codegen writes them into
6//! `Protocol.gen.kt` as Kotlin objects, so they enter the Renderer binary at build time and
7//! never cross the boundary at run time. The Host still sends roles and never a token
8//! table.
9//!
10//! What the Renderer implementer still owns is the rules these values feed: how an
11//! elevation is drawn, how each `ButtonVariant` is styled, and motion.
12
13use crate::schema::{Color, ColorRole, ColorScheme, DesignSystem, ShapeRole, SpaceRole, TypeRole};
14
15/// One `ColorRole` slot in both schemes, in `ColorRole` tag order.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct ColorToken {
18    pub role: ColorRole,
19    pub light: Color,
20    pub dark: Color,
21}
22
23/// One rung of the nine-rung type ladder (13.2).
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct TypeToken {
26    pub role: TypeRole,
27    /// Font size in sp.
28    pub size: f32,
29    /// 100 to 900.
30    pub weight: u16,
31    /// Line height in sp.
32    pub line_height: f32,
33    /// Letter spacing in sp, which may be negative.
34    pub letter_spacing: f32,
35    /// True when the rung uses the monospace family rather than the default one.
36    pub monospace: bool,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct ShapeToken {
41    pub role: ShapeRole,
42    /// Corner radius in dp. `Full` uses a radius large enough to read as a pill.
43    pub radius: f32,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct SpaceToken {
48    pub role: SpaceRole,
49    /// Spacing in dp.
50    pub value: f32,
51}
52
53/// Items 1 to 4 of the 14.6 table for one design system.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct DesignTokenTable {
56    pub system: DesignSystem,
57    /// The published guideline the values come from, recorded as 14.3 asks.
58    pub reference: &'static str,
59    pub default_family: &'static str,
60    pub monospace_family: &'static str,
61    pub colors: &'static [ColorToken; 23],
62    pub type_scale: &'static [TypeToken; 9],
63    pub shapes: &'static [ShapeToken; 6],
64    pub spaces: &'static [SpaceToken; 7],
65}
66
67impl DesignTokenTable {
68    pub fn color(&self, role: ColorRole, scheme: ColorScheme) -> Color {
69        let token = self.colors[role as usize - 1];
70        // `FollowSystem` is resolved by the Renderer, which is the side that knows.
71        // Asking the Host for it can only mean light.
72        if scheme == ColorScheme::Dark {
73            token.dark
74        } else {
75            token.light
76        }
77    }
78}
79
80/// Every design system's table, in `DesignSystem` tag order.
81///
82/// A fourth design system is one entry here plus one Renderer rule implementation.
83/// Nothing in `widgets.rs`, the Modifier schema or the Property schema moves (14.1).
84pub const DESIGN_TOKENS: &[DesignTokenTable] = &[
85    MATERIAL3,
86    APPLE_HIG,
87    FLUENT,
88    ADWAITA,
89    BREEZE,
90    DEEPIN,
91    LIQUID_GLASS,
92];
93
94pub fn table(system: DesignSystem) -> &'static DesignTokenTable {
95    &DESIGN_TOKENS[system as usize - 1]
96}
97
98macro_rules! colors {
99    ($($role:ident: $light:literal / $dark:literal),+ $(,)?) => {
100        &[$(ColorToken {
101            role: ColorRole::$role,
102            light: Color::rgb($light),
103            dark: Color::rgb($dark),
104        }),+]
105    };
106}
107
108macro_rules! type_scale {
109    ($($role:ident: $size:literal / $weight:literal / $line:literal / $spacing:literal / $mono:literal),+ $(,)?) => {
110        &[$(TypeToken {
111            role: TypeRole::$role,
112            size: $size,
113            weight: $weight,
114            line_height: $line,
115            letter_spacing: $spacing,
116            monospace: $mono,
117        }),+]
118    };
119}
120
121macro_rules! shapes {
122    ($($role:ident: $radius:literal),+ $(,)?) => {
123        &[$(ShapeToken { role: ShapeRole::$role, radius: $radius }),+]
124    };
125}
126
127macro_rules! spaces {
128    ($($role:ident: $value:literal),+ $(,)?) => {
129        &[$(SpaceToken { role: SpaceRole::$role, value: $value }),+]
130    };
131}
132
133const MATERIAL3: DesignTokenTable = DesignTokenTable {
134    system: DesignSystem::Material3,
135    reference: "Material 3 Expressive shape, colour and type scales, m3.material.io, 2025",
136    default_family: "Roboto",
137    monospace_family: "Roboto Mono",
138    colors: colors! {
139        Primary: 0x6750a4 / 0xd0bcff,
140        OnPrimary: 0xffffff / 0x381e72,
141        Secondary: 0x625b71 / 0xccc2dc,
142        OnSecondary: 0xffffff / 0x332d41,
143        Surface: 0xfef7ff / 0x141218,
144        OnSurface: 0x1d1b20 / 0xe6e0e9,
145        SurfaceVariant: 0xe7e0ec / 0x49454f,
146        OnSurfaceVariant: 0x49454f / 0xcac4d0,
147        Background: 0xfef7ff / 0x141218,
148        OnBackground: 0x1d1b20 / 0xe6e0e9,
149        Outline: 0x79747e / 0x938f99,
150        OutlineVariant: 0xcac4d0 / 0x49454f,
151        Error: 0xb3261e / 0xf2b8b5,
152        OnError: 0xffffff / 0x601410,
153        // Material 3's own `surfaceContainer`, which is how this system raises a panel:
154        // `Surface` and `Background` are one value here by design, so the container roles
155        // are the only thing that separates a layer from the page.
156        SurfaceContainer: 0xf3edf7 / 0x211f26,
157        // The baseline scheme's own third accent and its four container tones. Material 3
158        // is where the container idea comes from, so these are its published values rather
159        // than anything derived here.
160        Tertiary: 0x7d5260 / 0xefb8c8,
161        OnTertiary: 0xffffff / 0x492532,
162        PrimaryContainer: 0xeaddff / 0x4f378b,
163        OnPrimaryContainer: 0x21005d / 0xeaddff,
164        SecondaryContainer: 0xe8def8 / 0x4a4458,
165        OnSecondaryContainer: 0x1d192b / 0xe8def8,
166        TertiaryContainer: 0xffd8e4 / 0x633b48,
167        OnTertiaryContainer: 0x31111d / 0xffd8e4,
168    },
169    type_scale: type_scale! {
170        Display: 57.0 / 400 / 64.0 / 0.0 / false,
171        Headline: 32.0 / 400 / 40.0 / 0.0 / false,
172        Title: 22.0 / 400 / 28.0 / 0.0 / false,
173        Subtitle: 16.0 / 500 / 24.0 / 0.15 / false,
174        Body: 16.0 / 400 / 24.0 / 0.5 / false,
175        BodyStrong: 16.0 / 500 / 24.0 / 0.15 / false,
176        Label: 14.0 / 500 / 20.0 / 0.1 / false,
177        Caption: 12.0 / 400 / 16.0 / 0.4 / false,
178        Mono: 14.0 / 400 / 20.0 / 0.0 / true,
179    },
180    // The expressive ladder, which is the baseline one shifted up a rung at every step
181    // above the smallest. This is the loudest thing about the reference screens: a card
182    // there is cut at twenty eight rather than sixteen, a sheet is rounder still, and the
183    // buttons and the chips and the selected destination are all capsules. The baseline
184    // ladder drew the same layout with corners half the size, which reads as the previous
185    // version of Material rather than as this one.
186    shapes: shapes! {
187        None: 0.0,
188        ExtraSmall: 4.0,
189        Small: 12.0,
190        Medium: 16.0,
191        Large: 28.0,
192        Full: 1000.0,
193    },
194    spaces: spaces! {
195        None: 0.0,
196        Xs: 4.0,
197        Sm: 8.0,
198        Md: 16.0,
199        Lg: 24.0,
200        Xl: 32.0,
201        Xxl: 48.0,
202    },
203};
204
205const APPLE_HIG: DesignTokenTable = DesignTokenTable {
206    system: DesignSystem::Cupertino,
207    reference: "Apple Human Interface Guidelines, system colors and Dynamic Type, 2024",
208    default_family: "SF Pro",
209    monospace_family: "SF Mono",
210    colors: colors! {
211        Primary: 0x007aff / 0x0a84ff,
212        OnPrimary: 0xffffff / 0xffffff,
213        Secondary: 0x5856d6 / 0x5e5ce6,
214        OnSecondary: 0xffffff / 0xffffff,
215        Surface: 0xffffff / 0x1c1c1e,
216        OnSurface: 0x000000 / 0xffffff,
217        // The page is the grouped background and the variant is the colour an incoming
218        // message bubble has. They used to carry the same value, so anything filled with
219        // one on a page of the other was drawn, in the right colour, and invisible.
220        SurfaceVariant: 0xe9e9eb / 0x2c2c2e,
221        OnSurfaceVariant: 0x3c3c43 / 0xebebf5,
222        Background: 0xf2f2f7 / 0x000000,
223        OnBackground: 0x000000 / 0xffffff,
224        Outline: 0xc6c6c8 / 0x38383a,
225        OutlineVariant: 0xe5e5ea / 0x48484a,
226        Error: 0xff3b30 / 0xff453a,
227        OnError: 0xffffff / 0xffffff,
228        // secondarySystemGroupedBackground: the colour of a grouped box sitting on the
229        // grouped page. It matches `Surface` here, and that is correct rather than a
230        // duplicate: on iOS a panel on the grouped page is the plain reading surface, and
231        // what makes it a panel is that the page underneath is not.
232        SurfaceContainer: 0xffffff / 0x1c1c1e,
233        // systemPurple, the third of the platform accents after blue and indigo.
234        Tertiary: 0xaf52de / 0xbf5af2,
235        OnTertiary: 0xffffff / 0xffffff,
236        // iOS has no published container tones, so these are the tinted fills the platform
237        // draws by hand: a wash of the accent in light, and a deep, desaturated version of
238        // it in dark, which is what a selected row or a tinted card looks like there.
239        PrimaryContainer: 0xd6e4ff / 0x0a2d52,
240        OnPrimaryContainer: 0x003070 / 0xcfe3ff,
241        SecondaryContainer: 0xe2e0ff / 0x262663,
242        OnSecondaryContainer: 0x2a1b70 / 0xdedcff,
243        TertiaryContainer: 0xf3ddfb / 0x3f1a52,
244        OnTertiaryContainer: 0x3d0b52 / 0xf1d9fa,
245    },
246    // The large title is bold. Both reference screens set it that way, "Contacts" over a
247    // grouped list and "Cupertino" over a search field, and a large title at book weight
248    // is the one thing that stops an iOS screen reading as an iOS screen.
249    type_scale: type_scale! {
250        Display: 34.0 / 700 / 41.0 / 0.37 / false,
251        Headline: 28.0 / 400 / 34.0 / 0.36 / false,
252        Title: 22.0 / 400 / 28.0 / 0.35 / false,
253        Subtitle: 17.0 / 600 / 22.0 / -0.41 / false,
254        Body: 17.0 / 400 / 22.0 / -0.41 / false,
255        BodyStrong: 17.0 / 600 / 22.0 / -0.41 / false,
256        Label: 15.0 / 400 / 20.0 / -0.24 / false,
257        Caption: 12.0 / 400 / 16.0 / 0.0 / false,
258        Mono: 15.0 / 400 / 20.0 / 0.0 / true,
259    },
260    // HIG corners are continuous curvature. The radius is the same number, but the
261    // Renderer is expected to draw it with a squircle rather than a circular arc.
262    shapes: shapes! {
263        None: 0.0,
264        ExtraSmall: 4.0,
265        Small: 8.0,
266        Medium: 10.0,
267        Large: 14.0,
268        Full: 1000.0,
269    },
270    spaces: spaces! {
271        None: 0.0,
272        Xs: 4.0,
273        Sm: 8.0,
274        Md: 16.0,
275        Lg: 20.0,
276        Xl: 32.0,
277        Xxl: 44.0,
278    },
279};
280
281const FLUENT: DesignTokenTable = DesignTokenTable {
282    system: DesignSystem::Fluent,
283    reference: "WinUI / Fluent 2 web and Windows tokens, fluent2.microsoft.design, 2024",
284    default_family: "Segoe UI Variable",
285    monospace_family: "Cascadia Mono",
286    colors: colors! {
287        Primary: 0x0f6cbd / 0x479ef5,
288        OnPrimary: 0xffffff / 0x000000,
289        Secondary: 0xebf3fc / 0x0c3b5e,
290        OnSecondary: 0x0f548c / 0xffffff,
291        Surface: 0xffffff / 0x292929,
292        OnSurface: 0x242424 / 0xffffff,
293        // Fluent layers by lightness rather than by outline, and the page it layers on is
294        // the solid background base. The old trio sat within five parts of each other, so
295        // the layering was there in the numbers and not on the screen.
296        SurfaceVariant: 0xeaeaea / 0x333333,
297        OnSurfaceVariant: 0x424242 / 0xd6d6d6,
298        Background: 0xf3f3f3 / 0x1f1f1f,
299        OnBackground: 0x242424 / 0xffffff,
300        Outline: 0xd1d1d1 / 0x666666,
301        OutlineVariant: 0xe0e0e0 / 0x3d3d3d,
302        Error: 0xc50f1f / 0xdc626d,
303        OnError: 0xffffff / 0x000000,
304        // The card layer, which Fluent lifts off the solid background base by lightness.
305        SurfaceContainer: 0xffffff / 0x2b2b2b,
306        // The shared purple ramp, which is Fluent's accent beside the brand blue.
307        Tertiary: 0x8764b8 / 0xb18cd9,
308        OnTertiary: 0xffffff / 0x22103a,
309        // Fluent's brand tints, which are how it fills a selected or highlighted region:
310        // two steps of the brand ramp in light, and the dark shades of it in dark. The
311        // secondary tint is a step deeper than the primary one rather than a different
312        // hue, because Fluent's second accent is the same blue used more strongly.
313        PrimaryContainer: 0xcfe4fa / 0x0c3b5e,
314        OnPrimaryContainer: 0x0c3b5e / 0xcfe4fa,
315        SecondaryContainer: 0xb4d6fa / 0x123d61,
316        OnSecondaryContainer: 0x0b3350 / 0xb4d6fa,
317        TertiaryContainer: 0xe8dcf7 / 0x3b2159,
318        OnTertiaryContainer: 0x341a5e / 0xe8dcf7,
319    },
320    type_scale: type_scale! {
321        Display: 40.0 / 600 / 52.0 / 0.0 / false,
322        Headline: 28.0 / 600 / 36.0 / 0.0 / false,
323        Title: 20.0 / 600 / 28.0 / 0.0 / false,
324        Subtitle: 16.0 / 600 / 22.0 / 0.0 / false,
325        Body: 14.0 / 400 / 20.0 / 0.0 / false,
326        BodyStrong: 14.0 / 600 / 20.0 / 0.0 / false,
327        Label: 12.0 / 400 / 16.0 / 0.0 / false,
328        Caption: 12.0 / 400 / 16.0 / 0.0 / false,
329        Mono: 13.0 / 400 / 18.0 / 0.0 / true,
330    },
331    shapes: shapes! {
332        None: 0.0,
333        ExtraSmall: 2.0,
334        Small: 3.0,
335        Medium: 4.0,
336        Large: 8.0,
337        Full: 1000.0,
338    },
339    spaces: spaces! {
340        None: 0.0,
341        Xs: 2.0,
342        Sm: 4.0,
343        Md: 8.0,
344        Lg: 12.0,
345        Xl: 20.0,
346        Xxl: 32.0,
347    },
348};
349
350const ADWAITA: DesignTokenTable = DesignTokenTable {
351    system: DesignSystem::Gnome,
352    reference: "GNOME Human Interface Guidelines and the libadwaita named colours, GNOME 50",
353    default_family: "Cantarell",
354    monospace_family: "Source Code Pro",
355    colors: colors! {
356        // accent_bg_color, blue 3 in the GNOME palette.
357        Primary: 0x3584e4 / 0x3584e4,
358        OnPrimary: 0xffffff / 0xffffff,
359        // purple 3, the rare second accent. Never a second button colour.
360        Secondary: 0x9141ac / 0xc061cb,
361        // White in both schemes, which is what libadwaita puts on every accent fill but
362        // yellow. On the lighter purple 2 of the dark scheme that measures 3.6:1, which
363        // carries a short control label but would not carry body text. Nothing puts body
364        // text on the second accent.
365        OnSecondary: 0xffffff / 0xffffff,
366        // view_bg_color: the white of a list or a text view.
367        Surface: 0xffffff / 0x1e1e1e,
368        OnSurface: 0x2e3436 / 0xffffff,
369        // headerbar_bg_color: the slightly darker grey of chrome.
370        SurfaceVariant: 0xebebeb / 0x303030,
371        OnSurfaceVariant: 0x5e5c64 / 0xc0bfbc,
372        // window_bg_color.
373        Background: 0xfafafa / 0x242424,
374        OnBackground: 0x2e3436 / 0xffffff,
375        Outline: 0xcdc7c2 / 0x52514f,
376        OutlineVariant: 0xe6e3e1 / 0x3a3a3a,
377        // red 3 in light. Dark takes red 1, because the darker destructive red loses too
378        // much contrast against a dark window.
379        Error: 0xe01b24 / 0xff7b63,
380        OnError: 0xffffff / 0x2a0a06,
381        // An Adwaita card in light is white with a hairline around it, and white against
382        // window_bg_color is five parts of grey: the border is what you actually see. A
383        // role that has to be visible on the page by itself cannot be that, so the layer
384        // is sidebar_bg_color, the grey Adwaita already uses for a panel beside the view.
385        SurfaceContainer: 0xebebeb / 0x303030,
386        // teal 4, the third of the GNOME accent colours after blue and purple. Like the
387        // other two it holds one value across both schemes, which is how libadwaita ships
388        // its accents.
389        Tertiary: 0x2190a4 / 0x2190a4,
390        OnTertiary: 0xffffff / 0xffffff,
391        // Adwaita has no container tones of its own: it tints by drawing the accent at a
392        // low alpha over whatever is behind. That cannot be a role, because a role answers
393        // before anything knows what is behind it, so these are that same tint resolved
394        // against the window colour, one per accent.
395        PrimaryContainer: 0xd4e5fb / 0x1b3c5e,
396        OnPrimaryContainer: 0x0d3b70 / 0xcfe0f7,
397        SecondaryContainer: 0xecd9f1 / 0x44234c,
398        OnSecondaryContainer: 0x45164f / 0xecd9f1,
399        TertiaryContainer: 0xcfe9ed / 0x134249,
400        OnTertiaryContainer: 0x0a3d45 / 0xcfe9ed,
401    },
402    // libadwaita declares its title classes in points against an 11pt Cantarell body, and
403    // they are heavy: the largest title is weight 800, not 700. Those point values are
404    // carried over to sp here, which is why the body is 15 and not the 14 a Material scale
405    // would use.
406    type_scale: type_scale! {
407        Display: 44.0 / 800 / 52.0 / -0.5 / false,
408        Headline: 32.0 / 800 / 40.0 / -0.25 / false,
409        Title: 24.0 / 700 / 32.0 / 0.0 / false,
410        Subtitle: 20.0 / 700 / 28.0 / 0.0 / false,
411        Body: 15.0 / 400 / 22.0 / 0.0 / false,
412        BodyStrong: 15.0 / 700 / 22.0 / 0.0 / false,
413        Label: 13.0 / 700 / 18.0 / 0.1 / false,
414        Caption: 12.0 / 400 / 16.0 / 0.0 / false,
415        Mono: 14.0 / 400 / 20.0 / 0.0 / true,
416    },
417    // libadwaita's own named radii: six on a button or an entry, twelve on a card or a
418    // popover, fifteen on a window. The middle and top rungs were eight and twelve, which
419    // put a card where a button belongs and a window where a card belongs, and the GNOME
420    // 50 screen in docs/references/design-systems/gnome50/ shows the difference plainly:
421    // the preferences groups are rounded well past the buttons inside them, and the
422    // window is rounder again. Pills are kept for suggested actions and search entries.
423    shapes: shapes! {
424        None: 0.0,
425        ExtraSmall: 4.0,
426        Small: 6.0,
427        Medium: 12.0,
428        Large: 15.0,
429        Full: 1000.0,
430    },
431    // GNOME lays out on a six pixel grid, and its dialogs are roomy.
432    spaces: spaces! {
433        None: 0.0,
434        Xs: 3.0,
435        Sm: 6.0,
436        Md: 12.0,
437        Lg: 18.0,
438        Xl: 24.0,
439        Xxl: 36.0,
440    },
441};
442
443const BREEZE: DesignTokenTable = DesignTokenTable {
444    system: DesignSystem::Breeze,
445    reference: "KDE Breeze colour schemes and the KDE Human Interface Guidelines, Plasma 6",
446    default_family: "Noto Sans",
447    monospace_family: "Hack",
448    colors: colors! {
449        // Plasma blue, the default highlight in both schemes.
450        Primary: 0x3daee9 / 0x3daee9,
451        // Breeze puts white on the highlight, which measures under three to one against
452        // this blue. Dark ink keeps the same blue and stays readable at small sizes.
453        OnPrimary: 0x06222e / 0x06222e,
454        // The Breeze "positive" teal, its second accent. Dark ink again, for the same
455        // reason: white on this green is about two to one.
456        Secondary: 0x16a085 / 0x1abc9c,
457        OnSecondary: 0x03201b / 0x03201b,
458        // View background: the white of a list or an entry.
459        Surface: 0xfcfcfc / 0x1b1e20,
460        OnSurface: 0x232629 / 0xfcfcfc,
461        // A step deeper than the window, in Breeze's own grey.
462        SurfaceVariant: 0xe1e3e5 / 0x31363b,
463        OnSurfaceVariant: 0x4d5052 / 0xbdc3c7,
464        // Window background, a touch cooler and darker than the view.
465        Background: 0xeff0f1 / 0x232629,
466        OnBackground: 0x232629 / 0xfcfcfc,
467        Outline: 0xbdc3c7 / 0x4d5155,
468        OutlineVariant: 0xd8dbdd / 0x3f4449,
469        // Breeze "negative".
470        Error: 0xda4453 / 0xed8079,
471        OnError: 0xffffff / 0x2a0806,
472        // A framed panel in Plasma is the view colour sitting on the window: a step
473        // lighter than the window in light, and a step darker in dark, because Breeze
474        // Dark's view really is darker than its window. Either way the panel separates
475        // from the page, which is the whole promise of the role.
476        SurfaceContainer: 0xfcfcfc / 0x1b1e20,
477        // Breeze "neutral", the amber it uses for a warning state, taken here as the third
478        // accent. Dark ink on it for the same reason the other two carry dark ink: white
479        // on any of these Breeze fills measures under three to one.
480        Tertiary: 0xf67400 / 0xf8a44c,
481        OnTertiary: 0x2b1200 / 0x2b1200,
482        // Plasma tints a selected region with the highlight colour at low alpha over the
483        // view. Resolved against the window colour, once per accent, so the role can answer
484        // without knowing what it is over.
485        PrimaryContainer: 0xd3ecf9 / 0x123b4f,
486        OnPrimaryContainer: 0x0b3b52 / 0xcde6f5,
487        SecondaryContainer: 0xd2ece5 / 0x103a32,
488        OnSecondaryContainer: 0x083b31 / 0xcfe8e0,
489        TertiaryContainer: 0xfae0c4 / 0x4a3113,
490        OnTertiaryContainer: 0x4a2c00 / 0xf8dfc3,
491    },
492    // Plasma sets its interface in Noto Sans at 10pt, a step smaller than Adwaita's 11pt
493    // Cantarell, and its headings are semi bold rather than the near black weights
494    // libadwaita uses. Smaller and lighter at every rung, with tighter line heights to
495    // match the denser spacing.
496    type_scale: type_scale! {
497        Display: 34.0 / 600 / 40.0 / 0.0 / false,
498        Headline: 26.0 / 600 / 32.0 / 0.0 / false,
499        Title: 19.0 / 600 / 24.0 / 0.0 / false,
500        Subtitle: 16.0 / 500 / 21.0 / 0.0 / false,
501        Body: 13.0 / 400 / 19.0 / 0.0 / false,
502        BodyStrong: 13.0 / 600 / 19.0 / 0.0 / false,
503        Label: 12.0 / 500 / 16.0 / 0.2 / false,
504        Caption: 11.0 / 400 / 15.0 / 0.0 / false,
505        Mono: 12.0 / 400 / 17.0 / 0.0 / true,
506    },
507    // Breeze barely rounds. Frames and buttons are a two to four pixel radius and the
508    // largest containers stop at six, which is where the language gets its drafted look.
509    shapes: shapes! {
510        None: 0.0,
511        ExtraSmall: 2.0,
512        Small: 3.0,
513        Medium: 4.0,
514        Large: 6.0,
515        Full: 1000.0,
516    },
517    // Kirigami's ladder: small spacing 4, large spacing 8, grid unit 18.
518    spaces: spaces! {
519        None: 0.0,
520        Xs: 2.0,
521        Sm: 4.0,
522        Md: 8.0,
523        Lg: 12.0,
524        Xl: 18.0,
525        Xxl: 24.0,
526    },
527};
528
529const DEEPIN: DesignTokenTable = DesignTokenTable {
530    system: DesignSystem::Deepin,
531    reference: "Deepin Design specification and the DTK control defaults, deepin 23",
532    default_family: "Noto Sans",
533    monospace_family: "Noto Sans Mono",
534    // Neutral greys, not warm ones. The palette here was written from memory once and
535    // described itself as warm, with a trace of brown through every neutral. Deepin's own
536    // screens are not: its system monitor is a white list on a light grey window with a
537    // plain grey search field, and its calculator is a near black window of dark grey
538    // keys. Nothing in either is warm, and the warmth was the most visible thing about
539    // this system, so it was the most visible thing that was wrong.
540    colors: colors! {
541        // The brand blue.
542        Primary: 0x0081ff / 0x3ba2ff,
543        OnPrimary: 0xffffff / 0x04203a,
544        // The amber that sits beside the blue in Deepin's own readouts, where a second
545        // series is drawn in it.
546        Secondary: 0xf2a13c / 0xffb964,
547        OnSecondary: 0x2b1a05 / 0x33200a,
548        // The reading surface: a white list in light, a dark grey key in dark.
549        Surface: 0xffffff / 0x2a2a2a,
550        OnSurface: 0x202020 / 0xf0f0f0,
551        // The fill of a search field, and of the alternate row in a list.
552        SurfaceVariant: 0xe6e6e6 / 0x3a3a3a,
553        OnSurfaceVariant: 0x5a5a5a / 0xb4b4b4,
554        // The window, which is the reading surface itself in light: a Deepin window is
555        // white and what separates from it is the grey well beside the content, not a
556        // white card on a grey page.
557        Background: 0xffffff / 0x1a1a1a,
558        OnBackground: 0x202020 / 0xf0f0f0,
559        Outline: 0xcdcdcd / 0x4d4d4d,
560        OutlineVariant: 0xe0e0e0 / 0x333333,
561        Error: 0xff5736 / 0xff8a73,
562        OnError: 0xffffff / 0x34110a,
563        // The panel: the grey of a sidebar sunk into the white window, and in dark the
564        // key grey, which is a step lighter than the near black window. Either way the
565        // panel separates from the page, which is the promise of the role.
566        SurfaceContainer: 0xf1f1f1 / 0x2a2a2a,
567        // The violet of the deepin palette, the one accent here that is neither the brand
568        // blue nor a warm colour.
569        Tertiary: 0x7a5bd6 / 0x9f8ae3,
570        OnTertiary: 0xffffff / 0x1d0f45,
571        // Tints of the three accents, resolved neutrally against the page colour.
572        PrimaryContainer: 0xd8ecff / 0x203547,
573        OnPrimaryContainer: 0x00407f / 0x9dd0ff,
574        SecondaryContainer: 0xfdf0e1 / 0x473928,
575        OnSecondaryContainer: 0x79501e / 0xffdcb1,
576        TertiaryContainer: 0xebe6f8 / 0x343042,
577        OnTertiaryContainer: 0x3d2d6b / 0xcfc4f1,
578    },
579    // A middle weight ladder. The body sits between Breeze's 13 and Adwaita's 15, and the
580    // headings are semi bold with loose line heights, which suits the rounded shapes and
581    // survives on a desktop whose default font is unknown.
582    type_scale: type_scale! {
583        Display: 40.0 / 600 / 50.0 / 0.0 / false,
584        Headline: 30.0 / 600 / 38.0 / 0.0 / false,
585        Title: 22.0 / 500 / 30.0 / 0.0 / false,
586        Subtitle: 18.0 / 500 / 26.0 / 0.0 / false,
587        Body: 14.0 / 400 / 21.0 / 0.0 / false,
588        BodyStrong: 14.0 / 600 / 21.0 / 0.0 / false,
589        Label: 13.0 / 500 / 18.0 / 0.3 / false,
590        Caption: 12.0 / 400 / 17.0 / 0.0 / false,
591        Mono: 13.0 / 400 / 19.0 / 0.0 / true,
592    },
593    // The roundest of the three Linux systems. Deepin's windows are rounded far past
594    // anything GTK or Qt does, and its controls follow at a smaller radius, so even a
595    // button reads as a lozenge next to a Breeze rectangle. The middle rung is ten rather
596    // than twelve: the keys in the calculator screen are cut at about a sixth of their
597    // height, which is where a Deepin button sits, and twelve was Adwaita's card radius
598    // borrowed for a button.
599    shapes: shapes! {
600        None: 0.0,
601        ExtraSmall: 6.0,
602        Small: 8.0,
603        Medium: 10.0,
604        Large: 18.0,
605        Full: 1000.0,
606    },
607    // Roomy, and on a ten pixel rhythm rather than GNOME's six or Kirigami's four.
608    spaces: spaces! {
609        None: 0.0,
610        Xs: 4.0,
611        Sm: 10.0,
612        Md: 16.0,
613        Lg: 20.0,
614        Xl: 30.0,
615        Xxl: 40.0,
616    },
617};
618
619/// Apple's current language, the one macOS 26 and iOS 26 draw.
620///
621/// It shares a palette with `APPLE_HIG` above, because the system colours did not change:
622/// what changed is what a surface is made of, how deep a corner is cut, and how far a
623/// control is allowed to float. Two of those three are rules rather than tokens, so the
624/// visible difference in this table is the shape ladder.
625const LIQUID_GLASS: DesignTokenTable = DesignTokenTable {
626    system: DesignSystem::LiquidGlass,
627    reference: "Apple Human Interface Guidelines, Liquid Glass, system colors and Dynamic Type, 2026",
628    default_family: "SF Pro",
629    monospace_family: "SF Mono",
630    colors: colors! {
631        Primary: 0x007aff / 0x0a84ff,
632        OnPrimary: 0xffffff / 0xffffff,
633        Secondary: 0x5856d6 / 0x5e5ce6,
634        OnSecondary: 0xffffff / 0xffffff,
635        Surface: 0xffffff / 0x1c1c1e,
636        OnSurface: 0x000000 / 0xffffff,
637        // The secondary fill, which is what a glass surface tints towards when the blur
638        // is unavailable and what a grouped row sits on.
639        SurfaceVariant: 0xe9e9eb / 0x2c2c2e,
640        OnSurfaceVariant: 0x3c3c43 / 0xebebf5,
641        // The page is the document, not a grey well for it to sit in. That is the sharpest
642        // difference from the flat language beside it, where a grouped page is grey and
643        // the white rectangles on it are what a reader looks at: here the separation
644        // comes from the material a panel is made of, so the page underneath can be the
645        // reading surface itself.
646        //
647        // The dark page is pure black, which is right on a phone and wrong in a desktop
648        // window. A window is the other value Apple specifies for this one role, and the
649        // component rules choose between the two by window size. No other role is
650        // specified twice, so no other role is chosen anywhere but here.
651        Background: 0xffffff / 0x000000,
652        OnBackground: 0x000000 / 0xffffff,
653        Outline: 0xc6c6c8 / 0x38383a,
654        OutlineVariant: 0xe5e5ea / 0x48484a,
655        Error: 0xff3b30 / 0xff453a,
656        OnError: 0xffffff / 0xffffff,
657        // A panel has to lift off a page that is already white, so this is the lightest
658        // system grey rather than another white.
659        SurfaceContainer: 0xf2f2f7 / 0x1c1c1e,
660        // Liquid Glass keeps Cupertino's accents, because it is the same platform
661        // palette seen through a different material. What changes is how a surface is
662        // drawn, not which purple Apple uses.
663        Tertiary: 0xaf52de / 0xbf5af2,
664        OnTertiary: 0xffffff / 0xffffff,
665        PrimaryContainer: 0xd6e4ff / 0x0a2d52,
666        OnPrimaryContainer: 0x003070 / 0xcfe3ff,
667        SecondaryContainer: 0xe2e0ff / 0x262663,
668        OnSecondaryContainer: 0x2a1b70 / 0xdedcff,
669        TertiaryContainer: 0xf3ddfb / 0x3f1a52,
670        OnTertiaryContainer: 0x3d0b52 / 0xf1d9fa,
671    },
672    // Same sizes as the flat language, because Dynamic Type did not move, and heavier at
673    // every rung that labels something. A label sitting on a translucent surface competes
674    // with whatever shows through it, and every control label in the reference screens is
675    // set semibold for that reason: the buttons in an alert, the segments of a picker, the
676    // section heads of a formatting sheet.
677    type_scale: type_scale! {
678        Display: 34.0 / 700 / 41.0 / 0.37 / false,
679        Headline: 28.0 / 700 / 34.0 / 0.36 / false,
680        Title: 22.0 / 700 / 28.0 / 0.35 / false,
681        Subtitle: 17.0 / 600 / 22.0 / -0.41 / false,
682        Body: 17.0 / 400 / 22.0 / -0.41 / false,
683        BodyStrong: 17.0 / 600 / 22.0 / -0.41 / false,
684        Label: 15.0 / 600 / 20.0 / -0.24 / false,
685        Caption: 12.0 / 500 / 16.0 / 0.0 / false,
686        Mono: 15.0 / 400 / 20.0 / 0.0 / true,
687    },
688    // Deeper than the flat language, and continuous rather than circular. A surface that
689    // catches light along its rim needs a corner long enough for the rim to travel round
690    // it; a ten dp arc pinches that highlight into a point. The Renderer draws these as a
691    // superellipse, which is the other half of the same decision.
692    shapes: shapes! {
693        None: 0.0,
694        ExtraSmall: 6.0,
695        Small: 10.0,
696        Medium: 16.0,
697        Large: 22.0,
698        Full: 1000.0,
699    },
700    // Roomier than the flat language at every step above the smallest. Two reasons, both
701    // visible in the reference: a twenty two dp corner needs more room inside it before
702    // text stops crowding the curve, and these surfaces float with a margin around them
703    // instead of running to the window edge, so the margin is a spacing step as well.
704    spaces: spaces! {
705        None: 0.0,
706        Xs: 4.0,
707        Sm: 10.0,
708        Md: 18.0,
709        Lg: 24.0,
710        Xl: 36.0,
711        Xxl: 48.0,
712    },
713};
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use crate::schema::DESIGN_SYSTEM_SCHEMA;
719
720    fn channel(value: u32) -> f64 {
721        let value = f64::from(value) / 255.0;
722        if value <= 0.040_45 {
723            value / 12.92
724        } else {
725            ((value + 0.055) / 1.055).powf(2.4)
726        }
727    }
728
729    fn luminance(color: Color) -> f64 {
730        let argb = color.to_argb();
731        0.2126 * channel((argb >> 16) & 0xff)
732            + 0.7152 * channel((argb >> 8) & 0xff)
733            + 0.0722 * channel(argb & 0xff)
734    }
735
736    fn contrast(first: Color, second: Color) -> f64 {
737        let (high, low) = {
738            let (a, b) = (luminance(first), luminance(second));
739            if a > b { (a, b) } else { (b, a) }
740        };
741        (high + 0.05) / (low + 0.05)
742    }
743
744    /// Every `DesignSystem` variant has a table, so adding a variant without
745    /// its values fails here rather than at run time.
746    #[test]
747    fn fr14_every_design_system_has_a_token_table() {
748        assert_eq!(DESIGN_TOKENS.len(), DESIGN_SYSTEM_SCHEMA.len());
749        for (index, variant) in DESIGN_SYSTEM_SCHEMA.iter().enumerate() {
750            let table = DESIGN_TOKENS[index];
751            assert_eq!(table.system as u16, variant.tag);
752            assert!(
753                !table.reference.is_empty(),
754                "{} has no source",
755                variant.name
756            );
757            assert!(!table.default_family.is_empty());
758            assert!(!table.monospace_family.is_empty());
759        }
760    }
761
762    /// The tables are ordered by role tag, so a Renderer can index them directly.
763    #[test]
764    fn fr14_tokens_are_stored_in_role_tag_order() {
765        for table in DESIGN_TOKENS {
766            for (index, token) in table.colors.iter().enumerate() {
767                assert_eq!(token.role as usize, index + 1);
768            }
769            for (index, token) in table.type_scale.iter().enumerate() {
770                assert_eq!(token.role as usize, index + 1);
771            }
772            for (index, token) in table.shapes.iter().enumerate() {
773                assert_eq!(token.role as usize, index + 1);
774            }
775            for (index, token) in table.spaces.iter().enumerate() {
776                assert_eq!(token.role as usize, index + 1);
777            }
778        }
779    }
780
781    /// A filled thing has to be visible on the page it sits on.
782    ///
783    /// Cupertino gave `SurfaceVariant` and `Background` the same value, so a chat bubble
784    /// filled with the variant on a page of the background was drawn, with the right
785    /// colour, and was invisible. Layering is the entire reason three roles exist.
786    ///
787    /// `Background` and `Surface` are deliberately not compared. Material 3 gives them the
788    /// same value on purpose and expresses depth through tonal containers and elevation,
789    /// so requiring those to differ would be requiring Material 3 to stop being itself.
790    ///
791    /// That is exactly why `SurfaceContainer` is checked here too. Material 3 having no
792    /// gap between the page and the surface is not a defect, but it does mean `Surface` is
793    /// not a role a panel can be made of, and until this role existed there was nothing in
794    /// the vocabulary that promised to be visible on the page while holding `OnSurface`
795    /// ink. A calculator readout filled with `Surface` disappeared under Material 3 and
796    /// nothing here noticed, because nothing here was asked about it.
797    #[test]
798    fn fr14_surface_variant_is_visible_against_the_page_and_the_surface() {
799        /// Summed channel distance in eight bit terms, which is enough to catch a collapse.
800        fn apart(first: Color, second: Color) -> u32 {
801            (0..3)
802                .map(|channel| {
803                    let shift = channel * 8;
804                    let a = (first.0 >> shift) & 0xff;
805                    let b = (second.0 >> shift) & 0xff;
806                    a.abs_diff(b)
807                })
808                .sum()
809        }
810
811        for table in DESIGN_TOKENS {
812            for scheme in [ColorScheme::Light, ColorScheme::Dark] {
813                let variant = table.color(ColorRole::SurfaceVariant, scheme);
814                for under in [ColorRole::Background, ColorRole::Surface] {
815                    let distance = apart(variant, table.color(under, scheme));
816                    assert!(
817                        distance >= 24,
818                        "{:?} {:?}: SurfaceVariant and {:?} are {} apart, which reads as \
819                         one flat surface. Anything filled with one on a page of the other \
820                         disappears.",
821                        table.system,
822                        scheme,
823                        under,
824                        distance
825                    );
826                }
827                // A panel is raised off the page, so the layer role has to be visible
828                // against the page. It is not compared against `Surface`, because in
829                // Cupertino a panel on the grouped page is the plain reading surface and
830                // the two carrying one value there is the correct answer, not a collapse.
831                let container = table.color(ColorRole::SurfaceContainer, scheme);
832                let distance = apart(container, table.color(ColorRole::Background, scheme));
833                assert!(
834                    distance >= 24,
835                    "{:?} {:?}: SurfaceContainer and Background are {} apart, so a panel \
836                     made of it is drawn and cannot be seen. This is the one role a panel \
837                     can rely on, so it has to lift off the page in every system.",
838                    table.system,
839                    scheme,
840                    distance
841                );
842                // The accent containers answer the same question for a tinted panel. A
843                // tint nobody can see is a panel that is not there, and these are the
844                // fills a screen made of coloured tiles is built out of.
845                for container in [
846                    ColorRole::PrimaryContainer,
847                    ColorRole::SecondaryContainer,
848                    ColorRole::TertiaryContainer,
849                ] {
850                    let distance = apart(
851                        table.color(container, scheme),
852                        table.color(ColorRole::Background, scheme),
853                    );
854                    assert!(
855                        distance >= 24,
856                        "{:?} {:?}: {:?} and Background are {} apart, so a tinted panel is \
857                         drawn and cannot be seen.",
858                        table.system,
859                        scheme,
860                        container,
861                        distance
862                    );
863                }
864            }
865        }
866    }
867
868    /// The ladder descends from Display to Caption in every system. `Mono` sits
869    /// outside the ladder, because it is a family choice and not a rung.
870    #[test]
871    fn fr13_type_ladder_is_monotonic() {
872        for table in DESIGN_TOKENS {
873            let ladder = &table.type_scale[..8];
874            for pair in ladder.windows(2) {
875                assert!(
876                    pair[0].size >= pair[1].size,
877                    "{:?}: {:?} is not larger than {:?}",
878                    table.system,
879                    pair[0].role,
880                    pair[1].role
881                );
882            }
883            for token in table.type_scale.iter() {
884                assert!(token.size > 0.0);
885                assert!((100..=900).contains(&token.weight));
886                assert!(token.line_height >= token.size);
887            }
888            assert!(table.type_scale[TypeRole::Mono as usize - 1].monospace);
889            assert!(
890                table.type_scale[TypeRole::BodyStrong as usize - 1].weight
891                    > table.type_scale[TypeRole::Body as usize - 1].weight
892            );
893        }
894    }
895
896    /// Radii and spacing grow with the role, starting at zero.
897    #[test]
898    fn fr13_shape_and_space_ladders_grow() {
899        for table in DESIGN_TOKENS {
900            assert_eq!(table.shapes[0].radius, 0.0);
901            for pair in table.shapes.windows(2) {
902                assert!(pair[1].radius > pair[0].radius, "{:?}", table.system);
903            }
904            assert_eq!(table.spaces[0].value, 0.0);
905            for pair in table.spaces.windows(2) {
906                assert!(pair[1].value > pair[0].value, "{:?}", table.system);
907            }
908        }
909    }
910
911    /// `On*` is readable on its pair in both schemes.
912    ///
913    /// Reading surfaces hold WCAG AA body text (4.5:1). Accent fills are held to 3:1,
914    /// which is the AA large-text and non-text bound: Apple's systemBlue with white
915    /// sits at about 4.0:1 and its systemRed at about 3.4:1, and those are the shipped
916    /// platform colours. Tightening past that would mean inventing colours that no
917    /// platform actually uses, which is worse than the looser bound.
918    #[test]
919    fn fr14_on_roles_stay_readable() {
920        let reading = [
921            (ColorRole::Surface, ColorRole::OnSurface),
922            (ColorRole::SurfaceVariant, ColorRole::OnSurfaceVariant),
923            (ColorRole::Background, ColorRole::OnBackground),
924            // The layer role has no ink of its own: it holds the page's reading ink, and
925            // that is the promise a caller relies on when filling a panel with it.
926            (ColorRole::SurfaceContainer, ColorRole::OnSurface),
927            // The accent containers exist so that a paragraph can land on a tinted panel,
928            // not just a word, so they are held to the reading bound rather than to the
929            // looser bound their accents keep.
930            (ColorRole::PrimaryContainer, ColorRole::OnPrimaryContainer),
931            (
932                ColorRole::SecondaryContainer,
933                ColorRole::OnSecondaryContainer,
934            ),
935            (ColorRole::TertiaryContainer, ColorRole::OnTertiaryContainer),
936        ];
937        let accent = [
938            (ColorRole::Primary, ColorRole::OnPrimary),
939            (ColorRole::Secondary, ColorRole::OnSecondary),
940            (ColorRole::Tertiary, ColorRole::OnTertiary),
941            (ColorRole::Error, ColorRole::OnError),
942        ];
943        for table in DESIGN_TOKENS {
944            for scheme in [ColorScheme::Light, ColorScheme::Dark] {
945                for (base, on) in reading {
946                    let ratio = contrast(table.color(base, scheme), table.color(on, scheme));
947                    assert!(
948                        ratio >= 4.5,
949                        "{:?} {scheme:?} {base:?}/{on:?} is {ratio:.2}:1",
950                        table.system
951                    );
952                }
953                for (base, on) in accent {
954                    let ratio = contrast(table.color(base, scheme), table.color(on, scheme));
955                    assert!(
956                        ratio >= 3.0,
957                        "{:?} {scheme:?} {base:?}/{on:?} is {ratio:.2}:1",
958                        table.system
959                    );
960                }
961            }
962        }
963    }
964
965    /// No two design systems answer a whole ladder with the same values.
966    ///
967    /// A design system that agrees with another one on every colour, every type rung,
968    /// every radius or every spacing step is that other system wearing a different name,
969    /// and a caller who selected it would see no change at all. Checking each ladder
970    /// separately is what catches a table that was copied and then edited in one place.
971    #[test]
972    fn fr14_no_two_design_systems_answer_a_whole_ladder_identically() {
973        for (index, first) in DESIGN_TOKENS.iter().enumerate() {
974            for second in &DESIGN_TOKENS[index + 1..] {
975                assert_ne!(
976                    first.colors, second.colors,
977                    "{:?} and {:?} have the same palette",
978                    first.system, second.system
979                );
980                assert_ne!(
981                    first.type_scale, second.type_scale,
982                    "{:?} and {:?} have the same type scale",
983                    first.system, second.system
984                );
985                assert_ne!(
986                    first.shapes, second.shapes,
987                    "{:?} and {:?} round everything the same way",
988                    first.system, second.system
989                );
990                assert_ne!(
991                    first.spaces, second.spaces,
992                    "{:?} and {:?} have the same spacing ladder",
993                    first.system, second.system
994                );
995            }
996        }
997    }
998
999    /// `FollowSystem` is the Renderer's call, so the Host-side lookup can only
1000    /// report the light value. This documents that, rather than leaving it a surprise.
1001    #[test]
1002    fn fr14_follow_system_reads_as_light_on_the_host() {
1003        let table = table(DesignSystem::Material3);
1004        assert_eq!(
1005            table.color(ColorRole::Surface, ColorScheme::FollowSystem),
1006            table.color(ColorRole::Surface, ColorScheme::Light)
1007        );
1008    }
1009}