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::{
14 COLOR_ROLE_COUNT, Color, ColorRole, ColorScheme, DesignSystem, ShapeRole, SpaceRole, TypeRole,
15};
16
17/// One `ColorRole` slot in both schemes, in `ColorRole` tag order.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub struct ColorToken {
20 pub role: ColorRole,
21 pub light: Color,
22 pub dark: Color,
23}
24
25/// One rung of the nine-rung type ladder (13.2).
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct TypeToken {
28 pub role: TypeRole,
29 /// Font size in sp.
30 pub size: f32,
31 /// 100 to 900.
32 pub weight: u16,
33 /// Line height in sp.
34 pub line_height: f32,
35 /// Letter spacing in sp, which may be negative.
36 pub letter_spacing: f32,
37 /// True when the rung uses the monospace family rather than the default one.
38 pub monospace: bool,
39}
40
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub struct ShapeToken {
43 pub role: ShapeRole,
44 /// Corner radius in dp. `Full` uses a radius large enough to read as a pill.
45 pub radius: f32,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq)]
49pub struct SpaceToken {
50 pub role: SpaceRole,
51 /// Spacing in dp.
52 pub value: f32,
53}
54
55/// Items 1 to 4 of the 14.6 table for one design system.
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub struct DesignTokenTable {
58 pub system: DesignSystem,
59 /// The published guideline the values come from, recorded as 14.3 asks.
60 pub reference: &'static str,
61 pub default_family: &'static str,
62 pub monospace_family: &'static str,
63 pub colors: &'static [ColorToken; COLOR_ROLE_COUNT],
64 pub type_scale: &'static [TypeToken; 9],
65 pub shapes: &'static [ShapeToken; 6],
66 pub spaces: &'static [SpaceToken; 7],
67}
68
69impl DesignTokenTable {
70 pub fn color(&self, role: ColorRole, scheme: ColorScheme) -> Color {
71 let token = self.colors[role as usize - 1];
72 // `FollowSystem` is resolved by the Renderer, which is the side that knows.
73 // Asking the Host for it can only mean light.
74 if scheme == ColorScheme::Dark {
75 token.dark
76 } else {
77 token.light
78 }
79 }
80}
81
82/// Every design system's table, in `DesignSystem` tag order.
83///
84/// A fourth design system is one entry here plus one Renderer rule implementation.
85/// Nothing in `widgets.rs`, the Modifier schema or the Property schema moves (14.1).
86pub const DESIGN_TOKENS: &[DesignTokenTable] = &[
87 MATERIAL3,
88 APPLE_HIG,
89 FLUENT,
90 ADWAITA,
91 BREEZE,
92 DEEPIN,
93 LIQUID_GLASS,
94];
95
96pub fn table(system: DesignSystem) -> &'static DesignTokenTable {
97 &DESIGN_TOKENS[system as usize - 1]
98}
99
100macro_rules! colors {
101 ($($role:ident: $light:literal / $dark:literal),+ $(,)?) => {
102 &[$(ColorToken {
103 role: ColorRole::$role,
104 light: Color::rgb($light),
105 dark: Color::rgb($dark),
106 }),+]
107 };
108}
109
110macro_rules! type_scale {
111 ($($role:ident: $size:literal / $weight:literal / $line:literal / $spacing:literal / $mono:literal),+ $(,)?) => {
112 &[$(TypeToken {
113 role: TypeRole::$role,
114 size: $size,
115 weight: $weight,
116 line_height: $line,
117 letter_spacing: $spacing,
118 monospace: $mono,
119 }),+]
120 };
121}
122
123macro_rules! shapes {
124 ($($role:ident: $radius:literal),+ $(,)?) => {
125 &[$(ShapeToken { role: ShapeRole::$role, radius: $radius }),+]
126 };
127}
128
129macro_rules! spaces {
130 ($($role:ident: $value:literal),+ $(,)?) => {
131 &[$(SpaceToken { role: SpaceRole::$role, value: $value }),+]
132 };
133}
134
135const MATERIAL3: DesignTokenTable = DesignTokenTable {
136 system: DesignSystem::Material3,
137 reference: "Material 3 Expressive shape, colour and type scales, m3.material.io, 2025",
138 default_family: "Roboto",
139 monospace_family: "Roboto Mono",
140 colors: colors! {
141 Primary: 0x6750a4 / 0xd0bcff,
142 OnPrimary: 0xffffff / 0x381e72,
143 Secondary: 0x625b71 / 0xccc2dc,
144 OnSecondary: 0xffffff / 0x332d41,
145 Surface: 0xfef7ff / 0x141218,
146 OnSurface: 0x1d1b20 / 0xe6e0e9,
147 SurfaceVariant: 0xe7e0ec / 0x49454f,
148 OnSurfaceVariant: 0x49454f / 0xcac4d0,
149 Background: 0xfef7ff / 0x141218,
150 OnBackground: 0x1d1b20 / 0xe6e0e9,
151 Outline: 0x79747e / 0x938f99,
152 OutlineVariant: 0xcac4d0 / 0x49454f,
153 Error: 0xb3261e / 0xf2b8b5,
154 OnError: 0xffffff / 0x601410,
155 // Material 3's own `surfaceContainer`, which is how this system raises a panel:
156 // `Surface` and `Background` are one value here by design, so the container roles
157 // are the only thing that separates a layer from the page.
158 SurfaceContainer: 0xf3edf7 / 0x211f26,
159 // The baseline scheme's own third accent and its four container tones. Material 3
160 // is where the container idea comes from, so these are its published values rather
161 // than anything derived here.
162 Tertiary: 0x7d5260 / 0xefb8c8,
163 OnTertiary: 0xffffff / 0x492532,
164 PrimaryContainer: 0xeaddff / 0x4f378b,
165 OnPrimaryContainer: 0x21005d / 0xeaddff,
166 SecondaryContainer: 0xe8def8 / 0x4a4458,
167 OnSecondaryContainer: 0x1d192b / 0xe8def8,
168 TertiaryContainer: 0xffd8e4 / 0x633b48,
169 OnTertiaryContainer: 0x31111d / 0xffd8e4,
170 // Code colours. Material 3 publishes no code editor scheme, so these are derived from
171 // the base roles above by the fixed rule in `code_colours.rs`: each syntax ink takes one
172 // base role, the removed ink is the error role, the added ink is the error role turned
173 // to a green hue at the same tone and chroma, and the line and word backgrounds are those
174 // inks laid over the panel. `fr13_1_3_derived_code_colours_follow_the_rule` recomputes
175 // every one of them, so a change to a base role has to be carried through here.
176 SyntaxKeyword: 0x6750a4 / 0xd0bcff,
177 SyntaxString: 0x7d5260 / 0xefb8c8,
178 SyntaxComment: 0x49454f / 0xcac4d0,
179 SyntaxNumber: 0x31111d / 0xffd8e4,
180 SyntaxConstant: 0x31111d / 0xffd8e4,
181 SyntaxType: 0x625b71 / 0xccc2dc,
182 SyntaxFunction: 0x21005d / 0xeaddff,
183 SyntaxVariable: 0x1d1b20 / 0xe6e0e9,
184 SyntaxProperty: 0x1d192b / 0xe8def8,
185 SyntaxOperator: 0x49454f / 0xcac4d0,
186 SyntaxPunctuation: 0x49454f / 0xcac4d0,
187 SyntaxTag: 0x6750a4 / 0xd0bcff,
188 SyntaxAttribute: 0x625b71 / 0xccc2dc,
189 SyntaxEscape: 0xb3261e / 0xf2b8b5,
190 SyntaxMacro: 0x625b71 / 0xccc2dc,
191 DiffAdded: 0x006d17 / 0xb2ceaa,
192 DiffRemoved: 0xb3261e / 0xf2b8b5,
193 DiffModified: 0x7d5260 / 0xefb8c8,
194 DiffAddedContainer: 0xd6dedc / 0x3e4240,
195 DiffRemovedContainer: 0xebd5dd / 0x4b3e43,
196 DiffAddedEmphasis: 0xaac7b4 / 0x5b655b,
197 DiffRemovedEmphasis: 0xe0b1b6 / 0x755c5f,
198 },
199 type_scale: type_scale! {
200 Display: 57.0 / 400 / 64.0 / 0.0 / false,
201 Headline: 32.0 / 400 / 40.0 / 0.0 / false,
202 Title: 22.0 / 400 / 28.0 / 0.0 / false,
203 Subtitle: 16.0 / 500 / 24.0 / 0.15 / false,
204 Body: 16.0 / 400 / 24.0 / 0.5 / false,
205 BodyStrong: 16.0 / 500 / 24.0 / 0.15 / false,
206 Label: 14.0 / 500 / 20.0 / 0.1 / false,
207 Caption: 12.0 / 400 / 16.0 / 0.4 / false,
208 Mono: 14.0 / 400 / 20.0 / 0.0 / true,
209 },
210 // The expressive ladder, which is the baseline one shifted up a rung at every step
211 // above the smallest. This is the loudest thing about the reference screens: a card
212 // there is cut at twenty eight rather than sixteen, a sheet is rounder still, and the
213 // buttons and the chips and the selected destination are all capsules. The baseline
214 // ladder drew the same layout with corners half the size, which reads as the previous
215 // version of Material rather than as this one.
216 shapes: shapes! {
217 None: 0.0,
218 ExtraSmall: 4.0,
219 Small: 12.0,
220 Medium: 16.0,
221 Large: 28.0,
222 Full: 1000.0,
223 },
224 spaces: spaces! {
225 None: 0.0,
226 Xs: 4.0,
227 Sm: 8.0,
228 Md: 16.0,
229 Lg: 24.0,
230 Xl: 32.0,
231 Xxl: 48.0,
232 },
233};
234
235const APPLE_HIG: DesignTokenTable = DesignTokenTable {
236 system: DesignSystem::Cupertino,
237 reference: "Apple Human Interface Guidelines, system colors and Dynamic Type, 2024",
238 default_family: "SF Pro",
239 monospace_family: "SF Mono",
240 colors: colors! {
241 Primary: 0x007aff / 0x0a84ff,
242 OnPrimary: 0xffffff / 0xffffff,
243 Secondary: 0x5856d6 / 0x5e5ce6,
244 OnSecondary: 0xffffff / 0xffffff,
245 Surface: 0xffffff / 0x1c1c1e,
246 OnSurface: 0x000000 / 0xffffff,
247 // The page is the grouped background and the variant is the colour an incoming
248 // message bubble has. They used to carry the same value, so anything filled with
249 // one on a page of the other was drawn, in the right colour, and invisible.
250 SurfaceVariant: 0xe9e9eb / 0x2c2c2e,
251 OnSurfaceVariant: 0x3c3c43 / 0xebebf5,
252 Background: 0xf2f2f7 / 0x000000,
253 OnBackground: 0x000000 / 0xffffff,
254 Outline: 0xc6c6c8 / 0x38383a,
255 OutlineVariant: 0xe5e5ea / 0x48484a,
256 Error: 0xff3b30 / 0xff453a,
257 OnError: 0xffffff / 0xffffff,
258 // secondarySystemGroupedBackground: the colour of a grouped box sitting on the
259 // grouped page. It matches `Surface` here, and that is correct rather than a
260 // duplicate: on iOS a panel on the grouped page is the plain reading surface, and
261 // what makes it a panel is that the page underneath is not.
262 SurfaceContainer: 0xffffff / 0x1c1c1e,
263 // systemPurple, the third of the platform accents after blue and indigo.
264 Tertiary: 0xaf52de / 0xbf5af2,
265 OnTertiary: 0xffffff / 0xffffff,
266 // iOS has no published container tones, so these are the tinted fills the platform
267 // draws by hand: a wash of the accent in light, and a deep, desaturated version of
268 // it in dark, which is what a selected row or a tinted card looks like there.
269 PrimaryContainer: 0xd6e4ff / 0x0a2d52,
270 OnPrimaryContainer: 0x003070 / 0xcfe3ff,
271 SecondaryContainer: 0xe2e0ff / 0x262663,
272 OnSecondaryContainer: 0x2a1b70 / 0xdedcff,
273 TertiaryContainer: 0xf3ddfb / 0x3f1a52,
274 OnTertiaryContainer: 0x3d0b52 / 0xf1d9fa,
275 // Code colours, after Xcode's Default (Light) and Default (Dark). UNVERIFIED: Xcode's
276 // themes are not published as files, so these were written from the shipped editor
277 // rather than read from a source, and need checking against Xcode itself. Xcode has no
278 // diff palette of its own, so the diff inks are the platform's accessible green and red
279 // and the line and word backgrounds are those inks laid over the panel. Where a value
280 // had to be moved in tone to keep 4.5:1 on this system's panel, the original is noted.
281 SyntaxKeyword: 0x9b2393 / 0xfc5fa3,
282 SyntaxString: 0xc41a16 / 0xfc6a5d,
283 // SyntaxComment is 0x5d6c79 / 0x6c7986 in the editor.
284 SyntaxComment: 0x5d6c79 / 0x798693,
285 SyntaxNumber: 0x1c00cf / 0xd0bf69,
286 SyntaxConstant: 0x6c36a9 / 0xa167e6,
287 SyntaxType: 0x3900a0 / 0xd0a8ff,
288 SyntaxFunction: 0x326d74 / 0x67b7a4,
289 SyntaxVariable: 0x000000 / 0xdfdfe0,
290 SyntaxProperty: 0x326d74 / 0x67b7a4,
291 SyntaxOperator: 0x000000 / 0xdfdfe0,
292 SyntaxPunctuation: 0x000000 / 0xdfdfe0,
293 SyntaxTag: 0x0b4f79 / 0x5dd8ff,
294 SyntaxAttribute: 0x815f03 / 0xbf8555,
295 SyntaxEscape: 0x1c00cf / 0xd0bf69,
296 SyntaxMacro: 0x643820 / 0xfd8f3f,
297 // DiffAdded is 0x248a3d / 0x30d158 in the editor.
298 DiffAdded: 0x087a2f / 0x30d158,
299 // DiffRemoved is 0xd70015 / 0xff6961 in the editor.
300 DiffRemoved: 0xce0014 / 0xff6e66,
301 DiffModified: 0x007aff / 0x0a84ff,
302 DiffAddedContainer: 0xe1efe6 / 0x20402a,
303 DiffRemovedContainer: 0xf9e0e3 / 0x492c2c,
304 DiffAddedEmphasis: 0xb5d7c1 / 0x246435,
305 DiffRemovedEmphasis: 0xf0b3b9 / 0x773d3b,
306 },
307 // The large title is bold. Both reference screens set it that way, "Contacts" over a
308 // grouped list and "Cupertino" over a search field, and a large title at book weight
309 // is the one thing that stops an iOS screen reading as an iOS screen.
310 type_scale: type_scale! {
311 Display: 34.0 / 700 / 41.0 / 0.37 / false,
312 Headline: 28.0 / 400 / 34.0 / 0.36 / false,
313 Title: 22.0 / 400 / 28.0 / 0.35 / false,
314 Subtitle: 17.0 / 600 / 22.0 / -0.41 / false,
315 Body: 17.0 / 400 / 22.0 / -0.41 / false,
316 BodyStrong: 17.0 / 600 / 22.0 / -0.41 / false,
317 Label: 15.0 / 400 / 20.0 / -0.24 / false,
318 Caption: 12.0 / 400 / 16.0 / 0.0 / false,
319 Mono: 15.0 / 400 / 20.0 / 0.0 / true,
320 },
321 // HIG corners are continuous curvature. The radius is the same number, but the
322 // Renderer is expected to draw it with a squircle rather than a circular arc.
323 shapes: shapes! {
324 None: 0.0,
325 ExtraSmall: 4.0,
326 Small: 8.0,
327 Medium: 10.0,
328 Large: 14.0,
329 Full: 1000.0,
330 },
331 spaces: spaces! {
332 None: 0.0,
333 Xs: 4.0,
334 Sm: 8.0,
335 Md: 16.0,
336 Lg: 20.0,
337 Xl: 32.0,
338 Xxl: 44.0,
339 },
340};
341
342const FLUENT: DesignTokenTable = DesignTokenTable {
343 system: DesignSystem::Fluent,
344 reference: "WinUI / Fluent 2 web and Windows tokens, fluent2.microsoft.design, 2024",
345 default_family: "Segoe UI Variable",
346 monospace_family: "Cascadia Mono",
347 colors: colors! {
348 Primary: 0x0f6cbd / 0x479ef5,
349 OnPrimary: 0xffffff / 0x000000,
350 Secondary: 0xebf3fc / 0x0c3b5e,
351 OnSecondary: 0x0f548c / 0xffffff,
352 Surface: 0xffffff / 0x292929,
353 OnSurface: 0x242424 / 0xffffff,
354 // Fluent layers by lightness rather than by outline, and the page it layers on is
355 // the solid background base. The old trio sat within five parts of each other, so
356 // the layering was there in the numbers and not on the screen.
357 SurfaceVariant: 0xeaeaea / 0x333333,
358 OnSurfaceVariant: 0x424242 / 0xd6d6d6,
359 Background: 0xf3f3f3 / 0x1f1f1f,
360 OnBackground: 0x242424 / 0xffffff,
361 Outline: 0xd1d1d1 / 0x666666,
362 OutlineVariant: 0xe0e0e0 / 0x3d3d3d,
363 Error: 0xc50f1f / 0xdc626d,
364 OnError: 0xffffff / 0x000000,
365 // The card layer, which Fluent lifts off the solid background base by lightness.
366 SurfaceContainer: 0xffffff / 0x2b2b2b,
367 // The shared purple ramp, which is Fluent's accent beside the brand blue.
368 Tertiary: 0x8764b8 / 0xb18cd9,
369 OnTertiary: 0xffffff / 0x22103a,
370 // Fluent's brand tints, which are how it fills a selected or highlighted region:
371 // two steps of the brand ramp in light, and the dark shades of it in dark. The
372 // secondary tint is a step deeper than the primary one rather than a different
373 // hue, because Fluent's second accent is the same blue used more strongly.
374 PrimaryContainer: 0xcfe4fa / 0x0c3b5e,
375 OnPrimaryContainer: 0x0c3b5e / 0xcfe4fa,
376 SecondaryContainer: 0xb4d6fa / 0x123d61,
377 OnSecondaryContainer: 0x0b3350 / 0xb4d6fa,
378 TertiaryContainer: 0xe8dcf7 / 0x3b2159,
379 OnTertiaryContainer: 0x341a5e / 0xe8dcf7,
380 // Code colours, read from VS Code's Light+ and Dark+ in microsoft/vscode:
381 // extensions/theme-defaults/themes/light_vs.json (f93fdeb4), light_plus.json (09fe3fcb),
382 // dark_vs.json (f93fdeb4), dark_plus.json (e061feef). Punctuation is the editor
383 // foreground and macros take keyword.control, which is what a preprocessor directive is
384 // scoped as. The diff backgrounds are VS Code's own defaults from
385 // src/vs/platform/theme/common/colors/editorColors.ts (463b5e85): lines at
386 // rgba(155,185,85,.2) and rgba(255,0,0,.2), words at #9ccc2c40/33 and #ff000033 over
387 // them, resolved over this system's panel. Where a value had to be moved in tone to keep
388 // 4.5:1 on this system's panel, the editor's own value is noted.
389 SyntaxKeyword: 0x0000ff / 0x569cd6,
390 SyntaxString: 0xa31515 / 0xce9178,
391 // SyntaxComment is 0x008000 / 0x6a9955 in the editor.
392 SyntaxComment: 0x008000 / 0x6f9e5a,
393 SyntaxNumber: 0x098658 / 0xb5cea8,
394 SyntaxConstant: 0x0070c1 / 0x4fc1ff,
395 SyntaxType: 0x267f99 / 0x4ec9b0,
396 SyntaxFunction: 0x795e26 / 0xdcdcaa,
397 SyntaxVariable: 0x001080 / 0x9cdcfe,
398 SyntaxProperty: 0x001080 / 0x9cdcfe,
399 SyntaxOperator: 0x000000 / 0xd4d4d4,
400 SyntaxPunctuation: 0x000000 / 0xd4d4d4,
401 SyntaxTag: 0x800000 / 0x569cd6,
402 SyntaxAttribute: 0xe50000 / 0x9cdcfe,
403 SyntaxEscape: 0xee0000 / 0xd7ba7d,
404 SyntaxMacro: 0xaf00db / 0xc586c0,
405 // DiffAdded is 0x098658 / 0xb5cea8 in the editor.
406 DiffAdded: 0x007b50 / 0xb5cea8,
407 DiffRemoved: 0xa31515 / 0xce9178,
408 DiffModified: 0x0451a5 / 0x569cd6,
409 DiffAddedContainer: 0xebf1dd / 0x414733,
410 DiffRemovedContainer: 0xffcccc / 0x552222,
411 DiffAddedEmphasis: 0xd7e8b1 / 0x536232,
412 DiffRemovedEmphasis: 0xff9999 / 0x771b1b,
413 },
414 type_scale: type_scale! {
415 Display: 40.0 / 600 / 52.0 / 0.0 / false,
416 Headline: 28.0 / 600 / 36.0 / 0.0 / false,
417 Title: 20.0 / 600 / 28.0 / 0.0 / false,
418 Subtitle: 16.0 / 600 / 22.0 / 0.0 / false,
419 Body: 14.0 / 400 / 20.0 / 0.0 / false,
420 BodyStrong: 14.0 / 600 / 20.0 / 0.0 / false,
421 Label: 12.0 / 400 / 16.0 / 0.0 / false,
422 Caption: 12.0 / 400 / 16.0 / 0.0 / false,
423 Mono: 13.0 / 400 / 18.0 / 0.0 / true,
424 },
425 shapes: shapes! {
426 None: 0.0,
427 ExtraSmall: 2.0,
428 Small: 3.0,
429 Medium: 4.0,
430 Large: 8.0,
431 Full: 1000.0,
432 },
433 spaces: spaces! {
434 None: 0.0,
435 Xs: 2.0,
436 Sm: 4.0,
437 Md: 8.0,
438 Lg: 12.0,
439 Xl: 20.0,
440 Xxl: 32.0,
441 },
442};
443
444const ADWAITA: DesignTokenTable = DesignTokenTable {
445 system: DesignSystem::Gnome,
446 reference: "GNOME Human Interface Guidelines and the libadwaita named colours, GNOME 50",
447 default_family: "Cantarell",
448 monospace_family: "Source Code Pro",
449 colors: colors! {
450 // accent_bg_color, blue 3 in the GNOME palette.
451 Primary: 0x3584e4 / 0x3584e4,
452 OnPrimary: 0xffffff / 0xffffff,
453 // purple 3, the rare second accent. Never a second button colour.
454 Secondary: 0x9141ac / 0xc061cb,
455 // White in both schemes, which is what libadwaita puts on every accent fill but
456 // yellow. On the lighter purple 2 of the dark scheme that measures 3.6:1, which
457 // carries a short control label but would not carry body text. Nothing puts body
458 // text on the second accent.
459 OnSecondary: 0xffffff / 0xffffff,
460 // view_bg_color: the white of a list or a text view.
461 Surface: 0xffffff / 0x1e1e1e,
462 OnSurface: 0x2e3436 / 0xffffff,
463 // headerbar_bg_color: the slightly darker grey of chrome.
464 SurfaceVariant: 0xebebeb / 0x303030,
465 OnSurfaceVariant: 0x5e5c64 / 0xc0bfbc,
466 // window_bg_color.
467 Background: 0xfafafa / 0x242424,
468 OnBackground: 0x2e3436 / 0xffffff,
469 Outline: 0xcdc7c2 / 0x52514f,
470 OutlineVariant: 0xe6e3e1 / 0x3a3a3a,
471 // red 3 in light. Dark takes red 1, because the darker destructive red loses too
472 // much contrast against a dark window.
473 Error: 0xe01b24 / 0xff7b63,
474 OnError: 0xffffff / 0x2a0a06,
475 // An Adwaita card in light is white with a hairline around it, and white against
476 // window_bg_color is five parts of grey: the border is what you actually see. A
477 // role that has to be visible on the page by itself cannot be that, so the layer
478 // is sidebar_bg_color, the grey Adwaita already uses for a panel beside the view.
479 SurfaceContainer: 0xebebeb / 0x303030,
480 // teal 4, the third of the GNOME accent colours after blue and purple. Like the
481 // other two it holds one value across both schemes, which is how libadwaita ships
482 // its accents.
483 Tertiary: 0x2190a4 / 0x2190a4,
484 OnTertiary: 0xffffff / 0xffffff,
485 // Adwaita has no container tones of its own: it tints by drawing the accent at a
486 // low alpha over whatever is behind. That cannot be a role, because a role answers
487 // before anything knows what is behind it, so these are that same tint resolved
488 // against the window colour, one per accent.
489 PrimaryContainer: 0xd4e5fb / 0x1b3c5e,
490 OnPrimaryContainer: 0x0d3b70 / 0xcfe0f7,
491 SecondaryContainer: 0xecd9f1 / 0x44234c,
492 OnSecondaryContainer: 0x45164f / 0xecd9f1,
493 TertiaryContainer: 0xcfe9ed / 0x134249,
494 OnTertiaryContainer: 0x0a3d45 / 0xcfe9ed,
495 // Code colours, read from GtkSourceView's styles in GNOME/gtksourceview:
496 // data/styles/Adwaita.xml (f9e0f254) and Adwaita-dark.xml (c4604e35). Keywords are
497 // def:statement, variables def:identifier, tags and attributes xml:element-name and
498 // xml:attribute-name, escapes def:special-char, macros def:preprocessor, and property,
499 // operator and punctuation the text foreground. The diff inks are diff:added-line,
500 // diff:removed-line and diff:changed-line; the line and word backgrounds are those inks
501 // laid over the panel. Where a value had to be moved in tone to keep 4.5:1 on this
502 // system's grey panel, the scheme's own value is noted.
503 // SyntaxKeyword is 0xc64600 / 0xffa348 in the editor.
504 SyntaxKeyword: 0xba4100 / 0xffa348,
505 // SyntaxString is 0x218787 / 0x5bc8af in the editor.
506 SyntaxString: 0x007778 / 0x5bc8af,
507 // SyntaxComment is 0x77767b / 0x777777 in the editor.
508 SyntaxComment: 0x6a6a6e / 0x999898,
509 // SyntaxNumber is 0x4e57ba / 0x7d8ac7 in the editor.
510 SyntaxNumber: 0x4e57ba / 0x8794d2,
511 // SyntaxConstant is 0x4e57ba / 0x7d8ac7 in the editor.
512 SyntaxConstant: 0x4e57ba / 0x8794d2,
513 // SyntaxType is 0x218787 / 0x5bc8af in the editor.
514 SyntaxType: 0x007778 / 0x5bc8af,
515 // SyntaxFunction is 0x1c71d8 / 0x62a0ea in the editor.
516 SyntaxFunction: 0x0067cd / 0x62a0ea,
517 // SyntaxVariable is 0x4e9a06 / 0x4e9a06 in the editor.
518 SyntaxVariable: 0x3b7800 / 0x5dab1e,
519 SyntaxProperty: 0x504e55 / 0xc0bfbc,
520 SyntaxOperator: 0x504e55 / 0xc0bfbc,
521 SyntaxPunctuation: 0x504e55 / 0xc0bfbc,
522 // SyntaxTag is 0x218787 / 0x33b2a4 in the editor.
523 SyntaxTag: 0x007778 / 0x33b2a4,
524 // SyntaxAttribute is 0xc64600 / 0xff7800 in the editor.
525 SyntaxAttribute: 0xba4100 / 0xff7800,
526 // SyntaxEscape is 0xed333b / 0xf66151 in the editor.
527 SyntaxEscape: 0xd01a2a / 0xfd6656,
528 // SyntaxMacro is 0xc64600 / 0xe66100 in the editor.
529 SyntaxMacro: 0xba4100 / 0xfb7018,
530 // DiffAdded is 0x26a1a2 / 0x33b2a4 in the editor.
531 DiffAdded: 0x006a6b / 0x56cebf,
532 // DiffRemoved is 0xf66151 / 0xf66151 in the editor.
533 DiffRemoved: 0xae2d23 / 0xffa295,
534 DiffModified: 0xe66100 / 0xff7800,
535 DiffAddedContainer: 0xcfdcdc / 0x38504d,
536 DiffRemovedContainer: 0xe4d4d3 / 0x594744,
537 DiffAddedEmphasis: 0xa5c4c5 / 0x3f6f69,
538 DiffRemovedEmphasis: 0xd9b2af / 0x835e58,
539 },
540 // libadwaita declares its title classes in points against an 11pt Cantarell body, and
541 // they are heavy: the largest title is weight 800, not 700. Those point values are
542 // carried over to sp here, which is why the body is 15 and not the 14 a Material scale
543 // would use.
544 type_scale: type_scale! {
545 Display: 44.0 / 800 / 52.0 / -0.5 / false,
546 Headline: 32.0 / 800 / 40.0 / -0.25 / false,
547 Title: 24.0 / 700 / 32.0 / 0.0 / false,
548 Subtitle: 20.0 / 700 / 28.0 / 0.0 / false,
549 Body: 15.0 / 400 / 22.0 / 0.0 / false,
550 BodyStrong: 15.0 / 700 / 22.0 / 0.0 / false,
551 Label: 13.0 / 700 / 18.0 / 0.1 / false,
552 Caption: 12.0 / 400 / 16.0 / 0.0 / false,
553 Mono: 14.0 / 400 / 20.0 / 0.0 / true,
554 },
555 // libadwaita's own named radii: six on a button or an entry, twelve on a card or a
556 // popover, fifteen on a window. The middle and top rungs were eight and twelve, which
557 // put a card where a button belongs and a window where a card belongs, and the GNOME
558 // 50 screen in docs/references/design-systems/gnome50/ shows the difference plainly:
559 // the preferences groups are rounded well past the buttons inside them, and the
560 // window is rounder again. Pills are kept for suggested actions and search entries.
561 shapes: shapes! {
562 None: 0.0,
563 ExtraSmall: 4.0,
564 Small: 6.0,
565 Medium: 12.0,
566 Large: 15.0,
567 Full: 1000.0,
568 },
569 // GNOME lays out on a six pixel grid, and its dialogs are roomy.
570 spaces: spaces! {
571 None: 0.0,
572 Xs: 3.0,
573 Sm: 6.0,
574 Md: 12.0,
575 Lg: 18.0,
576 Xl: 24.0,
577 Xxl: 36.0,
578 },
579};
580
581const BREEZE: DesignTokenTable = DesignTokenTable {
582 system: DesignSystem::Breeze,
583 reference: "KDE Breeze colour schemes and the KDE Human Interface Guidelines, Plasma 6",
584 default_family: "Noto Sans",
585 monospace_family: "Hack",
586 colors: colors! {
587 // Plasma blue, the default highlight in both schemes.
588 Primary: 0x3daee9 / 0x3daee9,
589 // Breeze puts white on the highlight, which measures under three to one against
590 // this blue. Dark ink keeps the same blue and stays readable at small sizes.
591 OnPrimary: 0x06222e / 0x06222e,
592 // The Breeze "positive" teal, its second accent. Dark ink again, for the same
593 // reason: white on this green is about two to one.
594 Secondary: 0x16a085 / 0x1abc9c,
595 OnSecondary: 0x03201b / 0x03201b,
596 // View background: the white of a list or an entry.
597 Surface: 0xfcfcfc / 0x1b1e20,
598 OnSurface: 0x232629 / 0xfcfcfc,
599 // A step deeper than the window, in Breeze's own grey.
600 SurfaceVariant: 0xe1e3e5 / 0x31363b,
601 OnSurfaceVariant: 0x4d5052 / 0xbdc3c7,
602 // Window background, a touch cooler and darker than the view.
603 Background: 0xeff0f1 / 0x232629,
604 OnBackground: 0x232629 / 0xfcfcfc,
605 Outline: 0xbdc3c7 / 0x4d5155,
606 OutlineVariant: 0xd8dbdd / 0x3f4449,
607 // Breeze "negative".
608 Error: 0xda4453 / 0xed8079,
609 OnError: 0xffffff / 0x2a0806,
610 // A framed panel in Plasma is the view colour sitting on the window: a step
611 // lighter than the window in light, and a step darker in dark, because Breeze
612 // Dark's view really is darker than its window. Either way the panel separates
613 // from the page, which is the whole promise of the role.
614 SurfaceContainer: 0xfcfcfc / 0x1b1e20,
615 // Breeze "neutral", the amber it uses for a warning state, taken here as the third
616 // accent. Dark ink on it for the same reason the other two carry dark ink: white
617 // on any of these Breeze fills measures under three to one.
618 Tertiary: 0xf67400 / 0xf8a44c,
619 OnTertiary: 0x2b1200 / 0x2b1200,
620 // Plasma tints a selected region with the highlight colour at low alpha over the
621 // view. Resolved against the window colour, once per accent, so the role can answer
622 // without knowing what it is over.
623 PrimaryContainer: 0xd3ecf9 / 0x123b4f,
624 OnPrimaryContainer: 0x0b3b52 / 0xcde6f5,
625 SecondaryContainer: 0xd2ece5 / 0x103a32,
626 OnSecondaryContainer: 0x083b31 / 0xcfe8e0,
627 TertiaryContainer: 0xfae0c4 / 0x4a3113,
628 OnTertiaryContainer: 0x4a2c00 / 0xf8dfc3,
629 // Code colours, read from KSyntaxHighlighting in KDE/syntax-highlighting:
630 // data/themes/breeze-light.theme (b5576b24) and breeze-dark.theme (9d1d9a4e). Keywords
631 // are the normal text colour there, set bold by the editor. The diff inks follow
632 // data/syntax/diff.xml (f8b28165), which draws an added line as Variable and a removed
633 // line as String, and the modified ink is the theme's ModifiedLines. The backgrounds are
634 // those inks laid over the panel. Where a value had to be moved in tone to keep 4.5:1,
635 // the scheme's own value is noted.
636 SyntaxKeyword: 0x1f1c1b / 0xcfcfc2,
637 SyntaxString: 0xbf0303 / 0xf44f4f,
638 // SyntaxComment is 0x898887 / 0x7a7c7d in the editor.
639 SyntaxComment: 0x747473 / 0x848687,
640 // SyntaxNumber is 0xb08000 / 0xf67400 in the editor.
641 SyntaxNumber: 0x966d00 / 0xf67400,
642 SyntaxConstant: 0xaa5500 / 0x27aeae,
643 // SyntaxType is 0x0057ae / 0x2980b9 in the editor.
644 SyntaxType: 0x0057ae / 0x3b8dc7,
645 // SyntaxFunction is 0x644a9b / 0x8e44ad in the editor.
646 SyntaxFunction: 0x644a9b / 0xb266d1,
647 SyntaxVariable: 0x0057ae / 0x27aeae,
648 SyntaxProperty: 0x0057ae / 0x27aeae,
649 // SyntaxOperator is 0xca60ca / 0x3f8058 in the editor.
650 SyntaxOperator: 0xaf48b1 / 0x519269,
651 SyntaxPunctuation: 0x1f1c1b / 0xcfcfc2,
652 SyntaxTag: 0x1f1c1b / 0xcfcfc2,
653 // SyntaxAttribute is 0x0057ae / 0x2980b9 in the editor.
654 SyntaxAttribute: 0x0057ae / 0x3b8dc7,
655 // SyntaxEscape is 0x3daee9 / 0x3daee9 in the editor.
656 SyntaxEscape: 0x007bac / 0x3daee9,
657 SyntaxMacro: 0x006e28 / 0x27ae60,
658 // DiffAdded is 0x0057ae / 0x27aeae in the editor.
659 DiffAdded: 0x0057ae / 0x2cb1b1,
660 // DiffRemoved is 0xbf0303 / 0xf44f4f in the editor.
661 DiffRemoved: 0xbf0303 / 0xff736e,
662 DiffModified: 0xfdbc4b / 0xc04900,
663 DiffAddedContainer: 0xdee8f3 / 0x1e3b3d,
664 DiffRemovedContainer: 0xf5dede / 0x492f30,
665 DiffAddedEmphasis: 0xb0cae5 / 0x22595a,
666 DiffRemovedEmphasis: 0xeab1b1 / 0x76403f,
667 },
668 // Plasma sets its interface in Noto Sans at 10pt, a step smaller than Adwaita's 11pt
669 // Cantarell, and its headings are semi bold rather than the near black weights
670 // libadwaita uses. Smaller and lighter at every rung, with tighter line heights to
671 // match the denser spacing.
672 type_scale: type_scale! {
673 Display: 34.0 / 600 / 40.0 / 0.0 / false,
674 Headline: 26.0 / 600 / 32.0 / 0.0 / false,
675 Title: 19.0 / 600 / 24.0 / 0.0 / false,
676 Subtitle: 16.0 / 500 / 21.0 / 0.0 / false,
677 Body: 13.0 / 400 / 19.0 / 0.0 / false,
678 BodyStrong: 13.0 / 600 / 19.0 / 0.0 / false,
679 Label: 12.0 / 500 / 16.0 / 0.2 / false,
680 Caption: 11.0 / 400 / 15.0 / 0.0 / false,
681 Mono: 12.0 / 400 / 17.0 / 0.0 / true,
682 },
683 // Breeze barely rounds. Frames and buttons are a two to four pixel radius and the
684 // largest containers stop at six, which is where the language gets its drafted look.
685 shapes: shapes! {
686 None: 0.0,
687 ExtraSmall: 2.0,
688 Small: 3.0,
689 Medium: 4.0,
690 Large: 6.0,
691 Full: 1000.0,
692 },
693 // Kirigami's ladder: small spacing 4, large spacing 8, grid unit 18.
694 spaces: spaces! {
695 None: 0.0,
696 Xs: 2.0,
697 Sm: 4.0,
698 Md: 8.0,
699 Lg: 12.0,
700 Xl: 18.0,
701 Xxl: 24.0,
702 },
703};
704
705const DEEPIN: DesignTokenTable = DesignTokenTable {
706 system: DesignSystem::Deepin,
707 reference: "Deepin Design specification and the DTK control defaults, deepin 23",
708 default_family: "Noto Sans",
709 monospace_family: "Noto Sans Mono",
710 // Neutral greys, not warm ones. The palette here was written from memory once and
711 // described itself as warm, with a trace of brown through every neutral. Deepin's own
712 // screens are not: its system monitor is a white list on a light grey window with a
713 // plain grey search field, and its calculator is a near black window of dark grey
714 // keys. Nothing in either is warm, and the warmth was the most visible thing about
715 // this system, so it was the most visible thing that was wrong.
716 colors: colors! {
717 // The brand blue.
718 Primary: 0x0081ff / 0x3ba2ff,
719 OnPrimary: 0xffffff / 0x04203a,
720 // The amber that sits beside the blue in Deepin's own readouts, where a second
721 // series is drawn in it.
722 Secondary: 0xf2a13c / 0xffb964,
723 OnSecondary: 0x2b1a05 / 0x33200a,
724 // The reading surface: a white list in light, a dark grey key in dark.
725 Surface: 0xffffff / 0x2a2a2a,
726 OnSurface: 0x202020 / 0xf0f0f0,
727 // The fill of a search field, and of the alternate row in a list.
728 SurfaceVariant: 0xe6e6e6 / 0x3a3a3a,
729 OnSurfaceVariant: 0x5a5a5a / 0xb4b4b4,
730 // The window, which is the reading surface itself in light: a Deepin window is
731 // white and what separates from it is the grey well beside the content, not a
732 // white card on a grey page.
733 Background: 0xffffff / 0x1a1a1a,
734 OnBackground: 0x202020 / 0xf0f0f0,
735 Outline: 0xcdcdcd / 0x4d4d4d,
736 OutlineVariant: 0xe0e0e0 / 0x333333,
737 Error: 0xff5736 / 0xff8a73,
738 OnError: 0xffffff / 0x34110a,
739 // The panel: the grey of a sidebar sunk into the white window, and in dark the
740 // key grey, which is a step lighter than the near black window. Either way the
741 // panel separates from the page, which is the promise of the role.
742 SurfaceContainer: 0xf1f1f1 / 0x2a2a2a,
743 // The violet of the deepin palette, the one accent here that is neither the brand
744 // blue nor a warm colour.
745 Tertiary: 0x7a5bd6 / 0x9f8ae3,
746 OnTertiary: 0xffffff / 0x1d0f45,
747 // Tints of the three accents, resolved neutrally against the page colour.
748 PrimaryContainer: 0xd8ecff / 0x203547,
749 OnPrimaryContainer: 0x00407f / 0x9dd0ff,
750 SecondaryContainer: 0xfdf0e1 / 0x473928,
751 OnSecondaryContainer: 0x79501e / 0xffdcb1,
752 TertiaryContainer: 0xebe6f8 / 0x343042,
753 OnTertiaryContainer: 0x3d2d6b / 0xcfc4f1,
754 // Code colours. Deepin publishes no code editor scheme, so these are derived from the
755 // base roles above by the same rule Material 3's are. The brand blue, the amber and the
756 // error orange do not reach 4.5:1 on the grey panel as they are, so the rule moves them
757 // darker in tone, keeping their hue and chroma.
758 SyntaxKeyword: 0x006bd5 / 0x3ba2ff,
759 SyntaxString: 0x7758d3 / 0x9f8ae3,
760 SyntaxComment: 0x5a5a5a / 0xb4b4b4,
761 SyntaxNumber: 0x3d2d6b / 0xcfc4f1,
762 SyntaxConstant: 0x3d2d6b / 0xcfc4f1,
763 SyntaxType: 0x9b5e00 / 0xffb964,
764 SyntaxFunction: 0x00407f / 0x9dd0ff,
765 SyntaxVariable: 0x202020 / 0xf0f0f0,
766 SyntaxProperty: 0x79501e / 0xffdcb1,
767 SyntaxOperator: 0x5a5a5a / 0xb4b4b4,
768 SyntaxPunctuation: 0x5a5a5a / 0xb4b4b4,
769 SyntaxTag: 0x006bd5 / 0x3ba2ff,
770 SyntaxAttribute: 0x9b5e00 / 0xffb964,
771 SyntaxEscape: 0xcd3416 / 0xff8a73,
772 SyntaxMacro: 0x9b5e00 / 0xffb964,
773 DiffAdded: 0x007118 / 0x76c270,
774 DiffRemoved: 0xbb2608 / 0xff9783,
775 DiffModified: 0x7a5bd6 / 0x9f8ae3,
776 DiffAddedContainer: 0xd4e2d7 / 0x394838,
777 DiffRemovedContainer: 0xebd9d5 / 0x55403c,
778 DiffAddedEmphasis: 0xa9cbb0 / 0x486746,
779 DiffRemovedEmphasis: 0xe1b4ab / 0x7f564e,
780 },
781 // A middle weight ladder. The body sits between Breeze's 13 and Adwaita's 15, and the
782 // headings are semi bold with loose line heights, which suits the rounded shapes and
783 // survives on a desktop whose default font is unknown.
784 type_scale: type_scale! {
785 Display: 40.0 / 600 / 50.0 / 0.0 / false,
786 Headline: 30.0 / 600 / 38.0 / 0.0 / false,
787 Title: 22.0 / 500 / 30.0 / 0.0 / false,
788 Subtitle: 18.0 / 500 / 26.0 / 0.0 / false,
789 Body: 14.0 / 400 / 21.0 / 0.0 / false,
790 BodyStrong: 14.0 / 600 / 21.0 / 0.0 / false,
791 Label: 13.0 / 500 / 18.0 / 0.3 / false,
792 Caption: 12.0 / 400 / 17.0 / 0.0 / false,
793 Mono: 13.0 / 400 / 19.0 / 0.0 / true,
794 },
795 // The roundest of the three Linux systems. Deepin's windows are rounded far past
796 // anything GTK or Qt does, and its controls follow at a smaller radius, so even a
797 // button reads as a lozenge next to a Breeze rectangle. The middle rung is ten rather
798 // than twelve: the keys in the calculator screen are cut at about a sixth of their
799 // height, which is where a Deepin button sits, and twelve was Adwaita's card radius
800 // borrowed for a button.
801 shapes: shapes! {
802 None: 0.0,
803 ExtraSmall: 6.0,
804 Small: 8.0,
805 Medium: 10.0,
806 Large: 18.0,
807 Full: 1000.0,
808 },
809 // Roomy, and on a ten pixel rhythm rather than GNOME's six or Kirigami's four.
810 spaces: spaces! {
811 None: 0.0,
812 Xs: 4.0,
813 Sm: 10.0,
814 Md: 16.0,
815 Lg: 20.0,
816 Xl: 30.0,
817 Xxl: 40.0,
818 },
819};
820
821/// Apple's current language, the one macOS 26 and iOS 26 draw.
822///
823/// It shares a palette with `APPLE_HIG` above, because the system colours did not change:
824/// what changed is what a surface is made of, how deep a corner is cut, and how far a
825/// control is allowed to float. Two of those three are rules rather than tokens, so the
826/// visible difference in this table is the shape ladder.
827const LIQUID_GLASS: DesignTokenTable = DesignTokenTable {
828 system: DesignSystem::LiquidGlass,
829 reference: "Apple Human Interface Guidelines, Liquid Glass, system colors and Dynamic Type, 2026",
830 default_family: "SF Pro",
831 monospace_family: "SF Mono",
832 colors: colors! {
833 Primary: 0x007aff / 0x0a84ff,
834 OnPrimary: 0xffffff / 0xffffff,
835 Secondary: 0x5856d6 / 0x5e5ce6,
836 OnSecondary: 0xffffff / 0xffffff,
837 Surface: 0xffffff / 0x1c1c1e,
838 OnSurface: 0x000000 / 0xffffff,
839 // The secondary fill, which is what a glass surface tints towards when the blur
840 // is unavailable and what a grouped row sits on.
841 SurfaceVariant: 0xe9e9eb / 0x2c2c2e,
842 OnSurfaceVariant: 0x3c3c43 / 0xebebf5,
843 // The page is the document, not a grey well for it to sit in. That is the sharpest
844 // difference from the flat language beside it, where a grouped page is grey and
845 // the white rectangles on it are what a reader looks at: here the separation
846 // comes from the material a panel is made of, so the page underneath can be the
847 // reading surface itself.
848 //
849 // The dark page is pure black, which is right on a phone and wrong in a desktop
850 // window. A window is the other value Apple specifies for this one role, and the
851 // component rules choose between the two by window size. No other role is
852 // specified twice, so no other role is chosen anywhere but here.
853 Background: 0xffffff / 0x000000,
854 OnBackground: 0x000000 / 0xffffff,
855 Outline: 0xc6c6c8 / 0x38383a,
856 OutlineVariant: 0xe5e5ea / 0x48484a,
857 Error: 0xff3b30 / 0xff453a,
858 OnError: 0xffffff / 0xffffff,
859 // A panel has to lift off a page that is already white, so this is the lightest
860 // system grey rather than another white.
861 SurfaceContainer: 0xf2f2f7 / 0x1c1c1e,
862 // Liquid Glass keeps Cupertino's accents, because it is the same platform
863 // palette seen through a different material. What changes is how a surface is
864 // drawn, not which purple Apple uses.
865 Tertiary: 0xaf52de / 0xbf5af2,
866 OnTertiary: 0xffffff / 0xffffff,
867 PrimaryContainer: 0xd6e4ff / 0x0a2d52,
868 OnPrimaryContainer: 0x003070 / 0xcfe3ff,
869 SecondaryContainer: 0xe2e0ff / 0x262663,
870 OnSecondaryContainer: 0x2a1b70 / 0xdedcff,
871 TertiaryContainer: 0xf3ddfb / 0x3f1a52,
872 OnTertiaryContainer: 0x3d0b52 / 0xf1d9fa,
873 // Code colours, after Xcode as Cupertino's are, and UNVERIFIED for the same reason. The
874 // panel is a step greyer here, so the diff inks are moved further in tone and the
875 // backgrounds resolve differently.
876 SyntaxKeyword: 0x9b2393 / 0xfc5fa3,
877 SyntaxString: 0xc41a16 / 0xfc6a5d,
878 // SyntaxComment is 0x5d6c79 / 0x6c7986 in the editor.
879 SyntaxComment: 0x5d6c79 / 0x798693,
880 SyntaxNumber: 0x1c00cf / 0xd0bf69,
881 SyntaxConstant: 0x6c36a9 / 0xa167e6,
882 SyntaxType: 0x3900a0 / 0xd0a8ff,
883 SyntaxFunction: 0x326d74 / 0x67b7a4,
884 SyntaxVariable: 0x000000 / 0xdfdfe0,
885 SyntaxProperty: 0x326d74 / 0x67b7a4,
886 SyntaxOperator: 0x000000 / 0xdfdfe0,
887 SyntaxPunctuation: 0x000000 / 0xdfdfe0,
888 SyntaxTag: 0x0b4f79 / 0x5dd8ff,
889 SyntaxAttribute: 0x815f03 / 0xbf8555,
890 SyntaxEscape: 0x1c00cf / 0xd0bf69,
891 SyntaxMacro: 0x643820 / 0xfd8f3f,
892 // DiffAdded is 0x248a3d / 0x30d158 in the editor.
893 DiffAdded: 0x00722a / 0x30d158,
894 // DiffRemoved is 0xd70015 / 0xff6961 in the editor.
895 DiffRemoved: 0xc40012 / 0xff6e66,
896 DiffModified: 0x007aff / 0x0a84ff,
897 DiffAddedContainer: 0xd5e3de / 0x20402a,
898 DiffRemovedContainer: 0xecd5dc / 0x492c2c,
899 DiffAddedEmphasis: 0xa9ccb9 / 0x246435,
900 DiffRemovedEmphasis: 0xe4a9b2 / 0x773d3b,
901 },
902 // Same sizes as the flat language, because Dynamic Type did not move, and heavier at
903 // every rung that labels something. A label sitting on a translucent surface competes
904 // with whatever shows through it, and every control label in the reference screens is
905 // set semibold for that reason: the buttons in an alert, the segments of a picker, the
906 // section heads of a formatting sheet.
907 type_scale: type_scale! {
908 Display: 34.0 / 700 / 41.0 / 0.37 / false,
909 Headline: 28.0 / 700 / 34.0 / 0.36 / false,
910 Title: 22.0 / 700 / 28.0 / 0.35 / false,
911 Subtitle: 17.0 / 600 / 22.0 / -0.41 / false,
912 Body: 17.0 / 400 / 22.0 / -0.41 / false,
913 BodyStrong: 17.0 / 600 / 22.0 / -0.41 / false,
914 Label: 15.0 / 600 / 20.0 / -0.24 / false,
915 Caption: 12.0 / 500 / 16.0 / 0.0 / false,
916 Mono: 15.0 / 400 / 20.0 / 0.0 / true,
917 },
918 // Deeper than the flat language, and continuous rather than circular. A surface that
919 // catches light along its rim needs a corner long enough for the rim to travel round
920 // it; a ten dp arc pinches that highlight into a point. The Renderer draws these as a
921 // superellipse, which is the other half of the same decision.
922 shapes: shapes! {
923 None: 0.0,
924 ExtraSmall: 6.0,
925 Small: 10.0,
926 Medium: 16.0,
927 Large: 22.0,
928 Full: 1000.0,
929 },
930 // Roomier than the flat language at every step above the smallest. Two reasons, both
931 // visible in the reference: a twenty two dp corner needs more room inside it before
932 // text stops crowding the curve, and these surfaces float with a margin around them
933 // instead of running to the window edge, so the margin is a spacing step as well.
934 spaces: spaces! {
935 None: 0.0,
936 Xs: 4.0,
937 Sm: 10.0,
938 Md: 18.0,
939 Lg: 24.0,
940 Xl: 36.0,
941 Xxl: 48.0,
942 },
943};
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use crate::schema::DESIGN_SYSTEM_SCHEMA;
949
950 fn channel(value: u32) -> f64 {
951 let value = f64::from(value) / 255.0;
952 if value <= 0.040_45 {
953 value / 12.92
954 } else {
955 ((value + 0.055) / 1.055).powf(2.4)
956 }
957 }
958
959 fn luminance(color: Color) -> f64 {
960 let argb = color.to_argb();
961 0.2126 * channel((argb >> 16) & 0xff)
962 + 0.7152 * channel((argb >> 8) & 0xff)
963 + 0.0722 * channel(argb & 0xff)
964 }
965
966 fn contrast(first: Color, second: Color) -> f64 {
967 let (high, low) = {
968 let (a, b) = (luminance(first), luminance(second));
969 if a > b { (a, b) } else { (b, a) }
970 };
971 (high + 0.05) / (low + 0.05)
972 }
973
974 /// Every `DesignSystem` variant has a table, so adding a variant without
975 /// its values fails here rather than at run time.
976 #[test]
977 fn fr14_every_design_system_has_a_token_table() {
978 assert_eq!(DESIGN_TOKENS.len(), DESIGN_SYSTEM_SCHEMA.len());
979 for (index, variant) in DESIGN_SYSTEM_SCHEMA.iter().enumerate() {
980 let table = DESIGN_TOKENS[index];
981 assert_eq!(table.system as u16, variant.tag);
982 assert!(
983 !table.reference.is_empty(),
984 "{} has no source",
985 variant.name
986 );
987 assert!(!table.default_family.is_empty());
988 assert!(!table.monospace_family.is_empty());
989 }
990 }
991
992 /// The tables are ordered by role tag, so a Renderer can index them directly.
993 #[test]
994 fn fr14_tokens_are_stored_in_role_tag_order() {
995 for table in DESIGN_TOKENS {
996 for (index, token) in table.colors.iter().enumerate() {
997 assert_eq!(token.role as usize, index + 1);
998 }
999 for (index, token) in table.type_scale.iter().enumerate() {
1000 assert_eq!(token.role as usize, index + 1);
1001 }
1002 for (index, token) in table.shapes.iter().enumerate() {
1003 assert_eq!(token.role as usize, index + 1);
1004 }
1005 for (index, token) in table.spaces.iter().enumerate() {
1006 assert_eq!(token.role as usize, index + 1);
1007 }
1008 }
1009 }
1010
1011 /// A filled thing has to be visible on the page it sits on.
1012 ///
1013 /// Cupertino gave `SurfaceVariant` and `Background` the same value, so a chat bubble
1014 /// filled with the variant on a page of the background was drawn, with the right
1015 /// colour, and was invisible. Layering is the entire reason three roles exist.
1016 ///
1017 /// `Background` and `Surface` are deliberately not compared. Material 3 gives them the
1018 /// same value on purpose and expresses depth through tonal containers and elevation,
1019 /// so requiring those to differ would be requiring Material 3 to stop being itself.
1020 ///
1021 /// That is exactly why `SurfaceContainer` is checked here too. Material 3 having no
1022 /// gap between the page and the surface is not a defect, but it does mean `Surface` is
1023 /// not a role a panel can be made of, and until this role existed there was nothing in
1024 /// the vocabulary that promised to be visible on the page while holding `OnSurface`
1025 /// ink. A calculator readout filled with `Surface` disappeared under Material 3 and
1026 /// nothing here noticed, because nothing here was asked about it.
1027 #[test]
1028 fn fr14_surface_variant_is_visible_against_the_page_and_the_surface() {
1029 /// Summed channel distance in eight bit terms, which is enough to catch a collapse.
1030 fn apart(first: Color, second: Color) -> u32 {
1031 (0..3)
1032 .map(|channel| {
1033 let shift = channel * 8;
1034 let a = (first.0 >> shift) & 0xff;
1035 let b = (second.0 >> shift) & 0xff;
1036 a.abs_diff(b)
1037 })
1038 .sum()
1039 }
1040
1041 for table in DESIGN_TOKENS {
1042 for scheme in [ColorScheme::Light, ColorScheme::Dark] {
1043 let variant = table.color(ColorRole::SurfaceVariant, scheme);
1044 for under in [ColorRole::Background, ColorRole::Surface] {
1045 let distance = apart(variant, table.color(under, scheme));
1046 assert!(
1047 distance >= 24,
1048 "{:?} {:?}: SurfaceVariant and {:?} are {} apart, which reads as \
1049 one flat surface. Anything filled with one on a page of the other \
1050 disappears.",
1051 table.system,
1052 scheme,
1053 under,
1054 distance
1055 );
1056 }
1057 // A panel is raised off the page, so the layer role has to be visible
1058 // against the page. It is not compared against `Surface`, because in
1059 // Cupertino a panel on the grouped page is the plain reading surface and
1060 // the two carrying one value there is the correct answer, not a collapse.
1061 let container = table.color(ColorRole::SurfaceContainer, scheme);
1062 let distance = apart(container, table.color(ColorRole::Background, scheme));
1063 assert!(
1064 distance >= 24,
1065 "{:?} {:?}: SurfaceContainer and Background are {} apart, so a panel \
1066 made of it is drawn and cannot be seen. This is the one role a panel \
1067 can rely on, so it has to lift off the page in every system.",
1068 table.system,
1069 scheme,
1070 distance
1071 );
1072 // The accent containers answer the same question for a tinted panel. A
1073 // tint nobody can see is a panel that is not there, and these are the
1074 // fills a screen made of coloured tiles is built out of.
1075 for container in [
1076 ColorRole::PrimaryContainer,
1077 ColorRole::SecondaryContainer,
1078 ColorRole::TertiaryContainer,
1079 ] {
1080 let distance = apart(
1081 table.color(container, scheme),
1082 table.color(ColorRole::Background, scheme),
1083 );
1084 assert!(
1085 distance >= 24,
1086 "{:?} {:?}: {:?} and Background are {} apart, so a tinted panel is \
1087 drawn and cannot be seen.",
1088 table.system,
1089 scheme,
1090 container,
1091 distance
1092 );
1093 }
1094 }
1095 }
1096 }
1097
1098 /// The ladder descends from Display to Caption in every system. `Mono` sits
1099 /// outside the ladder, because it is a family choice and not a rung.
1100 #[test]
1101 fn fr13_type_ladder_is_monotonic() {
1102 for table in DESIGN_TOKENS {
1103 let ladder = &table.type_scale[..8];
1104 for pair in ladder.windows(2) {
1105 assert!(
1106 pair[0].size >= pair[1].size,
1107 "{:?}: {:?} is not larger than {:?}",
1108 table.system,
1109 pair[0].role,
1110 pair[1].role
1111 );
1112 }
1113 for token in table.type_scale.iter() {
1114 assert!(token.size > 0.0);
1115 assert!((100..=900).contains(&token.weight));
1116 assert!(token.line_height >= token.size);
1117 }
1118 assert!(table.type_scale[TypeRole::Mono as usize - 1].monospace);
1119 assert!(
1120 table.type_scale[TypeRole::BodyStrong as usize - 1].weight
1121 > table.type_scale[TypeRole::Body as usize - 1].weight
1122 );
1123 }
1124 }
1125
1126 /// Radii and spacing grow with the role, starting at zero.
1127 #[test]
1128 fn fr13_shape_and_space_ladders_grow() {
1129 for table in DESIGN_TOKENS {
1130 assert_eq!(table.shapes[0].radius, 0.0);
1131 for pair in table.shapes.windows(2) {
1132 assert!(pair[1].radius > pair[0].radius, "{:?}", table.system);
1133 }
1134 assert_eq!(table.spaces[0].value, 0.0);
1135 for pair in table.spaces.windows(2) {
1136 assert!(pair[1].value > pair[0].value, "{:?}", table.system);
1137 }
1138 }
1139 }
1140
1141 /// `On*` is readable on its pair in both schemes.
1142 ///
1143 /// Reading surfaces hold WCAG AA body text (4.5:1). Accent fills are held to 3:1,
1144 /// which is the AA large-text and non-text bound: Apple's systemBlue with white
1145 /// sits at about 4.0:1 and its systemRed at about 3.4:1, and those are the shipped
1146 /// platform colours. Tightening past that would mean inventing colours that no
1147 /// platform actually uses, which is worse than the looser bound.
1148 #[test]
1149 fn fr14_on_roles_stay_readable() {
1150 let reading = [
1151 (ColorRole::Surface, ColorRole::OnSurface),
1152 (ColorRole::SurfaceVariant, ColorRole::OnSurfaceVariant),
1153 (ColorRole::Background, ColorRole::OnBackground),
1154 // The layer role has no ink of its own: it holds the page's reading ink, and
1155 // that is the promise a caller relies on when filling a panel with it.
1156 (ColorRole::SurfaceContainer, ColorRole::OnSurface),
1157 // The accent containers exist so that a paragraph can land on a tinted panel,
1158 // not just a word, so they are held to the reading bound rather than to the
1159 // looser bound their accents keep.
1160 (ColorRole::PrimaryContainer, ColorRole::OnPrimaryContainer),
1161 (
1162 ColorRole::SecondaryContainer,
1163 ColorRole::OnSecondaryContainer,
1164 ),
1165 (ColorRole::TertiaryContainer, ColorRole::OnTertiaryContainer),
1166 ];
1167 let accent = [
1168 (ColorRole::Primary, ColorRole::OnPrimary),
1169 (ColorRole::Secondary, ColorRole::OnSecondary),
1170 (ColorRole::Tertiary, ColorRole::OnTertiary),
1171 (ColorRole::Error, ColorRole::OnError),
1172 ];
1173 for table in DESIGN_TOKENS {
1174 for scheme in [ColorScheme::Light, ColorScheme::Dark] {
1175 for (base, on) in reading {
1176 let ratio = contrast(table.color(base, scheme), table.color(on, scheme));
1177 assert!(
1178 ratio >= 4.5,
1179 "{:?} {scheme:?} {base:?}/{on:?} is {ratio:.2}:1",
1180 table.system
1181 );
1182 }
1183 for (base, on) in accent {
1184 let ratio = contrast(table.color(base, scheme), table.color(on, scheme));
1185 assert!(
1186 ratio >= 3.0,
1187 "{:?} {scheme:?} {base:?}/{on:?} is {ratio:.2}:1",
1188 table.system
1189 );
1190 }
1191 }
1192 }
1193 }
1194
1195 /// No two design systems answer a whole ladder with the same values.
1196 ///
1197 /// A design system that agrees with another one on every colour, every type rung,
1198 /// every radius or every spacing step is that other system wearing a different name,
1199 /// and a caller who selected it would see no change at all. Checking each ladder
1200 /// separately is what catches a table that was copied and then edited in one place.
1201 #[test]
1202 fn fr14_no_two_design_systems_answer_a_whole_ladder_identically() {
1203 for (index, first) in DESIGN_TOKENS.iter().enumerate() {
1204 for second in &DESIGN_TOKENS[index + 1..] {
1205 assert_ne!(
1206 first.colors, second.colors,
1207 "{:?} and {:?} have the same palette",
1208 first.system, second.system
1209 );
1210 assert_ne!(
1211 first.type_scale, second.type_scale,
1212 "{:?} and {:?} have the same type scale",
1213 first.system, second.system
1214 );
1215 assert_ne!(
1216 first.shapes, second.shapes,
1217 "{:?} and {:?} round everything the same way",
1218 first.system, second.system
1219 );
1220 assert_ne!(
1221 first.spaces, second.spaces,
1222 "{:?} and {:?} have the same spacing ladder",
1223 first.system, second.system
1224 );
1225 }
1226 }
1227 }
1228
1229 /// Code stays readable on the panel it is drawn on, in every system and both schemes.
1230 ///
1231 /// Every syntax ink, comments included, holds the body-text bound on `SurfaceContainer`,
1232 /// which is what a code block is filled with. Each diff ink holds it on its own line
1233 /// background, and so does the body ink, because an unchanged word on a changed line is
1234 /// still body text. The word background has to be visible on the line background, or
1235 /// the words that changed are not marked at all.
1236 #[test]
1237 fn fr13_1_3_code_colours_hold_their_contrast_in_every_system() {
1238 use crate::contrast::{DIFF_ROLES, MIN_SEPARATION, SYNTAX_ROLES, apart};
1239 for table in DESIGN_TOKENS {
1240 for scheme in [ColorScheme::Light, ColorScheme::Dark] {
1241 let panel = table.color(ColorRole::SurfaceContainer, scheme);
1242 for role in SYNTAX_ROLES {
1243 let ratio = contrast(table.color(role, scheme), panel);
1244 assert!(
1245 ratio >= 4.5,
1246 "{:?} {scheme:?}: {role:?} on the code panel is {ratio:.2}:1",
1247 table.system
1248 );
1249 }
1250 for (ink, line, word) in DIFF_ROLES {
1251 let background = table.color(line, scheme);
1252 let ratio = contrast(table.color(ink, scheme), background);
1253 assert!(
1254 ratio >= 4.5,
1255 "{:?} {scheme:?}: {ink:?} on {line:?} is {ratio:.2}:1",
1256 table.system
1257 );
1258 let body = contrast(table.color(ColorRole::OnSurface, scheme), background);
1259 assert!(
1260 body >= 4.5,
1261 "{:?} {scheme:?}: body text on {line:?} is {body:.2}:1",
1262 table.system
1263 );
1264 let distance = apart(table.color(word, scheme), background);
1265 assert!(
1266 distance >= MIN_SEPARATION,
1267 "{:?} {scheme:?}: {word:?} is {distance} from {line:?}, so the changed \
1268 words are not marked",
1269 table.system
1270 );
1271 }
1272 }
1273 }
1274 }
1275
1276 /// The same code block does not come out in one keyword colour seven times.
1277 #[test]
1278 fn fr13_1_3_keyword_colour_differs_between_systems() {
1279 for scheme in [ColorScheme::Light, ColorScheme::Dark] {
1280 let keywords: std::collections::BTreeSet<u32> = DESIGN_TOKENS
1281 .iter()
1282 .map(|table| table.color(ColorRole::SyntaxKeyword, scheme).to_argb())
1283 .collect();
1284 assert!(
1285 keywords.len() >= 2,
1286 "every system paints keywords #{:08x} in {scheme:?}",
1287 keywords.first().copied().unwrap_or_default()
1288 );
1289 }
1290 }
1291
1292 /// The two systems without a code editor scheme hold exactly what the rule derives
1293 /// from their own base roles.
1294 ///
1295 /// One unit of slack per channel, and only that. The derivation runs through powers,
1296 /// cube roots and an arctangent, and a different maths library may round the last bit
1297 /// of one of those the other way, which can move a channel that sits on a half by one.
1298 /// Anything more than that is a value that no longer follows the rule.
1299 #[test]
1300 fn fr13_1_3_derived_code_colours_follow_the_rule() {
1301 for system in [DesignSystem::Material3, DesignSystem::Deepin] {
1302 let table = table(system);
1303 for scheme in [ColorScheme::Light, ColorScheme::Dark] {
1304 let derived = crate::code_colours::derive(table, scheme);
1305 assert_eq!(derived.len(), 22);
1306 for (role, expected) in derived {
1307 let shipped = table.color(role, scheme);
1308 let off = [16, 8, 0]
1309 .into_iter()
1310 .map(|shift| {
1311 ((shipped.to_argb() >> shift) & 0xff)
1312 .abs_diff((expected.to_argb() >> shift) & 0xff)
1313 })
1314 .max()
1315 .unwrap_or(0);
1316 assert!(
1317 off <= 1,
1318 "{system:?} {scheme:?}: {role:?} is #{:06x} in the table and the rule \
1319 gives #{:06x}",
1320 shipped.to_argb() & 0xff_ffff,
1321 expected.to_argb() & 0xff_ffff
1322 );
1323 }
1324 }
1325 }
1326 }
1327
1328 /// A syntax ink taken from a base role is that role's value where it already reads, so
1329 /// a reader can see in the table which role each one came from.
1330 #[test]
1331 fn fr13_1_3_a_derived_syntax_ink_is_its_base_role_where_that_reads() {
1332 for system in [DesignSystem::Material3, DesignSystem::Deepin] {
1333 let table = table(system);
1334 for scheme in [ColorScheme::Light, ColorScheme::Dark] {
1335 let panel = table.color(ColorRole::SurfaceContainer, scheme);
1336 for (role, from) in crate::code_colours::DERIVED_FROM {
1337 let base = table.color(from, scheme);
1338 if contrast(base, panel) >= 4.5 {
1339 assert_eq!(
1340 table.color(role, scheme),
1341 base,
1342 "{system:?} {scheme:?}: {role:?} should be {from:?}"
1343 );
1344 }
1345 }
1346 assert_eq!(
1347 table.color(ColorRole::DiffModified, scheme),
1348 table.color(ColorRole::Tertiary, scheme)
1349 );
1350 }
1351 }
1352 }
1353
1354 /// `FollowSystem` is the Renderer's call, so the Host-side lookup can only
1355 /// report the light value. This documents that, rather than leaving it a surprise.
1356 #[test]
1357 fn fr14_follow_system_reads_as_light_on_the_host() {
1358 let table = table(DesignSystem::Material3);
1359 assert_eq!(
1360 table.color(ColorRole::Surface, ColorScheme::FollowSystem),
1361 table.color(ColorRole::Surface, ColorScheme::Light)
1362 );
1363 }
1364}