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}