Skip to main content

max / makeover

29.4 KB · 699 lines History Blame Raw
1 //! Intent resolution
2
3 use crate::{Rgb, ThemeColors, ThemeMeta, darken, lighten, readable_on};
4 use serde::Serialize;
5 use std::collections::BTreeMap;
6
7 // Names this module's prose links to, resolved for rustdoc.
8 #[allow(unused_imports)]
9 use crate::derive_tonal_steps;
10
11 /// Base intents: (TOML dotted source key, canonical token key). The token key
12 /// is the CSS-var stem (`--{token}`) and the `rgb()` lookup key.
13 ///
14 /// Read straight from the loaded theme, which is not quite the same as read
15 /// from the file: `content.secondary` and `content.muted` are tonal steps of
16 /// `content.primary` and are filled in at load by [`derive_tonal_steps`], so
17 /// they arrive here already computed and take this path like any other.
18 pub const BASE_INTENTS: &[(&str, &str)] = &[
19 ("surface.page", "surface-page"),
20 ("surface.raised", "surface-raised"),
21 ("surface.sunken", "surface-sunken"),
22 ("surface.overlay", "surface-overlay"),
23 ("content.primary", "content"),
24 ("content.secondary", "content-secondary"),
25 ("content.muted", "content-muted"),
26 ("action.primary", "action"),
27 ("status.danger", "danger"),
28 ("status.success", "success"),
29 ("status.warning", "warning"),
30 ("status.info", "info"),
31 ("line.border", "border"),
32 ("category.one", "category-one"),
33 ("category.two", "category-two"),
34 ("category.three", "category-three"),
35 ("category.four", "category-four"),
36 ("category.five", "category-five"),
37 ("category.six", "category-six"),
38 ];
39
40 /// A fully resolved intent layer: every token key → concrete `#rrggbb`.
41 /// Includes both authored base intents and the computed derived intents.
42 #[derive(Debug, Clone, Serialize)]
43 #[serde(rename_all = "camelCase")]
44 pub struct SemanticTokens {
45 pub meta: ThemeMeta,
46 /// token-key → resolved hex. Stable, deterministic ordering.
47 pub intents: BTreeMap<String, String>,
48 }
49
50 impl SemanticTokens {
51 /// Resolved hex for a token key, if present.
52 pub fn hex(&self, key: &str) -> Option<&str> {
53 self.intents.get(key).map(String::as_str)
54 }
55
56 /// Resolved RGB tuple for a token key (for egui / native consumers).
57 ///
58 /// `None` for a translucent token. Two intents are emitted as `rgba(...)`
59 /// rather than hex, `overlay` and `elevation`, and dropping the alpha would
60 /// hand a native consumer an opaque near-black where it asked for a scrim.
61 /// Those want [`rgba`](Self::rgba).
62 pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
63 self.intents
64 .get(key)
65 .and_then(|h| Rgb::from_hex(h))
66 .map(Rgb::tuple)
67 }
68
69 /// Resolved RGBA tuple for a token key, alpha as 0-255.
70 ///
71 /// Reads both spellings, so a caller that does not care whether an intent
72 /// happens to be translucent can use this for everything: an opaque token
73 /// comes back at 255.
74 ///
75 /// It exists because a CSS consumer can take `rgba(...)` as a string
76 /// straight out of [`hex`](Self::hex) and a native one cannot. Without it
77 /// the two translucent intents are reachable from a stylesheet and from
78 /// nowhere else, which is the coupling deriving in the crate was meant to
79 /// avoid.
80 pub fn rgba(&self, key: &str) -> Option<(u8, u8, u8, u8)> {
81 let value = self.intents.get(key)?;
82 if let Some(rgb) = Rgb::from_hex(value) {
83 let (r, g, b) = rgb.tuple();
84 return Some((r, g, b, 255));
85 }
86 let inner = value.strip_prefix("rgba(")?.strip_suffix(')')?;
87 let mut parts = inner.split(',').map(str::trim);
88 let r = parts.next()?.parse().ok()?;
89 let g = parts.next()?.parse().ok()?;
90 let b = parts.next()?.parse().ok()?;
91 let alpha: f32 = parts.next()?.parse().ok()?;
92 if parts.next().is_some() || !(0.0..=1.0).contains(&alpha) {
93 return None;
94 }
95 Some((r, g, b, (alpha * 255.0).round() as u8))
96 }
97 }
98
99 /// Resolve an authored theme into the full intent token set.
100 ///
101 /// 1. Copy each present base intent from the authored colors.
102 /// 2. Compute the derived interactive states from the base intents, so every
103 /// consumer gets identical output.
104 ///
105 /// Each derived token is emitted only when its source intents exist, mirroring
106 /// the skip-missing behavior of the rest of the crate.
107 pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
108 let mut intents: BTreeMap<String, String> = BTreeMap::new();
109
110 // 1. Base intents (authored). Copy only values that parse as a hex color and
111 // re-emit them in canonical `#rrggbb` form, so an authored value can never
112 // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined
113 // raw into a `<style>` block by the web server). A malformed value is skipped,
114 // mirroring the skip-missing behavior for absent intents.
115 for (src, token) in BASE_INTENTS {
116 if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
117 intents.insert((*token).to_string(), rgb.to_hex());
118 }
119 }
120
121 // Helper: parse an already-resolved token to Rgb.
122 let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
123
124 // 2. Derived intents — perceptual (OKLab) steps + WCAG-picked text.
125 // Lightness deltas are in OKLab L units; mix ratios interpolate in OKLab.
126 let mut derived: Vec<(String, Rgb)> = Vec::new();
127 if let Some(action) = get(&intents, "action") {
128 derived.push(("action-hover".into(), lighten(action, 0.05)));
129 derived.push(("content-on-action".into(), readable_on(action)));
130 // The focus ring is the action colour itself, not a tint of it: a ring
131 // is a statement that the keyboard is here, and a faded one reads as a
132 // disabled control rather than an emphatic one.
133 //
134 // One ring, not one per primitive. Where the ring sits is a depth
135 // question and not a per-component choice: a well takes it inside its
136 // own edge and a raised surface takes it outside. That is one decision
137 // with two renderings rather than one decision per component, which is
138 // how the three apps ended up with three rings. This token is the one
139 // shared artifact; which thing wears it, and how it is drawn, is each
140 // renderer's own (see `makeover_layout`'s crate header, "reach, focus
141 // and the focus ring").
142 derived.push(("focus-ring".into(), action));
143 }
144 if let Some(page) = get(&intents, "surface-page") {
145 // Modal scrim: a near-black tone carrying a faint hint of the theme's
146 // hue, at 50% alpha. Anchored very dark (OKLab L=0.08) so it dims the
147 // page on light *and* dark themes. Emitted as rgba (not a flat hex), so
148 // it is inserted directly rather than through the hex loop below.
149 let mut o = page.to_oklab();
150 o.l = 0.08;
151 let s = Rgb::from_oklab(o);
152 intents.insert(
153 "overlay".into(),
154 format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
155 );
156
157 // What a surface that FLOATS OVER the page is cast onto it with.
158 //
159 // The one intent here about a surface's relationship to the page rather
160 // than about the surface itself, which is why it is derived from `page`
161 // and not from `surface-raised`. A shadow is not the thing, it is the
162 // absence of light on what is behind the thing.
163 //
164 // SCOPE, and it is the whole point of this intent existing rather than
165 // a general "shadow": a surface that overlays the page takes this, a
166 // surface IN the page takes a bevel. Menus, toasts, popovers and
167 // dropdowns overlay. A card, a plate and a framed image do not, and
168 // reaching for this on one of those is how a pre-Platinum look survives
169 // a conversion wearing a token's name. `.raised` is the answer there.
170 //
171 // Same anchor as the scrim above and for the same reason: a tone read
172 // off the theme's hue but pinned very dark, so it reads as absence of
173 // light on a light theme and on a dark one alike. A shadow tinted to a
174 // dark theme's own lightness would not be a shadow.
175 //
176 // The alpha is the only number here that is a look decision rather than
177 // a derivation. 0.18 sits between the two literal scales it replaces:
178 // the MNW server's --shadow-2 (0.10) reads as nothing under a menu, and
179 // its --shadow-3 (0.15) was measured invisible at plate size. Geometry
180 // stays with the consumer, the way bevel thickness does.
181 intents.insert(
182 "elevation".into(),
183 format!("rgba({}, {}, {}, 0.18)", s.r, s.g, s.b),
184 );
185 }
186 if let Some(raised) = get(&intents, "surface-raised") {
187 // The two edges of a bevel: a raised control is lit from the top left,
188 // so its top and left edges take `bevel-light` and its bottom and right
189 // edges `bevel-dark`. Inverting the pair gives a pressed state and an
190 // inset well, which is what makes the idiom cheap for a consumer.
191 //
192 // Derived here rather than composed per-app because the two webviews
193 // could do it in `color-mix()` and audiofiles, which is egui, could not.
194 // Geometry (thickness, radius, which side gets which) stays app-side.
195 //
196 // The deltas are asymmetric because the eye is: an equal step down reads
197 // as a smaller change than the same step up, so the shadow is cut deeper
198 // than the highlight is raised.
199 //
200 // A face already at the top of the ramp cannot hold a highlight — the
201 // lightening clamps and the control bevels on two sides without ever
202 // resolving as lit. That is a property of the theme, not of this
203 // derivation; `bevel_edges_are_distinct_from_their_face` names the
204 // shipped themes it currently bites.
205 derived.push(("bevel-light".into(), lighten(raised, 0.14)));
206 derived.push(("bevel-dark".into(), darken(raised, 0.18)));
207
208 // An inset well: the content surface inside a raised container, so a
209 // list reads as content in a container rather than as bands on a panel.
210 // `surface-sunken` cannot serve, because a theme is free to author it
211 // darker than raised (goingson does) and a well has to go the other way.
212 //
213 // Which way is "the other way" depends on the theme, and this is the one
214 // derivation here that inverts. A well is lighter than its face on a
215 // light theme and darker on a dark one, where the bevel pair sidesteps
216 // the question by emitting both directions at once.
217 //
218 // Read the direction off `content` rather than off `Variant`. A theme
219 // whose text is dark is a theme whose surfaces are light, whatever its
220 // `variant` field claims, so this resolves correctly even when that
221 // field is wrong and it keeps the branch on measured color rather than
222 // on metadata.
223 //
224 // Deltas are asymmetric for the same reason the bevel's are, and smaller
225 // than the bevel's because a well is an area rather than an edge. The
226 // step up is the specimen's, measured: #D9DDF4 to #F3F5FD is 0.069.
227 //
228 // A face at the top of its ramp cannot hold a lighter well, the same
229 // clamp `bevel-light` hits; `well_is_visible_against_its_face` names the
230 // shipped themes where it bites.
231 if let Some(content) = get(&intents, "content") {
232 let content_is_darker = content.to_oklab().l < raised.to_oklab().l;
233 let well = if content_is_darker {
234 lighten(raised, 0.07)
235 } else {
236 darken(raised, 0.09)
237 };
238 derived.push(("surface-well".into(), well));
239 }
240 }
241 if let Some(sunken) = get(&intents, "surface-sunken") {
242 derived.push(("hover-surface".into(), sunken));
243 }
244 if let Some(border) = get(&intents, "border") {
245 derived.push(("border-strong".into(), darken(border, 0.05)));
246 }
247
248 for (token, rgb) in derived {
249 intents.insert(token, rgb.to_hex());
250 }
251
252 SemanticTokens {
253 meta: theme.meta.clone(),
254 intents,
255 }
256 }
257
258 /// Emit the resolved intent layer as CSS declarations (no selector), one
259 /// ` --token: #hex;` line each, in deterministic (BTreeMap) order.
260 pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
261 let mut out = String::new();
262 for (token, hex) in &tokens.intents {
263 out.push_str(" --");
264 out.push_str(token);
265 out.push_str(": ");
266 out.push_str(hex);
267 out.push_str(";\n");
268 }
269 out
270 }
271
272 /// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
273 /// CSS mapping every web surface injects.
274 pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
275 format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
276 }
277
278 #[cfg(test)]
279 mod tests {
280 use super::*;
281 use crate::fixture::nord_toml;
282 use crate::{Emphasis, embedded_themes, emphasized, parse_theme_str};
283
284 #[test]
285 fn resolve_base_intents_passthrough() {
286 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
287 let t = resolve(&theme);
288 assert_eq!(t.hex("surface-page"), Some("#2e3440"));
289 assert_eq!(t.hex("content"), Some("#d8dee9")); // content.primary -> content
290 // Not a passthrough: a tonal step of the ink, whatever the file said.
291 assert_eq!(
292 t.hex("content-muted").unwrap(),
293 emphasized(
294 Rgb::from_hex("#d8dee9").unwrap(),
295 Rgb::from_hex("#2e3440").unwrap(),
296 Emphasis::Muted
297 )
298 .to_hex()
299 );
300 assert_eq!(t.hex("action"), Some("#81a1c1"));
301 assert_eq!(t.hex("danger"), Some("#bf616a"));
302 assert_eq!(t.hex("border"), Some("#4c566a"));
303 assert_eq!(t.hex("category-five"), Some("#b48ead"));
304 }
305
306 #[test]
307 fn resolve_derived_intents() {
308 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
309 let t = resolve(&theme);
310 let action = Rgb::from_hex("#81a1c1").unwrap();
311 let page = Rgb::from_hex("#2e3440").unwrap();
312 let _ = page;
313 assert_eq!(
314 t.hex("action-hover").unwrap(),
315 lighten(action, 0.05).to_hex()
316 );
317 assert_eq!(
318 t.hex("content-on-action").unwrap(),
319 readable_on(action).to_hex()
320 );
321 assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
322 assert_eq!(t.hex("hover-surface"), Some("#434c5e")); // = surface.sunken
323 // Pruned by the usage audit (0 consumers): action-active, the *-surface
324 // tints, selection, row-stripe. Apps that need them derive inline via
325 // the shared mix().
326 assert!(t.hex("action-active").is_none());
327 assert!(t.hex("danger-surface").is_none());
328 assert!(t.hex("selection").is_none());
329 assert!(t.hex("row-stripe").is_none());
330 }
331
332 #[test]
333 fn resolve_bevel_intents() {
334 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
335 let t = resolve(&theme);
336 let raised = Rgb::from_hex("#3b4252").unwrap();
337 assert_eq!(
338 t.hex("bevel-light").unwrap(),
339 lighten(raised, 0.14).to_hex()
340 );
341 assert_eq!(t.hex("bevel-dark").unwrap(), darken(raised, 0.18).to_hex());
342 }
343
344 // A bevel is two edges around one face, so both edges have to be visibly off
345 // that face or the control never resolves as lit. The lightening clamps at
346 // the top of the ramp, which means a theme authoring a white raised surface
347 // gets a highlight identical to the surface it is meant to sit on.
348 //
349 // The list is asserted rather than merely reported so that changing a theme
350 // has to come here and say so. Shrinking it is the fix; growing it is a
351 // regression in the theme, not in this derivation.
352 #[test]
353 fn bevel_edges_are_distinct_from_their_face() {
354 const CANNOT_BEVEL: &[&str] = &["neobrute", "oxocarbon-light"];
355
356 let mut degenerate: Vec<String> = Vec::new();
357 for (id, source) in embedded_themes() {
358 let theme = parse_theme_str(id, source, false).unwrap();
359 let t = resolve(&theme);
360 let Some(raised) = t.hex("surface-raised") else {
361 continue;
362 };
363 let light = t.hex("bevel-light").expect("raised implies bevel-light");
364 let dark = t.hex("bevel-dark").expect("raised implies bevel-dark");
365 if light == raised || dark == raised {
366 degenerate.push(id.to_string());
367 }
368 }
369 degenerate.sort();
370
371 assert_eq!(
372 degenerate, CANNOT_BEVEL,
373 "themes whose raised surface cannot hold both bevel edges"
374 );
375 }
376
377 // The well inverts by theme, so assert both directions explicitly rather
378 // than only the one the light themes happen to take.
379 #[test]
380 fn resolve_well_intent_follows_the_content_direction() {
381 // nord is dark: light text on a dark raised surface, so the well goes
382 // down and away from the text.
383 let dark = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
384 let dark_raised = Rgb::from_hex("#3b4252").unwrap();
385 assert_eq!(
386 dark.hex("surface-well").unwrap(),
387 darken(dark_raised, 0.09).to_hex()
388 );
389
390 // The shipped light themes take the other branch.
391 let goingson = embedded_themes()
392 .into_iter()
393 .find(|(id, _)| *id == "goingson")
394 .expect("goingson is embedded")
395 .1;
396 let light = resolve(&parse_theme_str("goingson", goingson, false).unwrap());
397 let light_raised = light
398 .hex("surface-raised")
399 .and_then(Rgb::from_hex)
400 .expect("goingson authors a raised surface");
401 assert_eq!(
402 light.hex("surface-well").unwrap(),
403 lighten(light_raised, 0.07).to_hex()
404 );
405 }
406
407 // A well is a fill, not an edge, so the only thing that makes it read is
408 // being a different color from the surface it is cut into.
409 //
410 // Same shape and the same asserted-list discipline as
411 // `bevel_edges_are_distinct_from_their_face`, and it bites the same two
412 // themes for the same reason: a raised surface already at the top of the
413 // ramp has nothing lighter to go to.
414 #[test]
415 fn well_is_distinct_from_its_face() {
416 const CANNOT_WELL: &[&str] = &["neobrute", "oxocarbon-light"];
417
418 let mut degenerate: Vec<String> = Vec::new();
419 for (id, source) in embedded_themes() {
420 let theme = parse_theme_str(id, source, false).unwrap();
421 let t = resolve(&theme);
422 let Some(raised) = t.hex("surface-raised") else {
423 continue;
424 };
425 let well = t.hex("surface-well").expect("raised implies surface-well");
426 if well == raised {
427 degenerate.push(id.to_string());
428 }
429 }
430 degenerate.sort();
431
432 assert_eq!(
433 degenerate, CANNOT_WELL,
434 "themes whose raised surface cannot hold a well"
435 );
436 }
437
438 // Distinct is not the same as visible. A face near the top of the ramp
439 // clamps partway rather than exactly, which yields a well that differs from
440 // its face by a hex digit and by nothing the eye can find. `rosepine-dawn`
441 // authors raised at L=0.987 and gets 0.009 of the 0.07 it asked for.
442 //
443 // Worth a separate test from the one above because the fix differs: an
444 // exactly-degenerate theme needs its raised surface off the ramp end, while
445 // these need it merely lowered. Both fixes are the theme's, not this
446 // derivation's, which is why the list is asserted rather than warned about.
447 #[test]
448 fn well_is_visible_against_its_face() {
449 // Below this, the well and its face are the same surface to a reader.
450 const MIN_DELTA_L: f32 = 0.02;
451 const CANNOT_HOLD_A_VISIBLE_WELL: &[&str] =
452 &["neobrute", "oxocarbon-light", "rosepine-dawn"];
453
454 let mut invisible: Vec<String> = Vec::new();
455 for (id, source) in embedded_themes() {
456 let theme = parse_theme_str(id, source, false).unwrap();
457 let t = resolve(&theme);
458 let (Some(raised), Some(well)) = (
459 t.hex("surface-raised").and_then(Rgb::from_hex),
460 t.hex("surface-well").and_then(Rgb::from_hex),
461 ) else {
462 continue;
463 };
464 if (well.to_oklab().l - raised.to_oklab().l).abs() < MIN_DELTA_L {
465 invisible.push(id.to_string());
466 }
467 }
468 invisible.sort();
469
470 assert_eq!(
471 invisible, CANNOT_HOLD_A_VISIBLE_WELL,
472 "themes whose well is too close to its face to read as one"
473 );
474 }
475
476 // The three tests above each measure a derived color against the face it was
477 // derived from, so a theme can pass all of them and still have nothing lift
478 // off anything: the face itself sits on the page, and that relationship is
479 // the one a bevel needs in order to read as an object rather than as a
480 // rectangle with decorated edges. makenot.work passed all three and could
481 // not hold a bevel, which is what this covers.
482 //
483 // The threshold is picked against the ramps already ruled on rather than
484 // against a round number. makenot.work shipped at 0.024 and was invisible,
485 // was tried at 0.036 and rejected as marginal on badges and chips, and was
486 // accepted at 0.058; goingson and audiofiles sit at 0.119 and 0.065. Every
487 // ramp judged inadequate is below 0.036 and every one judged adequate is
488 // above 0.058, so the line goes in the gap between them. Note the unit: this
489 // is oklab L on 0 to 1, not the CIE L* on 0 to 100 that the theme files quote
490 // in their comments, and the two are not interchangeable.
491 //
492 // Most of the list is imported palettes, which were authored for syntax
493 // highlighting and owe our depth model nothing. Failing here says a theme
494 // cannot hold a bevel, not that it is wrong. Shrinking the list is the fix;
495 // growing it is a regression in the theme, not in this derivation.
496 //
497 // An entry leaves this list only when the fix is upstream's own, never a
498 // color we picked. The remaining entries are shallow ramps in published
499 // palettes, deferred until every app is migrated and eyeballed.
500 #[test]
501 fn raised_is_distinct_from_page() {
502 // Below this, a raised surface and the page under it are one surface to
503 // a reader, whichever direction the theme ramps in.
504 const MIN_DELTA_L: f32 = 0.05;
505 const CANNOT_LIFT_OFF_THE_PAGE: &[&str] = &[
506 "akari-dawn",
507 "akari-night",
508 "ayu-light",
509 "ayu-mirage",
510 "catppuccin-latte",
511 "catppuccin-mocha",
512 "dawnfox",
513 "dracula",
514 "everforest",
515 "flatwhite",
516 "gruvbox-light",
517 "neobrute",
518 "one-dark",
519 "oxocarbon-dark",
520 "oxocarbon-light",
521 "poimandres",
522 "rosepine",
523 "rosepine-dawn",
524 "solarized-dark",
525 ];
526
527 let mut flat: Vec<String> = Vec::new();
528 for (id, source) in embedded_themes() {
529 let theme = parse_theme_str(id, source, false).unwrap();
530 let t = resolve(&theme);
531 let (Some(page), Some(raised)) = (
532 t.hex("surface-page").and_then(Rgb::from_hex),
533 t.hex("surface-raised").and_then(Rgb::from_hex),
534 ) else {
535 continue;
536 };
537 if (raised.to_oklab().l - page.to_oklab().l).abs() < MIN_DELTA_L {
538 flat.push(id.to_string());
539 }
540 }
541 flat.sort();
542
543 assert_eq!(
544 flat, CANNOT_LIFT_OFF_THE_PAGE,
545 "themes whose raised surface is too close to the page to lift off it"
546 );
547 }
548
549 #[test]
550 fn resolve_overlay_is_dark_translucent_scrim() {
551 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
552 let t = resolve(&theme);
553 let overlay = t.hex("overlay").unwrap();
554 assert!(
555 overlay.starts_with("rgba("),
556 "overlay is translucent: {overlay}"
557 );
558 assert!(overlay.ends_with(", 0.5)"));
559 // The scrim tone is anchored very dark regardless of theme.
560 let inner = overlay
561 .trim_start_matches("rgba(")
562 .trim_end_matches(", 0.5)");
563 let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
564 let scrim = Rgb {
565 r: parts[0],
566 g: parts[1],
567 b: parts[2],
568 };
569 assert!(scrim.to_oklab().l < 0.2, "scrim must be near-black");
570 }
571
572 /// Every shipped theme derives it, on both polarities, and it is always a
573 /// near-black translucent tone. A shadow tinted to a dark theme's own
574 /// lightness would not read as one.
575 #[test]
576 fn elevation_is_a_near_black_cast_on_every_theme() {
577 for (id, source) in embedded_themes() {
578 let theme = parse_theme_str(id, source, false).unwrap();
579 let t = resolve(&theme);
580 let Some(elevation) = t.hex("elevation") else {
581 panic!("{id} derives no elevation");
582 };
583 assert!(
584 elevation.starts_with("rgba(") && elevation.ends_with(", 0.18)"),
585 "{id}: elevation is translucent: {elevation}"
586 );
587 let inner = elevation
588 .trim_start_matches("rgba(")
589 .trim_end_matches(", 0.18)");
590 let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
591 let cast = Rgb {
592 r: parts[0],
593 g: parts[1],
594 b: parts[2],
595 };
596 assert!(
597 cast.to_oklab().l < 0.2,
598 "{id}: a cast shadow must be near-black, got {elevation}"
599 );
600 }
601 }
602
603 /// The scrim and the cast share an anchor and differ only in weight. Stated
604 /// as a test because the two are easy to drift apart, and a scrim that
605 /// stopped matching the shadow under the thing it dims would show.
606 #[test]
607 fn elevation_and_the_scrim_are_the_same_tone() {
608 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
609 let t = resolve(&theme);
610 let scrim = t.hex("overlay").unwrap();
611 let cast = t.hex("elevation").unwrap();
612 assert_eq!(
613 scrim.trim_end_matches(", 0.5)"),
614 cast.trim_end_matches(", 0.18)"),
615 );
616 }
617
618 /// The accessor that makes a translucent intent reachable from something
619 /// that is not a stylesheet. Both spellings, and an opaque token answers
620 /// 255 so a caller need not know which kind it asked for.
621 #[test]
622 fn rgba_reads_both_spellings() {
623 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
624 let t = resolve(&theme);
625
626 let (_, _, _, opaque) = t.rgba("surface-page").expect("page is a hex token");
627 assert_eq!(opaque, 255);
628
629 let (r, g, b, alpha) = t.rgba("elevation").expect("elevation is translucent");
630 assert_eq!(alpha, 46, "0.18 of 255");
631 assert_eq!(t.rgb("elevation"), None, "rgb declines to drop the alpha");
632
633 let (sr, sg, sb, scrim) = t.rgba("overlay").expect("overlay is translucent");
634 assert_eq!((sr, sg, sb), (r, g, b), "one tone, two weights");
635 assert_eq!(scrim, 128);
636 }
637
638 #[test]
639 fn resolve_drops_non_hex_base_intent() {
640 // A base intent that isn't a hex color must never reach the resolved
641 // token set (it would otherwise be inlined verbatim into a <style>
642 // block). Skipped like a missing intent; valid siblings survive.
643 let theme = parse_theme_str(
644 "x",
645 "[surface]\npage = \"</style><script>alert(1)</script>\"\n[content]\nprimary = \"#111111\"\n",
646 false,
647 )
648 .unwrap();
649 let t = resolve(&theme);
650 assert!(
651 t.hex("surface-page").is_none(),
652 "non-hex base intent leaked"
653 );
654 assert_eq!(t.hex("content").unwrap(), "#111111");
655 // The injected markup appears in no resolved value.
656 assert!(!t.intents.values().any(|v| v.contains('<')));
657 }
658
659 #[test]
660 fn resolve_skips_derived_when_source_missing() {
661 // No [action] => no action-derived tokens.
662 let theme = parse_theme_str(
663 "x",
664 "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n",
665 false,
666 )
667 .unwrap();
668 let t = resolve(&theme);
669 assert!(t.hex("action").is_none());
670 assert!(t.hex("action-hover").is_none());
671 assert!(t.hex("selection").is_none());
672 assert_eq!(
673 t.hex("border-strong").unwrap(),
674 darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex()
675 );
676 }
677
678 #[test]
679 fn rgb_accessor_for_native_consumers() {
680 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
681 let t = resolve(&theme);
682 assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1)));
683 assert_eq!(t.rgb("nonexistent"), None);
684 }
685
686 // ---- css emit ----
687
688 #[test]
689 fn intent_css_vars_wraps_root_and_includes_tokens() {
690 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
691 let css = intent_css_vars(&resolve(&theme));
692 assert!(css.starts_with(":root {\n"));
693 assert!(css.contains(" --surface-page: #2e3440;\n"));
694 assert!(css.contains(" --danger: #bf616a;\n"));
695 assert!(css.contains(" --action-hover: "));
696 assert!(css.trim_end().ends_with('}'));
697 }
698 }
699