rio-theme 0.30.0

Theme engine for rustio-admin: turns raw brand colors into a safe, computed tokens.css.
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
//! Serialise `ThemeTokens` into a `tokens.css` string.
//!
//! Output is plain hex — all `color-mix()` math has already been done
//! by the engine. A static file is faster than nested `color-mix()`
//! at parse time and has zero browser-support risk.
//!
//! Dark mode is emitted as **two blocks** that mirror the framework's
//! own guards: an explicit `:root[data-theme="dark"]` block and a
//! `@media (prefers-color-scheme: dark) { :root { … } }` auto block.
//! Because the generated file is appended *after* the framework bundle,
//! a `:root`-only override would tie `[data-theme="dark"]` on
//! specificity and win by source order — leaking light into dark. The
//! dual structure prevents that for both the explicit toggle and OS
//! auto-dark. The dark *values* come from [`DarkPolicy`] (auto-derived,
//! light-only, or explicit). See `docs/design/TOKENS-EMIT-SPEC.md`.
//!
//! The output has two sections inside the same `:root` block:
//!
//! 1. **Canonical brand-* tokens** — the engine's primary output per
//!    §11 of the implementation brief. These are the names the
//!    decision layer reasons in: `--rio-brand-light`, `-dark`,
//!    `-adaptive`, `-surface`, `-accent`, `-hover`, `-active`,
//!    `-tint`, `-text`.
//!
//! 2. **Drop-in compatibility tokens** — the names the live admin
//!    templates already consume (`--rio-accent*`, the surface ladder,
//!    the text ladder, the border ladder, semantic backgrounds).
//!    Without this block the generated file would not actually drop
//!    into the framework's CSS bundle. Brand-derived tokens
//!    (`--rio-accent`, `--rio-accent-hover`, `--rio-accent-soft`,
//!    `--rio-accent-border`, `--rio-bg`, `--rio-border`,
//!    `--rio-info-bg`) come from the resolved `ThemeTokens`; the
//!    slate scaffold (`--rio-surface-*`, `--rio-text-*`,
//!    `--rio-border-soft`, `--rio-border-strong`) is brand-agnostic
//!    by design — mirrors the live `colors.css` so a brand swap does
//!    not move the chrome ladder out from under existing components.

use std::fmt::Write as _;

use crate::color::Color;
use crate::contrast::{contrast_ratio, AA_TEXT};
use crate::engine::ThemeTokens;

/// How the emitted `tokens.css` should handle dark mode.
///
/// The generated file is **appended after** the framework's baked CSS
/// bundle, so a `:root`-only override (light values, no dark blocks)
/// ties the framework's `[data-theme="dark"]` on specificity and wins
/// by source order — leaking light surfaces into dark mode. To avoid
/// that, `emit` always writes the dual dark structure the framework
/// uses: an explicit `:root[data-theme="dark"]` block (higher
/// specificity, tie-breaks the toggle) **and** a
/// `@media (prefers-color-scheme: dark) { :root { … } }` auto block
/// (later source order than the plain `:root`, so OS-dark without an
/// explicit toggle is covered too). This enum decides what *values*
/// those dark blocks carry.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum DarkPolicy {
    /// Auto-derive dark from the resolved light tokens: a brand-agnostic
    /// slate ladder (inverted surfaces, slate text) with the brand
    /// accent **lifted** until it clears `AA_TEXT` on the dark surface.
    /// The default.
    #[default]
    Auto,
    /// The brand is light-only. Dark pins to the framework's default
    /// dark palette (slate surfaces + the framework's lifted teal
    /// accent) — a *neutral* dark override, never light values. Use
    /// this to opt out of brand color in dark rather than leak light.
    LightOnly,
    /// Explicit dark accent supplied by the brand author; the dark
    /// blocks lift it for `AA_TEXT` on the dark surface. Surfaces and
    /// text stay on the framework slate ladder (brand-agnostic, exactly
    /// as the light `:root` block keeps the chrome ladder brand-agnostic).
    Explicit {
        /// The author's chosen dark-mode accent (pre-lift).
        accent: Color,
    },
}

/// Render a drop-in `tokens.css` from a fully resolved `ThemeTokens`.
///
/// The single `:root` block carries both the canonical `--rio-brand-*`
/// vocabulary the engine reasons in, and the legacy `--rio-*` names
/// the live admin templates already consume. See the module docs for
/// the rationale behind keeping both.
pub fn emit(tokens: &ThemeTokens) -> String {
    emit_with(tokens, &DarkPolicy::Auto)
}

/// Render a drop-in `tokens.css` with an explicit [`DarkPolicy`].
///
/// `emit` is the `DarkPolicy::Auto` shorthand. Both always write the
/// dual dark structure (explicit `[data-theme="dark"]` + `@media`
/// auto block) so the override never leaks light into dark mode.
pub fn emit_with(tokens: &ThemeTokens, dark: &DarkPolicy) -> String {
    let mut s = String::new();
    s.push_str("/* Generated by rio-theme. Do not edit by hand. */\n");
    s.push_str(":root {\n");

    // --- Canonical engine output (DESIGN_THEME §11) ---
    s.push_str("  /* canonical brand-* tokens (engine output) */\n");
    line(&mut s, "--rio-brand-light", &tokens.brand_light.to_hex());
    line(&mut s, "--rio-brand-dark", &tokens.brand_dark.to_hex());
    s.push_str("  --rio-brand-adaptive: var(--rio-brand-light);\n");
    line(
        &mut s,
        "--rio-brand-surface",
        &tokens.brand_surface.to_hex(),
    );
    line(&mut s, "--rio-brand-accent", &tokens.brand_accent.to_hex());
    line(
        &mut s,
        "--rio-brand-secondary",
        &tokens.brand_secondary.to_hex(),
    );
    line(&mut s, "--rio-brand-hover", &tokens.brand_hover.to_hex());
    line(&mut s, "--rio-brand-active", &tokens.brand_active.to_hex());
    line(&mut s, "--rio-brand-tint", &tokens.brand_tint.to_hex());
    line(&mut s, "--rio-brand-text", &tokens.brand_text.to_hex());
    line(&mut s, "--rio-muted", &tokens.muted.to_hex());

    // --- Drop-in compatibility for the live admin template ---
    s.push('\n');
    s.push_str("  /* drop-in aliases for the live admin template */\n");

    // Brand-derived aliases. The live admin uses `--rio-accent` for
    // BUTTONS and other large affordances, so it must track the
    // tamed `brand_surface` (canonical "large fills" role), not the
    // raw `brand_accent` (canonical "small touches"). For non-vivid
    // inputs the two are equal so this is a no-op; for neon inputs
    // it's the difference between a usable button and an unreadable
    // one.
    line(&mut s, "--rio-accent", &tokens.brand_surface.to_hex());
    line(&mut s, "--rio-accent-hover", &tokens.brand_hover.to_hex());
    line(
        &mut s,
        "--rio-accent-rgb",
        &rgb_triple(&tokens.brand_surface),
    );
    line(&mut s, "--rio-accent-soft", &tokens.brand_tint.to_hex());
    // accent-border: a mid-light brand tint. Live framework uses
    // this for focus rings + input borders, where the visual job is
    // "lighter than the brand but the same family". Always lightened
    // brand-surface — secondary brand colors don't fit this role
    // (they're for badges/dots, surfaced as `--rio-brand-secondary`).
    line(
        &mut s,
        "--rio-accent-border",
        &tokens.brand_surface.lighten(0.65).to_hex(),
    );
    line(&mut s, "--rio-bg", &tokens.bg.to_hex());

    // Slate scaffold — brand-agnostic. Values lifted from the live
    // `colors.css` so generated themes inherit the same depth metaphor
    // and chrome relationship.
    line(&mut s, "--rio-surface", "#ffffff");
    line(&mut s, "--rio-surface-2", "#f8fafc");
    line(&mut s, "--rio-surface-3", "#f1f5f9");
    line(&mut s, "--rio-surface-chrome", "#0f172a");
    line(&mut s, "--rio-surface-elevated", "#ffffff");

    line(&mut s, "--rio-text-strong", "#0f172a");
    line(&mut s, "--rio-text", "#1e293b");
    line(&mut s, "--rio-text-muted", "#475569");
    line(&mut s, "--rio-text-subtle", "#64748b");

    line(&mut s, "--rio-border-soft", "#e2e8f0");
    line(&mut s, "--rio-border", &tokens.border.to_hex());
    line(&mut s, "--rio-border-strong", "#94a3b8");

    // Semantic foreground + matching soft backgrounds. Backgrounds
    // are computed from the (possibly hue-shifted) foregrounds, so a
    // shifted danger keeps a matching shifted danger-bg.
    line(&mut s, "--rio-success", &tokens.success.to_hex());
    line(&mut s, "--rio-warning", &tokens.warning.to_hex());
    line(&mut s, "--rio-danger", &tokens.danger.to_hex());
    line(
        &mut s,
        "--rio-success-bg",
        &soft_bg(&tokens.success).to_hex(),
    );
    line(
        &mut s,
        "--rio-warning-bg",
        &soft_bg(&tokens.warning).to_hex(),
    );
    line(&mut s, "--rio-danger-bg", &soft_bg(&tokens.danger).to_hex());
    line(&mut s, "--rio-info-bg", &tokens.brand_tint.to_hex());

    // Chart series.
    for (i, c) in tokens.chart.iter().enumerate() {
        let name = format!("--rio-chart-{}", i + 1);
        line(&mut s, &name, &c.to_hex());
    }

    s.push_str("}\n\n");

    // --- Dark theme: dual blocks, mirroring the framework's guards ---
    // 1. `:root[data-theme="dark"]` — explicit toggle. Higher specificity
    //    than the framework's `[data-theme="dark"]`, so it tie-breaks.
    // 2. `@media (prefers-color-scheme: dark) { :root }` — OS auto. Later
    //    source order than this file's own `:root`, so it wins the auto case.
    // Both carry identical values so the two paths render the same.
    let palette = dark_palette(tokens, dark);
    s.push_str(":root[data-theme=\"dark\"] {\n");
    write_palette(&mut s, &palette, "  ");
    s.push_str("}\n\n");
    s.push_str("@media (prefers-color-scheme: dark) {\n");
    s.push_str("  :root {\n");
    write_palette(&mut s, &palette, "    ");
    s.push_str("  }\n");
    s.push_str("}\n");
    s
}

/// Brand-agnostic slate ladder for dark mode (surfaces, text, borders).
/// Mirrors the framework's Phase-8 `[data-theme="dark"]` palette so a
/// generated override sits consistently on top of it. The accent is the
/// only [`DarkPolicy`]-dependent part (see [`dark_palette`]).
const DARK_BG: &str = "#0f172a";
const DARK_SURFACE: &str = "#1e293b";

/// Compute the ordered (token, value) pairs for the dark blocks.
///
/// Surfaces / text / borders / semantics are the brand-agnostic slate
/// ladder (identical for every policy, exactly as the light `:root`
/// block keeps the chrome ladder brand-agnostic). Only the accent set
/// varies: lifted brand (`Auto`), framework default (`LightOnly`), or a
/// lifted explicit color (`Explicit`).
fn dark_palette(tokens: &ThemeTokens, dark: &DarkPolicy) -> Vec<(&'static str, String)> {
    let dark_surface = Color::from_hex(DARK_SURFACE).expect("constant");
    let dark_bg = Color::from_hex(DARK_BG).expect("constant");

    // Accent for dark, per policy.
    let (accent, accent_hover) = match dark {
        // Pin to the framework's default dark accent (lifted teal). No
        // brand color leaks into dark — a neutral override.
        DarkPolicy::LightOnly => (
            Color::from_hex("#2dd4bf").expect("constant"),
            Color::from_hex("#5eead4").expect("constant"),
        ),
        // Lift the brand's large-fill accent until it clears AA on dark.
        DarkPolicy::Auto => {
            let a = lift_for_dark(&tokens.brand_surface, &dark_surface);
            (a, a.lighten(0.12))
        }
        // Lift the author's explicit dark accent the same way.
        DarkPolicy::Explicit { accent } => {
            let a = lift_for_dark(accent, &dark_surface);
            (a, a.lighten(0.12))
        }
    };

    // Semantics (lifted for dark) — brand-agnostic, like the surfaces.
    let success = Color::from_hex("#6fb98a").expect("constant");
    let warning = Color::from_hex("#d6a65a").expect("constant");
    let danger = Color::from_hex("#ef4444").expect("constant");

    vec![
        // Canonical adaptive pointer (the one token the old dark block set).
        ("--rio-brand-adaptive", "var(--rio-brand-dark)".to_string()),
        // Accent set (policy-dependent).
        ("--rio-accent", accent.to_hex()),
        ("--rio-accent-hover", accent_hover.to_hex()),
        ("--rio-accent-rgb", rgb_triple(&accent)),
        ("--rio-accent-soft", accent.mix(&dark_bg, 0.86).to_hex()),
        (
            "--rio-accent-border",
            accent.mix(&dark_surface, 0.35).to_hex(),
        ),
        ("--rio-info-bg", accent.mix(&dark_bg, 0.86).to_hex()),
        // Surface ladder (brand-agnostic slate, inverted from light).
        ("--rio-bg", DARK_BG.to_string()),
        ("--rio-surface", DARK_SURFACE.to_string()),
        ("--rio-surface-2", "#0b1120".to_string()),
        ("--rio-surface-3", "#334155".to_string()),
        ("--rio-surface-chrome", "#0f172a".to_string()),
        ("--rio-surface-elevated", "#1e293b".to_string()),
        // Text ladder (slate, light end).
        ("--rio-text-strong", "#f1f5f9".to_string()),
        ("--rio-text", "#cbd5e1".to_string()),
        ("--rio-text-muted", "#94a3b8".to_string()),
        ("--rio-text-subtle", "#64748b".to_string()),
        // Borders.
        ("--rio-border-soft", "#334155".to_string()),
        ("--rio-border", "#334155".to_string()),
        ("--rio-border-strong", "#64748b".to_string()),
        // Semantics + their dark soft backgrounds.
        ("--rio-success", success.to_hex()),
        ("--rio-warning", warning.to_hex()),
        ("--rio-danger", danger.to_hex()),
        ("--rio-success-bg", success.mix(&dark_bg, 0.84).to_hex()),
        ("--rio-warning-bg", warning.mix(&dark_bg, 0.84).to_hex()),
        ("--rio-danger-bg", danger.mix(&dark_bg, 0.84).to_hex()),
    ]
}

/// Write an ordered token list at a fixed indent.
fn write_palette(s: &mut String, palette: &[(&'static str, String)], indent: &str) {
    for (name, value) in palette {
        let _ = writeln!(s, "{indent}{name}: {value};");
    }
}

/// Lift an accent toward white until it clears `AA_TEXT` (4.5:1) against
/// the dark surface, so links / accent text are readable on dark.
/// Bounded; returns the best effort if 4.5 is unreachable.
fn lift_for_dark(accent: &Color, dark_surface: &Color) -> Color {
    let mut a = *accent;
    for _ in 0..40 {
        if contrast_ratio(&a, dark_surface) >= AA_TEXT {
            return a;
        }
        a = a.lighten(0.05);
    }
    a
}

fn line(s: &mut String, name: &str, value: &str) {
    // Fixed two-space indent and a single space after the colon. No
    // alignment by length — golden-file stability beats prettiness.
    let _ = writeln!(s, "  {name}: {value};");
}

/// Space-separated R G B 0..255 triple, matching the live
/// `--rio-accent-rgb` convention (so `rgb(var(--rio-accent-rgb) / 0.2)`
/// keeps working in alpha-tinted overlays).
fn rgb_triple(color: &Color) -> String {
    // Reparse the hex to get the quantized channels — guarantees the
    // RGB triple agrees with the hex value the file already prints.
    let hex = color.to_hex();
    let r = u8::from_str_radix(&hex[1..3], 16).expect("emitted hex is valid");
    let g = u8::from_str_radix(&hex[3..5], 16).expect("emitted hex is valid");
    let b = u8::from_str_radix(&hex[5..7], 16).expect("emitted hex is valid");
    format!("{r} {g} {b}")
}

/// Soft pill background derived from a semantic foreground.
///
/// Starts at 92% white (visually a soft tint in the foreground's hue
/// family) and walks lighter in 1% increments until the foreground
/// clears `AA_NON_TEXT` (3.0) against the resulting background.
/// Bounded at 99% so we never collapse to pure white. The
/// hand-tuned tailwind `-50` colors (`#ECFDF5`, `#FFFBEB`,
/// `#FEF2F2`) pass at slightly lighter mixes than a flat 92% would
/// produce — without the loop, amber warning-on-bg lands at 2.94,
/// below the 3.0 threshold for pill text.
fn soft_bg(fg: &Color) -> Color {
    let white = Color::from_hex("#ffffff").expect("constant");
    let mut amount = 0.92_f64;
    loop {
        let bg = fg.mix(&white, amount);
        if amount >= 0.99
            || crate::contrast::contrast_ratio(fg, &bg) >= crate::contrast::AA_NON_TEXT
        {
            return bg;
        }
        amount += 0.01;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::{resolve_theme, ThemeInput};

    #[test]
    fn emit_contains_every_canonical_brand_token() {
        let css = emit(&resolve_theme(ThemeInput::empty()));
        for name in [
            "--rio-brand-light",
            "--rio-brand-dark",
            "--rio-brand-adaptive",
            "--rio-brand-surface",
            "--rio-brand-accent",
            "--rio-brand-secondary",
            "--rio-brand-hover",
            "--rio-brand-active",
            "--rio-brand-tint",
            "--rio-brand-text",
            "--rio-muted",
        ] {
            assert!(css.contains(name), "missing canonical {name}");
        }
    }

    #[test]
    fn emit_contains_every_live_template_token() {
        // If this list ever drifts from the live `colors.css`, the
        // generated file stops being a drop-in. Update both together.
        let css = emit(&resolve_theme(ThemeInput::empty()));
        for name in [
            "--rio-accent",
            "--rio-accent-hover",
            "--rio-accent-rgb",
            "--rio-accent-soft",
            "--rio-accent-border",
            "--rio-bg",
            "--rio-surface",
            "--rio-surface-2",
            "--rio-surface-3",
            "--rio-surface-chrome",
            "--rio-surface-elevated",
            "--rio-text-strong",
            "--rio-text",
            "--rio-text-muted",
            "--rio-text-subtle",
            "--rio-border-soft",
            "--rio-border",
            "--rio-border-strong",
            "--rio-success",
            "--rio-warning",
            "--rio-danger",
            "--rio-success-bg",
            "--rio-warning-bg",
            "--rio-danger-bg",
            "--rio-info-bg",
        ] {
            assert!(css.contains(name), "missing drop-in alias {name}");
        }
    }

    #[test]
    fn accent_rgb_triple_agrees_with_accent_hex() {
        // The triple is what `rgb(var(--rio-accent-rgb) / α)` uses,
        // so it must quantize to the same bytes as `--rio-accent`'s
        // hex. Drop-in `--rio-accent` aliases `brand_surface` (see
        // the comment in `emit`).
        let tokens = resolve_theme(ThemeInput::empty());
        let css = emit(&tokens);
        let hex = tokens.brand_surface.to_hex();
        let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
        let g = u8::from_str_radix(&hex[3..5], 16).unwrap();
        let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
        let expected = format!("--rio-accent-rgb: {r} {g} {b};");
        assert!(css.contains(&expected), "expected `{expected}` in:\n{css}");
    }

    #[test]
    fn dark_block_is_always_emitted() {
        let css = emit(&resolve_theme(ThemeInput::empty()));
        assert!(css.contains(":root[data-theme=\"dark\"]"));
    }

    /// Both dark guards must be present so the explicit toggle AND OS
    /// auto-dark are covered — the whole point of the dual structure.
    #[test]
    fn dark_emits_both_explicit_and_auto_blocks() {
        for policy in [
            DarkPolicy::Auto,
            DarkPolicy::LightOnly,
            DarkPolicy::Explicit {
                accent: Color::from_hex("#0E6B5B").unwrap(),
            },
        ] {
            let css = emit_with(&resolve_theme(ThemeInput::empty()), &policy);
            assert!(
                css.contains(":root[data-theme=\"dark\"] {"),
                "{policy:?}: missing explicit dark block"
            );
            assert!(
                css.contains("@media (prefers-color-scheme: dark) {"),
                "{policy:?}: missing auto dark block"
            );
        }
    }

    /// Whatever the policy, the dark blocks must re-assert a dark
    /// `--rio-bg` — never let the light `:root` value leak through.
    #[test]
    fn dark_never_leaks_light_background() {
        let tokens = resolve_theme(ThemeInput {
            brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
        });
        for policy in [DarkPolicy::Auto, DarkPolicy::LightOnly] {
            let css = emit_with(&tokens, &policy);
            // The dark blocks set --rio-bg to the slate background.
            assert!(
                css.matches("--rio-bg: #0f172a;").count() >= 2,
                "{policy:?}: dark blocks must pin --rio-bg to #0f172a (got:\n{css})"
            );
        }
    }

    /// `Auto` lifts the brand accent until it clears AA on the dark
    /// surface — readable links/accent on dark.
    #[test]
    fn dark_auto_accent_clears_aa_on_dark_surface() {
        let tokens = resolve_theme(ThemeInput {
            brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
        });
        let pal = dark_palette(&tokens, &DarkPolicy::Auto);
        let accent_hex = pal
            .iter()
            .find(|(n, _)| *n == "--rio-accent")
            .map(|(_, v)| v.clone())
            .unwrap();
        let accent = Color::from_hex(&accent_hex).unwrap();
        let dark_surface = Color::from_hex(DARK_SURFACE).unwrap();
        assert!(
            contrast_ratio(&accent, &dark_surface) >= AA_TEXT - 0.01,
            "auto dark accent {accent_hex} only {:.2} on dark surface",
            contrast_ratio(&accent, &dark_surface)
        );
    }

    /// `LightOnly` pins the dark accent to the framework default — the
    /// brand color does not appear in dark at all.
    #[test]
    fn dark_light_only_pins_framework_accent() {
        let tokens = resolve_theme(ThemeInput {
            brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
        });
        let css = emit_with(&tokens, &DarkPolicy::LightOnly);
        // Light `:root` keeps the brand accent; the two dark blocks pin
        // to the framework default teal — so the brand never appears in
        // dark. Framework default shows up exactly twice (both blocks).
        assert_eq!(
            css.matches("--rio-accent: #2dd4bf;").count(),
            2,
            "LightOnly must pin both dark blocks to the framework accent"
        );
    }

    /// `Explicit` derives the dark accent from the author's supplied
    /// color (lifted for AA), independent of the brand input.
    #[test]
    fn dark_explicit_uses_supplied_accent() {
        let tokens = resolve_theme(ThemeInput {
            brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
        });
        let explicit = Color::from_hex("#0d9488").unwrap();
        let pal = dark_palette(&tokens, &DarkPolicy::Explicit { accent: explicit });
        let accent_hex = pal
            .iter()
            .find(|(n, _)| *n == "--rio-accent")
            .unwrap()
            .1
            .clone();
        let accent = Color::from_hex(&accent_hex).unwrap();
        let dark_surface = Color::from_hex(DARK_SURFACE).unwrap();
        // Lifted version of the explicit color, AA-clear on dark.
        assert!(contrast_ratio(&accent, &dark_surface) >= AA_TEXT - 0.01);
        // …and it is NOT the LightOnly framework default.
        assert_ne!(accent_hex, "#2dd4bf");
    }

    #[test]
    fn soft_bg_always_clears_aa_non_text_against_its_foreground() {
        // Property test for the contrast-aware `soft_bg` loop.
        // Regression for the bug verification surfaced: a flat 92%
        // white mix left amber warning at 2.94 (below AA-large 3.0)
        // against its derived background. Every semantic bg must
        // now clear 3.0 against its fg.
        use crate::color::Color;
        use crate::contrast::{contrast_ratio, AA_NON_TEXT};
        for brand_hex in [
            "#3f6089", "#0d9488", "#39ff14", "#0a1a2e", "#c9572e", "#888888", "#dc2626",
        ] {
            let tokens = resolve_theme(ThemeInput {
                brand_colors: vec![Color::from_hex(brand_hex).unwrap()],
            });
            for (name, fg) in [
                ("success", tokens.success),
                ("warning", tokens.warning),
                ("danger", tokens.danger),
            ] {
                let bg = super::soft_bg(&fg);
                let r = contrast_ratio(&fg, &bg);
                assert!(
                    r >= AA_NON_TEXT - 0.01,
                    "brand {brand_hex}: {name} {} on derived bg {} only {r:.2}",
                    fg.to_hex(),
                    bg.to_hex(),
                );
            }
        }
    }

    #[test]
    fn chart_tokens_index_from_one() {
        use crate::color::Color;
        let css = emit(&resolve_theme(ThemeInput {
            brand_colors: vec![
                Color::from_hex("#3f6089").unwrap(),
                Color::from_hex("#c9572e").unwrap(),
                Color::from_hex("#2e7d5b").unwrap(),
            ],
        }));
        assert!(css.contains("--rio-chart-1"));
        assert!(!css.contains("--rio-chart-0"));
    }
}