makeover 3.8.1

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
//! Intent resolution

use crate::{Rgb, ThemeColors, ThemeMeta, darken, lighten, mix, readable_on};
use serde::Serialize;
use std::collections::BTreeMap;

// Names this module's prose links to, resolved for rustdoc.
#[allow(unused_imports)]
use crate::derive_tonal_steps;

/// Base intents: (TOML dotted source key, canonical token key). The token key
/// is the CSS-var stem (`--{token}`) and the `rgb()` lookup key.
///
/// Read straight from the loaded theme, which is not quite the same as read
/// from the file: `content.secondary` and `content.muted` are tonal steps of
/// `content.primary` and are filled in at load by [`derive_tonal_steps`], so
/// they arrive here already computed and take this path like any other.
pub const BASE_INTENTS: &[(&str, &str)] = &[
    ("surface.page", "surface-page"),
    ("surface.raised", "surface-raised"),
    ("surface.sunken", "surface-sunken"),
    ("surface.overlay", "surface-overlay"),
    ("content.primary", "content"),
    ("content.secondary", "content-secondary"),
    ("content.muted", "content-muted"),
    ("action.primary", "action"),
    ("status.danger", "danger"),
    ("status.success", "success"),
    ("status.warning", "warning"),
    ("status.info", "info"),
    ("line.border", "border"),
    ("category.one", "category-one"),
    ("category.two", "category-two"),
    ("category.three", "category-three"),
    ("category.four", "category-four"),
    ("category.five", "category-five"),
    ("category.six", "category-six"),
];

/// A fully resolved intent layer: every token key → concrete `#rrggbb`.
/// Includes both authored base intents and the computed derived intents.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SemanticTokens {
    pub meta: ThemeMeta,
    /// token-key → resolved hex. Stable, deterministic ordering.
    pub intents: BTreeMap<String, String>,
}

impl SemanticTokens {
    /// Resolved hex for a token key, if present.
    pub fn hex(&self, key: &str) -> Option<&str> {
        self.intents.get(key).map(String::as_str)
    }

    /// Resolved RGB tuple for a token key (for egui / native consumers).
    ///
    /// `None` for a translucent token. Two intents are emitted as `rgba(...)`
    /// rather than hex, `overlay` and `elevation`, and dropping the alpha would
    /// hand a native consumer an opaque near-black where it asked for a scrim.
    /// Those want [`rgba`](Self::rgba).
    pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
        self.intents
            .get(key)
            .and_then(|h| Rgb::from_hex(h))
            .map(Rgb::tuple)
    }

    /// Resolved RGBA tuple for a token key, alpha as 0-255.
    ///
    /// Reads both spellings, so a caller that does not care whether an intent
    /// happens to be translucent can use this for everything: an opaque token
    /// comes back at 255.
    ///
    /// It exists because a CSS consumer can take `rgba(...)` as a string
    /// straight out of [`hex`](Self::hex) and a native one cannot. Without it
    /// the two translucent intents are reachable from a stylesheet and from
    /// nowhere else, which is the coupling deriving in the crate was meant to
    /// avoid.
    pub fn rgba(&self, key: &str) -> Option<(u8, u8, u8, u8)> {
        let value = self.intents.get(key)?;
        if let Some(rgb) = Rgb::from_hex(value) {
            let (r, g, b) = rgb.tuple();
            return Some((r, g, b, 255));
        }
        let inner = value.strip_prefix("rgba(")?.strip_suffix(')')?;
        let mut parts = inner.split(',').map(str::trim);
        let r = parts.next()?.parse().ok()?;
        let g = parts.next()?.parse().ok()?;
        let b = parts.next()?.parse().ok()?;
        let alpha: f32 = parts.next()?.parse().ok()?;
        if parts.next().is_some() || !(0.0..=1.0).contains(&alpha) {
            return None;
        }
        Some((r, g, b, (alpha * 255.0).round() as u8))
    }
}

/// Resolve an authored theme into the full intent token set.
///
/// 1. Copy each present base intent from the authored colors.
/// 2. Compute the derived interactive states from the base intents, so every
///    consumer gets identical output.
///
/// Each derived token is emitted only when its source intents exist, mirroring
/// the skip-missing behavior of the rest of the crate.
pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
    let mut intents: BTreeMap<String, String> = BTreeMap::new();

    // 1. Base intents (authored). Copy only values that parse as a hex color and
    // re-emit them in canonical `#rrggbb` form, so an authored value can never
    // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined
    // raw into a `<style>` block by the web server). A malformed value is skipped,
    // mirroring the skip-missing behavior for absent intents.
    for (src, token) in BASE_INTENTS {
        if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
            intents.insert((*token).to_string(), rgb.to_hex());
        }
    }

    // Helper: parse an already-resolved token to Rgb.
    let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));

    // 2. Derived intents — perceptual (OKLab) steps + WCAG-picked text.
    // Lightness deltas are in OKLab L units; mix ratios interpolate in OKLab.
    let mut derived: Vec<(String, Rgb)> = Vec::new();
    if let Some(action) = get(&intents, "action") {
        derived.push(("action-hover".into(), lighten(action, 0.05)));
        derived.push(("content-on-action".into(), readable_on(action)));
        // The focus ring is the action colour itself, not a tint of it: a ring
        // is a statement that the keyboard is here, and a faded one reads as a
        // disabled control rather than an emphatic one.
        //
        // One ring, not one per primitive. Where the ring sits is a depth
        // question and not a per-component choice: a well takes it inside its
        // own edge and a raised surface takes it outside. That is one decision
        // with two renderings rather than one decision per component, which is
        // how the three apps ended up with three rings. This token is the one
        // shared artifact; which thing wears it, and how it is drawn, is each
        // renderer's own (see `makeover_layout`'s crate header, "reach, focus
        // and the focus ring").
        derived.push(("focus-ring".into(), action));
    }
    if let Some(page) = get(&intents, "surface-page") {
        // Modal scrim: a near-black tone carrying a faint hint of the theme's
        // hue, at 50% alpha. Anchored very dark (OKLab L=0.08) so it dims the
        // page on light *and* dark themes. Emitted as rgba (not a flat hex), so
        // it is inserted directly rather than through the hex loop below.
        let mut o = page.to_oklab();
        o.l = 0.08;
        let s = Rgb::from_oklab(o);
        intents.insert(
            "overlay".into(),
            format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
        );

        // What a surface that FLOATS OVER the page is cast onto it with.
        //
        // The one intent here about a surface's relationship to the page rather
        // than about the surface itself, which is why it is derived from `page`
        // and not from `surface-raised`. A shadow is not the thing, it is the
        // absence of light on what is behind the thing.
        //
        // SCOPE, and it is the whole point of this intent existing rather than
        // a general "shadow": a surface that overlays the page takes this, a
        // surface IN the page takes a bevel. Menus, toasts, popovers and
        // dropdowns overlay. A card, a plate and a framed image do not, and
        // reaching for this on one of those is how a pre-Platinum look survives
        // a conversion wearing a token's name. `.raised` is the answer there.
        //
        // Same anchor as the scrim above and for the same reason: a tone read
        // off the theme's hue but pinned very dark, so it reads as absence of
        // light on a light theme and on a dark one alike. A shadow tinted to a
        // dark theme's own lightness would not be a shadow.
        //
        // The alpha is the only number here that is a look decision rather than
        // a derivation. 0.18 sits between the two literal scales it replaces:
        // the MNW server's --shadow-2 (0.10) reads as nothing under a menu, and
        // its --shadow-3 (0.15) was measured invisible at plate size. Geometry
        // stays with the consumer, the way bevel thickness does.
        intents.insert(
            "elevation".into(),
            format!("rgba({}, {}, {}, 0.18)", s.r, s.g, s.b),
        );
    }
    if let Some(raised) = get(&intents, "surface-raised") {
        // The two edges of a bevel: a raised control is lit from the top left,
        // so its top and left edges take `bevel-light` and its bottom and right
        // edges `bevel-dark`. Inverting the pair gives a pressed state and an
        // inset well, which is what makes the idiom cheap for a consumer.
        //
        // Derived here rather than composed per-app because the two webviews
        // could do it in `color-mix()` and audiofiles, which is egui, could not.
        // Geometry (thickness, radius, which side gets which) stays app-side.
        //
        // The deltas are asymmetric because the eye is: an equal step down reads
        // as a smaller change than the same step up, so the shadow is cut deeper
        // than the highlight is raised.
        //
        // A face already at the top of the ramp cannot hold a highlight — the
        // lightening clamps and the control bevels on two sides without ever
        // resolving as lit. That is a property of the theme, not of this
        // derivation; `bevel_edges_are_distinct_from_their_face` names the
        // shipped themes it currently bites.
        derived.push(("bevel-light".into(), lighten(raised, 0.14)));
        derived.push(("bevel-dark".into(), darken(raised, 0.18)));

        // An inset well: the content surface inside a raised container, so a
        // list reads as content in a container rather than as bands on a panel.
        // `surface-sunken` cannot serve, because a theme is free to author it
        // darker than raised (goingson does) and a well has to go the other way.
        //
        // Which way is "the other way" depends on the theme, and this is the one
        // derivation here that inverts. A well is lighter than its face on a
        // light theme and darker on a dark one, where the bevel pair sidesteps
        // the question by emitting both directions at once.
        //
        // Read the direction off `content` rather than off `Variant`. A theme
        // whose text is dark is a theme whose surfaces are light, whatever its
        // `variant` field claims, so this resolves correctly even when that
        // field is wrong and it keeps the branch on measured color rather than
        // on metadata.
        //
        // Deltas are asymmetric for the same reason the bevel's are, and smaller
        // than the bevel's because a well is an area rather than an edge. The
        // step up is the specimen's, measured: #D9DDF4 to #F3F5FD is 0.069.
        //
        // A face near the top of its ramp has no room for a lighter well. The
        // step clamps at the ramp's end, which is exactly where `bevel-light`
        // clamps too, so on most light themes the well and the lit edge were
        // one colour. A well exists only to differ from its face, so when the
        // requested direction has no room it goes the other way. `bevel-light`
        // does not get the same fallback: a highlight darker than its face is a
        // second shadow, not a highlight.
        if let Some(content) = get(&intents, "content") {
            let content_is_darker = content.to_oklab().l < raised.to_oklab().l;
            let well = if content_is_darker {
                step_with_room(raised, 0.07)
            } else {
                step_with_room(raised, -0.09)
            };
            derived.push(("surface-well".into(), well));

            // A status chip's fill, wiki `table-model`: the table's raised ground
            // with 12 percent of the tone in it, edged in the tone, inked in
            // `content`. Not the tone as text, which measured 1.34:1 on
            // goingson's warning. The raised ground rather than the well, since
            // the well's direction flips on a face with no room for it.
            for tone in ["info", "success", "warning", "danger"] {
                if let Some(color) = get(&intents, tone) {
                    derived.push((format!("{tone}-surface"), mix(raised, color, 0.12)));
                }
            }

            // The same fill for a toned surface a pointer is over. A badge
            // never needed one because a badge is not pressable; a button is,
            // and a toned button drawn only from `{tone}-surface` sits at
            // higher specificity than the plain `:hover` rule, so it answers a
            // pointer with nothing at all.
            //
            // Built the way `hover-surface` is -- off the sunken ground rather
            // than the raised one -- and then tinted at the same 12 percent, so
            // a toned button and a plain one travel the same distance under the
            // pointer and stay the same tone while they do it.
            if let Some(sunken) = get(&intents, "surface-sunken") {
                for tone in ["info", "success", "warning", "danger"] {
                    if let Some(color) = get(&intents, tone) {
                        derived.push((format!("{tone}-surface-hover"), mix(sunken, color, 0.12)));
                    }
                }
            }

            // The table model's three row tones, wiki `table-model`: a 5 percent
            // stripe on alternate rows, a 9 percent hover, and a hairline between
            // rows at 26 percent. Each is the theme's ink mixed into the raised
            // surface a table sits on, which is the specimen Max picked (C)
            // measured back to its ratios.
            //
            // Toward `content` rather than darker, for the reason `surface-well`
            // reads its direction off `content`: a row tone is a step toward the
            // text it sits under, which is darker on a light theme and lighter
            // on a dark one without asking which theme this is. The stripe and
            // hairline stay under the 3:1 that separates two areas on purpose,
            // since a row is not a region.
            derived.push(("row-stripe".into(), mix(raised, content, 0.05)));
            derived.push(("row-hover".into(), mix(raised, content, 0.09)));
            derived.push(("row-rule".into(), mix(raised, content, 0.26)));

            // The row a keyboard cursor is on, for a host that has one: the
            // terminal and immediate-mode renderers. A browser has a hover and
            // no cursor row, so the webview draws none. The accent rather than
            // the ink, since a cursor has to read over the stripe at a glance
            // and a stronger ink step would read as a heavier stripe. A fill
            // and never a foreground, so a failed row stays red under it. 18
            // percent is the most that keeps content at the text floor on
            // every theme whose raised surface clears it (flatwhite sets it).
            if let Some(action) = get(&intents, "action") {
                derived.push(("row-selected".into(), mix(raised, action, 0.18)));
            }
        }
    }
    if let Some(sunken) = get(&intents, "surface-sunken") {
        derived.push(("hover-surface".into(), sunken));
    }
    if let Some(border) = get(&intents, "border") {
        // The emphatic border tier: one step further from the page than
        // `border` is, which is not the same as one step darker.
        //
        // Direction is read off `content` against `surface-page`, the way
        // `surface-well` reads it, because "stronger" means "further from the
        // ground it is drawn on" and a dark theme's ground is at the bottom of
        // the ramp. Darkening unconditionally moves a dark theme's border
        // *toward* its page, so the strong tier came out weaker than the plain
        // one — invisible for as long as the border itself was, and obvious the
        // moment a theme gave the border a real value.
        //
        // A theme with no page or no content keeps the darkening, which is the
        // light-theme answer and the one the majority of a half-written file
        // will want.
        let away_from_page = match (get(&intents, "content"), get(&intents, "surface-page")) {
            (Some(content), Some(page)) => content.to_oklab().l > page.to_oklab().l,
            _ => false,
        };
        let strong = if away_from_page {
            lighten(border, 0.05)
        } else {
            darken(border, 0.05)
        };
        derived.push(("border-strong".into(), strong));
    }

    for (token, rgb) in derived {
        intents.insert(token, rgb.to_hex());
    }

    SemanticTokens {
        meta: theme.meta.clone(),
        intents,
    }
}

/// The smallest OKLab L difference at which two fills read as two surfaces.
///
/// Below it a well and the face it is cut into are the same area to a reader,
/// and a derivation landing this close to the end of the ramp lands on anything
/// else that clamped there.
pub(crate) const MIN_VISIBLE_DELTA_L: f32 = 0.02;

/// Shift `c`'s OKLab lightness by `delta`, or by `-delta` when the requested
/// direction has no room.
///
/// "No room" is landing within [`MIN_VISIBLE_DELTA_L`] of either end of the
/// ramp. For a derivation whose only job is to differ from its base: a step
/// that clamps is indistinguishable from every other step that clamped.
fn step_with_room(c: Rgb, delta: f32) -> Rgb {
    let target = c.to_oklab().l + delta;
    if (MIN_VISIBLE_DELTA_L..=1.0 - MIN_VISIBLE_DELTA_L).contains(&target) {
        lighten(c, delta)
    } else {
        lighten(c, -delta)
    }
}

/// Emit the resolved intent layer as CSS declarations (no selector), one
/// `  --token: #hex;` line each, in deterministic (BTreeMap) order.
pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
    let mut out = String::new();
    for (token, hex) in &tokens.intents {
        out.push_str("  --");
        out.push_str(token);
        out.push_str(": ");
        out.push_str(hex);
        out.push_str(";\n");
    }
    out
}

/// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
/// CSS mapping every web surface injects.
pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
    format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fixture::nord_toml;
    use crate::{Emphasis, embedded_themes, emphasized, parse_theme_str};

    #[test]
    fn resolve_base_intents_passthrough() {
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        let t = resolve(&theme);
        assert_eq!(t.hex("surface-page"), Some("#2e3440"));
        assert_eq!(t.hex("content"), Some("#d8dee9")); // content.primary -> content
        // Not a passthrough: a tonal step of the ink, whatever the file said.
        assert_eq!(
            t.hex("content-muted").unwrap(),
            emphasized(
                Rgb::from_hex("#d8dee9").unwrap(),
                Rgb::from_hex("#2e3440").unwrap(),
                Emphasis::Muted
            )
            .to_hex()
        );
        assert_eq!(t.hex("action"), Some("#81a1c1"));
        assert_eq!(t.hex("danger"), Some("#bf616a"));
        assert_eq!(t.hex("border"), Some("#4c566a"));
        assert_eq!(t.hex("category-five"), Some("#b48ead"));
    }

    #[test]
    fn resolve_derived_intents() {
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        let t = resolve(&theme);
        let action = Rgb::from_hex("#81a1c1").unwrap();
        let page = Rgb::from_hex("#2e3440").unwrap();
        let _ = page;
        assert_eq!(
            t.hex("action-hover").unwrap(),
            lighten(action, 0.05).to_hex()
        );
        assert_eq!(
            t.hex("content-on-action").unwrap(),
            readable_on(action).to_hex()
        );
        assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
        assert_eq!(t.hex("hover-surface"), Some("#434c5e")); // = surface.sunken
        // The toned pair a pressable tinted surface needs: the resting tint off
        // the raised ground, the hover tint off the sunken one, so a toned
        // control travels the same distance under a pointer as a plain one.
        let raised = Rgb::from_hex("#3b4252").unwrap();
        let sunken = Rgb::from_hex("#434c5e").unwrap();
        let danger = Rgb::from_hex("#bf616a").unwrap();
        assert_eq!(
            t.hex("danger-surface").unwrap(),
            mix(raised, danger, 0.12).to_hex()
        );
        assert_eq!(
            t.hex("danger-surface-hover").unwrap(),
            mix(sunken, danger, 0.12).to_hex()
        );
        assert_ne!(t.hex("danger-surface"), t.hex("danger-surface-hover"));
        // Pruned by the usage audit (0 consumers): action-active and selection.
        // Apps that need them derive inline via the shared mix().
        assert!(t.hex("action-active").is_none());
        assert!(t.hex("selection").is_none());
    }

    #[test]
    fn resolve_bevel_intents() {
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        let t = resolve(&theme);
        let raised = Rgb::from_hex("#3b4252").unwrap();
        assert_eq!(
            t.hex("bevel-light").unwrap(),
            lighten(raised, 0.14).to_hex()
        );
        assert_eq!(t.hex("bevel-dark").unwrap(), darken(raised, 0.18).to_hex());
    }

    // A bevel is two edges around one face, so both edges have to be visibly off
    // that face or the control never resolves as lit. The lightening clamps at
    // the top of the ramp, which means a theme authoring a white raised surface
    // gets a highlight identical to the surface it is meant to sit on.
    //
    // The list is asserted rather than merely reported so that changing a theme
    // has to come here and say so. Shrinking it is the fix; growing it is a
    // regression in the theme, not in this derivation.
    #[test]
    fn bevel_edges_are_distinct_from_their_face() {
        const CANNOT_BEVEL: &[&str] = &["neobrute", "oxocarbon-light"];

        let mut degenerate: Vec<String> = Vec::new();
        for (id, source) in embedded_themes() {
            let theme = parse_theme_str(id, source, false).unwrap();
            let t = resolve(&theme);
            let Some(raised) = t.hex("surface-raised") else {
                continue;
            };
            let light = t.hex("bevel-light").expect("raised implies bevel-light");
            let dark = t.hex("bevel-dark").expect("raised implies bevel-dark");
            if light == raised || dark == raised {
                degenerate.push(id.to_string());
            }
        }
        degenerate.sort();

        assert_eq!(
            degenerate, CANNOT_BEVEL,
            "themes whose raised surface cannot hold both bevel edges"
        );
    }

    // The well inverts by theme, so assert both directions explicitly rather
    // than only the one the light themes happen to take.
    #[test]
    fn resolve_well_intent_follows_the_content_direction() {
        // nord is dark: light text on a dark raised surface, so the well goes
        // down and away from the text.
        let dark = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
        let dark_raised = Rgb::from_hex("#3b4252").unwrap();
        assert_eq!(
            dark.hex("surface-well").unwrap(),
            darken(dark_raised, 0.09).to_hex()
        );

        // The shipped light themes take the other branch.
        let goingson = embedded_themes()
            .into_iter()
            .find(|(id, _)| *id == "goingson")
            .expect("goingson is embedded")
            .1;
        let light = resolve(&parse_theme_str("goingson", goingson, false).unwrap());
        let light_raised = light
            .hex("surface-raised")
            .and_then(Rgb::from_hex)
            .expect("goingson authors a raised surface");
        assert_eq!(
            light.hex("surface-well").unwrap(),
            lighten(light_raised, 0.07).to_hex()
        );
    }

    // A well is a fill, not an edge, so the only thing that makes it read is
    // being a different color from the surface it is cut into.
    //
    // Same shape and the same asserted-list discipline as
    // `bevel_edges_are_distinct_from_their_face`. It used to bite the same two
    // themes, whose raised surface sits at the top of the ramp; the well now
    // goes the other way when it has no room, so no bundled theme is exempt.
    #[test]
    fn well_is_distinct_from_its_face() {
        const CANNOT_WELL: &[&str] = &[];

        let mut degenerate: Vec<String> = Vec::new();
        for (id, source) in embedded_themes() {
            let theme = parse_theme_str(id, source, false).unwrap();
            let t = resolve(&theme);
            let Some(raised) = t.hex("surface-raised") else {
                continue;
            };
            let well = t.hex("surface-well").expect("raised implies surface-well");
            if well == raised {
                degenerate.push(id.to_string());
            }
        }
        degenerate.sort();

        assert_eq!(
            degenerate, CANNOT_WELL,
            "themes whose raised surface cannot hold a well"
        );
    }

    // Distinct is not the same as visible. A face near the top of the ramp
    // clamps partway rather than exactly, which yields a well that differs from
    // its face by a hex digit and by nothing the eye can find. `rosepine-dawn`
    // authors raised at L=0.987 and got 0.009 of the 0.07 it asked for, until
    // a step without room started travelling the other way.
    //
    // Kept separate from the test above because a partial clamp passes that
    // one, and the list stays asserted so a theme that defeats the fallback has
    // to come here and say so.
    #[test]
    fn well_is_visible_against_its_face() {
        const CANNOT_HOLD_A_VISIBLE_WELL: &[&str] = &[];

        let mut invisible: Vec<String> = Vec::new();
        for (id, source) in embedded_themes() {
            let theme = parse_theme_str(id, source, false).unwrap();
            let t = resolve(&theme);
            let (Some(raised), Some(well)) = (
                t.hex("surface-raised").and_then(Rgb::from_hex),
                t.hex("surface-well").and_then(Rgb::from_hex),
            ) else {
                continue;
            };
            if (well.to_oklab().l - raised.to_oklab().l).abs() < MIN_VISIBLE_DELTA_L {
                invisible.push(id.to_string());
            }
        }
        invisible.sort();

        assert_eq!(
            invisible, CANNOT_HOLD_A_VISIBLE_WELL,
            "themes whose well is too close to its face to read as one"
        );
    }

    // The collision the fallback exists for. A light face near the top of the
    // ramp sends both the well and `bevel-light` into the same clamp, so the
    // well measured against its face alone can pass while being the exact
    // colour of the lit edge drawn around it. Ten of the 31 bundled themes did.
    #[test]
    fn well_is_visible_against_the_highlight() {
        let mut collided: Vec<String> = Vec::new();
        for (id, source) in embedded_themes() {
            let theme = parse_theme_str(id, source, false).unwrap();
            let t = resolve(&theme);
            let (Some(well), Some(light)) = (
                t.hex("surface-well").and_then(Rgb::from_hex),
                t.hex("bevel-light").and_then(Rgb::from_hex),
            ) else {
                continue;
            };
            if (well.to_oklab().l - light.to_oklab().l).abs() < MIN_VISIBLE_DELTA_L {
                collided.push(id.to_string());
            }
        }
        collided.sort();
        assert!(
            collided.is_empty(),
            "themes whose well reads as the lit edge around it: {collided:?}"
        );
    }

    // The table model's row tones, pinned to the specimen they were measured
    // from: treatment C on akari-dawn, artifact d081c72d.
    #[test]
    fn row_tones_reproduce_the_chosen_table_specimen() {
        let source = embedded_themes()
            .into_iter()
            .find(|(id, _)| *id == "akari-dawn")
            .expect("akari-dawn is embedded")
            .1;
        let t = resolve(&parse_theme_str("akari-dawn", source, false).unwrap());
        assert_eq!(
            t.hex("row-stripe").map(str::to_ascii_lowercase).as_deref(),
            Some("#e9e5e0")
        );
        assert_eq!(
            t.hex("row-hover").map(str::to_ascii_lowercase).as_deref(),
            Some("#dedad5")
        );
        assert_eq!(
            t.hex("row-rule").map(str::to_ascii_lowercase).as_deref(),
            Some("#b0ada9")
        );
    }

    // The fallback's direction, pinned on the theme that found it. akari-dawn
    // authors raised at L=0.966, where the well's step up would land in the
    // clamp, so the well goes down instead.
    #[test]
    fn well_without_room_travels_the_other_way() {
        let source = embedded_themes()
            .into_iter()
            .find(|(id, _)| *id == "akari-dawn")
            .expect("akari-dawn is embedded")
            .1;
        let t = resolve(&parse_theme_str("akari-dawn", source, false).unwrap());
        let raised = t
            .hex("surface-raised")
            .and_then(Rgb::from_hex)
            .expect("akari-dawn authors a raised surface");
        assert_eq!(
            t.hex("surface-well").unwrap(),
            darken(raised, 0.07).to_hex()
        );
    }

    // The three tests above each measure a derived color against the face it was
    // derived from, so a theme can pass all of them and still have nothing lift
    // off anything: the face itself sits on the page, and that relationship is
    // the one a bevel needs in order to read as an object rather than as a
    // rectangle with decorated edges. makenot.work passed all three and could
    // not hold a bevel, which is what this covers.
    //
    // The threshold is picked against the ramps already ruled on rather than
    // against a round number. makenot.work shipped at 0.024 and was invisible,
    // was tried at 0.036 and rejected as marginal on badges and chips, and was
    // accepted at 0.058; goingson and audiofiles sit at 0.119 and 0.065. Every
    // ramp judged inadequate is below 0.036 and every one judged adequate is
    // above 0.058, so the line goes in the gap between them. Note the unit: this
    // is oklab L on 0 to 1, not the CIE L* on 0 to 100 that the theme files quote
    // in their comments, and the two are not interchangeable.
    //
    // Most of the list is imported palettes, which were authored for syntax
    // highlighting and owe our depth model nothing. Failing here says a theme
    // cannot hold a bevel, not that it is wrong. Shrinking the list is the fix;
    // growing it is a regression in the theme, not in this derivation.
    //
    // An imported palette leaves this list only when the fix is upstream's own,
    // never a color we picked, or the list becomes a record of what we were
    // willing to repaint rather than of what cannot hold a bevel. The remaining
    // entries are shallow ramps in published palettes, deferred until every app
    // is migrated and eyeballed.
    //
    // The akari pair is not an imported palette in that sense and is exempt.
    // Upstream ships no file in this schema, so its surface ramp was ours to
    // choose from the start, and it is the pair every app defaults to: a depth
    // model the default theme cannot render is a broken default, not a palette
    // we are borrowing on someone else's terms.
    #[test]
    fn raised_is_distinct_from_page() {
        // Below this, a raised surface and the page under it are one surface to
        // a reader, whichever direction the theme ramps in.
        const MIN_DELTA_L: f32 = 0.05;
        const CANNOT_LIFT_OFF_THE_PAGE: &[&str] = &[
            "ayu-light",
            "ayu-mirage",
            "catppuccin-latte",
            "catppuccin-mocha",
            "dawnfox",
            "dracula",
            "everforest",
            "flatwhite",
            "gruvbox-light",
            "neobrute",
            "one-dark",
            "oxocarbon-dark",
            "oxocarbon-light",
            "poimandres",
            "rosepine",
            "rosepine-dawn",
            "solarized-dark",
        ];

        let mut flat: Vec<String> = Vec::new();
        for (id, source) in embedded_themes() {
            let theme = parse_theme_str(id, source, false).unwrap();
            let t = resolve(&theme);
            let (Some(page), Some(raised)) = (
                t.hex("surface-page").and_then(Rgb::from_hex),
                t.hex("surface-raised").and_then(Rgb::from_hex),
            ) else {
                continue;
            };
            if (raised.to_oklab().l - page.to_oklab().l).abs() < MIN_DELTA_L {
                flat.push(id.to_string());
            }
        }
        flat.sort();

        assert_eq!(
            flat, CANNOT_LIFT_OFF_THE_PAGE,
            "themes whose raised surface is too close to the page to lift off it"
        );
    }

    #[test]
    fn resolve_overlay_is_dark_translucent_scrim() {
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        let t = resolve(&theme);
        let overlay = t.hex("overlay").unwrap();
        assert!(
            overlay.starts_with("rgba("),
            "overlay is translucent: {overlay}"
        );
        assert!(overlay.ends_with(", 0.5)"));
        // The scrim tone is anchored very dark regardless of theme.
        let inner = overlay
            .trim_start_matches("rgba(")
            .trim_end_matches(", 0.5)");
        let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
        let scrim = Rgb {
            r: parts[0],
            g: parts[1],
            b: parts[2],
        };
        assert!(scrim.to_oklab().l < 0.2, "scrim must be near-black");
    }

    /// Every shipped theme derives it, on both polarities, and it is always a
    /// near-black translucent tone. A shadow tinted to a dark theme's own
    /// lightness would not read as one.
    #[test]
    fn elevation_is_a_near_black_cast_on_every_theme() {
        for (id, source) in embedded_themes() {
            let theme = parse_theme_str(id, source, false).unwrap();
            let t = resolve(&theme);
            let Some(elevation) = t.hex("elevation") else {
                panic!("{id} derives no elevation");
            };
            assert!(
                elevation.starts_with("rgba(") && elevation.ends_with(", 0.18)"),
                "{id}: elevation is translucent: {elevation}"
            );
            let inner = elevation
                .trim_start_matches("rgba(")
                .trim_end_matches(", 0.18)");
            let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
            let cast = Rgb {
                r: parts[0],
                g: parts[1],
                b: parts[2],
            };
            assert!(
                cast.to_oklab().l < 0.2,
                "{id}: a cast shadow must be near-black, got {elevation}"
            );
        }
    }

    /// The scrim and the cast share an anchor and differ only in weight. Stated
    /// as a test because the two are easy to drift apart, and a scrim that
    /// stopped matching the shadow under the thing it dims would show.
    #[test]
    fn elevation_and_the_scrim_are_the_same_tone() {
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        let t = resolve(&theme);
        let scrim = t.hex("overlay").unwrap();
        let cast = t.hex("elevation").unwrap();
        assert_eq!(
            scrim.trim_end_matches(", 0.5)"),
            cast.trim_end_matches(", 0.18)"),
        );
    }

    /// The accessor that makes a translucent intent reachable from something
    /// that is not a stylesheet. Both spellings, and an opaque token answers
    /// 255 so a caller need not know which kind it asked for.
    #[test]
    fn rgba_reads_both_spellings() {
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        let t = resolve(&theme);

        let (_, _, _, opaque) = t.rgba("surface-page").expect("page is a hex token");
        assert_eq!(opaque, 255);

        let (r, g, b, alpha) = t.rgba("elevation").expect("elevation is translucent");
        assert_eq!(alpha, 46, "0.18 of 255");
        assert_eq!(t.rgb("elevation"), None, "rgb declines to drop the alpha");

        let (sr, sg, sb, scrim) = t.rgba("overlay").expect("overlay is translucent");
        assert_eq!((sr, sg, sb), (r, g, b), "one tone, two weights");
        assert_eq!(scrim, 128);
    }

    #[test]
    fn resolve_drops_non_hex_base_intent() {
        // A base intent that isn't a hex color must never reach the resolved
        // token set (it would otherwise be inlined verbatim into a <style>
        // block). Skipped like a missing intent; valid siblings survive.
        let theme = parse_theme_str(
            "x",
            "[surface]\npage = \"</style><script>alert(1)</script>\"\n[content]\nprimary = \"#111111\"\n",
            false,
        )
        .unwrap();
        let t = resolve(&theme);
        assert!(
            t.hex("surface-page").is_none(),
            "non-hex base intent leaked"
        );
        assert_eq!(t.hex("content").unwrap(), "#111111");
        // The injected markup appears in no resolved value.
        assert!(!t.intents.values().any(|v| v.contains('<')));
    }

    #[test]
    fn resolve_skips_derived_when_source_missing() {
        // No [action] => no action-derived tokens.
        let theme = parse_theme_str(
            "x",
            "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n",
            false,
        )
        .unwrap();
        let t = resolve(&theme);
        assert!(t.hex("action").is_none());
        assert!(t.hex("action-hover").is_none());
        assert!(t.hex("selection").is_none());
        assert_eq!(
            t.hex("border-strong").unwrap(),
            darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex()
        );
    }

    #[test]
    fn rgb_accessor_for_native_consumers() {
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        let t = resolve(&theme);
        assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1)));
        assert_eq!(t.rgb("nonexistent"), None);
    }

    // ---- css emit ----

    #[test]
    fn intent_css_vars_wraps_root_and_includes_tokens() {
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        let css = intent_css_vars(&resolve(&theme));
        assert!(css.starts_with(":root {\n"));
        assert!(css.contains("  --surface-page: #2e3440;\n"));
        assert!(css.contains("  --danger: #bf616a;\n"));
        assert!(css.contains("  --action-hover: "));
        assert!(css.trim_end().ends_with('}'));
    }
}