Skip to main content

rio_theme/
emit.rs

1//! Serialise `ThemeTokens` into a `tokens.css` string.
2//!
3//! Output is plain hex — all `color-mix()` math has already been done
4//! by the engine. A static file is faster than nested `color-mix()`
5//! at parse time and has zero browser-support risk.
6//!
7//! Dark mode is emitted as **two blocks** that mirror the framework's
8//! own guards: an explicit `:root[data-theme="dark"]` block and a
9//! `@media (prefers-color-scheme: dark) { :root { … } }` auto block.
10//! Because the generated file is appended *after* the framework bundle,
11//! a `:root`-only override would tie `[data-theme="dark"]` on
12//! specificity and win by source order — leaking light into dark. The
13//! dual structure prevents that for both the explicit toggle and OS
14//! auto-dark. The dark *values* come from [`DarkPolicy`] (auto-derived,
15//! light-only, or explicit). See `docs/design/TOKENS-EMIT-SPEC.md`.
16//!
17//! The output has two sections inside the same `:root` block:
18//!
19//! 1. **Canonical brand-* tokens** — the engine's primary output per
20//!    §11 of the implementation brief. These are the names the
21//!    decision layer reasons in: `--rio-brand-light`, `-dark`,
22//!    `-adaptive`, `-surface`, `-accent`, `-hover`, `-active`,
23//!    `-tint`, `-text`.
24//!
25//! 2. **Drop-in compatibility tokens** — the names the live admin
26//!    templates already consume (`--rio-accent*`, the surface ladder,
27//!    the text ladder, the border ladder, semantic backgrounds).
28//!    Without this block the generated file would not actually drop
29//!    into the framework's CSS bundle. Brand-derived tokens
30//!    (`--rio-accent`, `--rio-accent-hover`, `--rio-accent-soft`,
31//!    `--rio-accent-border`, `--rio-bg`, `--rio-border`,
32//!    `--rio-info-bg`) come from the resolved `ThemeTokens`; the
33//!    slate scaffold (`--rio-surface-*`, `--rio-text-*`,
34//!    `--rio-border-soft`, `--rio-border-strong`) is brand-agnostic
35//!    by design — mirrors the live `colors.css` so a brand swap does
36//!    not move the chrome ladder out from under existing components.
37
38use std::fmt::Write as _;
39
40use crate::color::Color;
41use crate::contrast::{contrast_ratio, AA_TEXT};
42use crate::engine::ThemeTokens;
43
44/// How the emitted `tokens.css` should handle dark mode.
45///
46/// The generated file is **appended after** the framework's baked CSS
47/// bundle, so a `:root`-only override (light values, no dark blocks)
48/// ties the framework's `[data-theme="dark"]` on specificity and wins
49/// by source order — leaking light surfaces into dark mode. To avoid
50/// that, `emit` always writes the dual dark structure the framework
51/// uses: an explicit `:root[data-theme="dark"]` block (higher
52/// specificity, tie-breaks the toggle) **and** a
53/// `@media (prefers-color-scheme: dark) { :root { … } }` auto block
54/// (later source order than the plain `:root`, so OS-dark without an
55/// explicit toggle is covered too). This enum decides what *values*
56/// those dark blocks carry.
57#[derive(Debug, Clone, Copy, PartialEq, Default)]
58pub enum DarkPolicy {
59    /// Auto-derive dark from the resolved light tokens: a brand-agnostic
60    /// slate ladder (inverted surfaces, slate text) with the brand
61    /// accent **lifted** until it clears `AA_TEXT` on the dark surface.
62    /// The default.
63    #[default]
64    Auto,
65    /// The brand is light-only. Dark pins to the framework's default
66    /// dark palette (slate surfaces + the framework's lifted teal
67    /// accent) — a *neutral* dark override, never light values. Use
68    /// this to opt out of brand color in dark rather than leak light.
69    LightOnly,
70    /// Explicit dark accent supplied by the brand author; the dark
71    /// blocks lift it for `AA_TEXT` on the dark surface. Surfaces and
72    /// text stay on the framework slate ladder (brand-agnostic, exactly
73    /// as the light `:root` block keeps the chrome ladder brand-agnostic).
74    Explicit {
75        /// The author's chosen dark-mode accent (pre-lift).
76        accent: Color,
77    },
78}
79
80/// Render a drop-in `tokens.css` from a fully resolved `ThemeTokens`.
81///
82/// The single `:root` block carries both the canonical `--rio-brand-*`
83/// vocabulary the engine reasons in, and the legacy `--rio-*` names
84/// the live admin templates already consume. See the module docs for
85/// the rationale behind keeping both.
86pub fn emit(tokens: &ThemeTokens) -> String {
87    emit_with(tokens, &DarkPolicy::Auto)
88}
89
90/// Render a drop-in `tokens.css` with an explicit [`DarkPolicy`].
91///
92/// `emit` is the `DarkPolicy::Auto` shorthand. Both always write the
93/// dual dark structure (explicit `[data-theme="dark"]` + `@media`
94/// auto block) so the override never leaks light into dark mode.
95pub fn emit_with(tokens: &ThemeTokens, dark: &DarkPolicy) -> String {
96    let mut s = String::new();
97    s.push_str("/* Generated by rio-theme. Do not edit by hand. */\n");
98    s.push_str(":root {\n");
99
100    // --- Canonical engine output (DESIGN_THEME §11) ---
101    s.push_str("  /* canonical brand-* tokens (engine output) */\n");
102    line(&mut s, "--rio-brand-light", &tokens.brand_light.to_hex());
103    line(&mut s, "--rio-brand-dark", &tokens.brand_dark.to_hex());
104    s.push_str("  --rio-brand-adaptive: var(--rio-brand-light);\n");
105    line(
106        &mut s,
107        "--rio-brand-surface",
108        &tokens.brand_surface.to_hex(),
109    );
110    line(&mut s, "--rio-brand-accent", &tokens.brand_accent.to_hex());
111    line(
112        &mut s,
113        "--rio-brand-secondary",
114        &tokens.brand_secondary.to_hex(),
115    );
116    line(&mut s, "--rio-brand-hover", &tokens.brand_hover.to_hex());
117    line(&mut s, "--rio-brand-active", &tokens.brand_active.to_hex());
118    line(&mut s, "--rio-brand-tint", &tokens.brand_tint.to_hex());
119    line(&mut s, "--rio-brand-text", &tokens.brand_text.to_hex());
120    line(&mut s, "--rio-muted", &tokens.muted.to_hex());
121
122    // --- Drop-in compatibility for the live admin template ---
123    s.push('\n');
124    s.push_str("  /* drop-in aliases for the live admin template */\n");
125
126    // Brand-derived aliases. The live admin uses `--rio-accent` for
127    // BUTTONS and other large affordances, so it must track the
128    // tamed `brand_surface` (canonical "large fills" role), not the
129    // raw `brand_accent` (canonical "small touches"). For non-vivid
130    // inputs the two are equal so this is a no-op; for neon inputs
131    // it's the difference between a usable button and an unreadable
132    // one.
133    line(&mut s, "--rio-accent", &tokens.brand_surface.to_hex());
134    line(&mut s, "--rio-accent-hover", &tokens.brand_hover.to_hex());
135    line(
136        &mut s,
137        "--rio-accent-rgb",
138        &rgb_triple(&tokens.brand_surface),
139    );
140    line(&mut s, "--rio-accent-soft", &tokens.brand_tint.to_hex());
141    // accent-border: a mid-light brand tint. Live framework uses
142    // this for focus rings + input borders, where the visual job is
143    // "lighter than the brand but the same family". Always lightened
144    // brand-surface — secondary brand colors don't fit this role
145    // (they're for badges/dots, surfaced as `--rio-brand-secondary`).
146    line(
147        &mut s,
148        "--rio-accent-border",
149        &tokens.brand_surface.lighten(0.65).to_hex(),
150    );
151    line(&mut s, "--rio-bg", &tokens.bg.to_hex());
152
153    // Slate scaffold — brand-agnostic. Values lifted from the live
154    // `colors.css` so generated themes inherit the same depth metaphor
155    // and chrome relationship.
156    line(&mut s, "--rio-surface", "#ffffff");
157    line(&mut s, "--rio-surface-2", "#f8fafc");
158    line(&mut s, "--rio-surface-3", "#f1f5f9");
159    line(&mut s, "--rio-surface-chrome", "#0f172a");
160    line(&mut s, "--rio-surface-elevated", "#ffffff");
161
162    line(&mut s, "--rio-text-strong", "#0f172a");
163    line(&mut s, "--rio-text", "#1e293b");
164    line(&mut s, "--rio-text-muted", "#475569");
165    line(&mut s, "--rio-text-subtle", "#64748b");
166
167    line(&mut s, "--rio-border-soft", "#e2e8f0");
168    line(&mut s, "--rio-border", &tokens.border.to_hex());
169    line(&mut s, "--rio-border-strong", "#94a3b8");
170
171    // Semantic foreground + matching soft backgrounds. Backgrounds
172    // are computed from the (possibly hue-shifted) foregrounds, so a
173    // shifted danger keeps a matching shifted danger-bg.
174    line(&mut s, "--rio-success", &tokens.success.to_hex());
175    line(&mut s, "--rio-warning", &tokens.warning.to_hex());
176    line(&mut s, "--rio-danger", &tokens.danger.to_hex());
177    line(
178        &mut s,
179        "--rio-success-bg",
180        &soft_bg(&tokens.success).to_hex(),
181    );
182    line(
183        &mut s,
184        "--rio-warning-bg",
185        &soft_bg(&tokens.warning).to_hex(),
186    );
187    line(&mut s, "--rio-danger-bg", &soft_bg(&tokens.danger).to_hex());
188    line(&mut s, "--rio-info-bg", &tokens.brand_tint.to_hex());
189
190    // Chart series.
191    for (i, c) in tokens.chart.iter().enumerate() {
192        let name = format!("--rio-chart-{}", i + 1);
193        line(&mut s, &name, &c.to_hex());
194    }
195
196    s.push_str("}\n\n");
197
198    // --- Dark theme: dual blocks, mirroring the framework's guards ---
199    // 1. `:root[data-theme="dark"]` — explicit toggle. Higher specificity
200    //    than the framework's `[data-theme="dark"]`, so it tie-breaks.
201    // 2. `@media (prefers-color-scheme: dark) { :root }` — OS auto. Later
202    //    source order than this file's own `:root`, so it wins the auto case.
203    // Both carry identical values so the two paths render the same.
204    let palette = dark_palette(tokens, dark);
205    s.push_str(":root[data-theme=\"dark\"] {\n");
206    write_palette(&mut s, &palette, "  ");
207    s.push_str("}\n\n");
208    s.push_str("@media (prefers-color-scheme: dark) {\n");
209    s.push_str("  :root {\n");
210    write_palette(&mut s, &palette, "    ");
211    s.push_str("  }\n");
212    s.push_str("}\n");
213    s
214}
215
216/// Brand-agnostic slate ladder for dark mode (surfaces, text, borders).
217/// Mirrors the framework's Phase-8 `[data-theme="dark"]` palette so a
218/// generated override sits consistently on top of it. The accent is the
219/// only [`DarkPolicy`]-dependent part (see [`dark_palette`]).
220const DARK_BG: &str = "#0f172a";
221const DARK_SURFACE: &str = "#1e293b";
222
223/// Compute the ordered (token, value) pairs for the dark blocks.
224///
225/// Surfaces / text / borders / semantics are the brand-agnostic slate
226/// ladder (identical for every policy, exactly as the light `:root`
227/// block keeps the chrome ladder brand-agnostic). Only the accent set
228/// varies: lifted brand (`Auto`), framework default (`LightOnly`), or a
229/// lifted explicit color (`Explicit`).
230fn dark_palette(tokens: &ThemeTokens, dark: &DarkPolicy) -> Vec<(&'static str, String)> {
231    let dark_surface = Color::from_hex(DARK_SURFACE).expect("constant");
232    let dark_bg = Color::from_hex(DARK_BG).expect("constant");
233
234    // Accent for dark, per policy.
235    let (accent, accent_hover) = match dark {
236        // Pin to the framework's default dark accent (lifted teal). No
237        // brand color leaks into dark — a neutral override.
238        DarkPolicy::LightOnly => (
239            Color::from_hex("#2dd4bf").expect("constant"),
240            Color::from_hex("#5eead4").expect("constant"),
241        ),
242        // Lift the brand's large-fill accent until it clears AA on dark.
243        DarkPolicy::Auto => {
244            let a = lift_for_dark(&tokens.brand_surface, &dark_surface);
245            (a, a.lighten(0.12))
246        }
247        // Lift the author's explicit dark accent the same way.
248        DarkPolicy::Explicit { accent } => {
249            let a = lift_for_dark(accent, &dark_surface);
250            (a, a.lighten(0.12))
251        }
252    };
253
254    // Semantics (lifted for dark) — brand-agnostic, like the surfaces.
255    let success = Color::from_hex("#6fb98a").expect("constant");
256    let warning = Color::from_hex("#d6a65a").expect("constant");
257    let danger = Color::from_hex("#ef4444").expect("constant");
258
259    vec![
260        // Canonical adaptive pointer (the one token the old dark block set).
261        ("--rio-brand-adaptive", "var(--rio-brand-dark)".to_string()),
262        // Accent set (policy-dependent).
263        ("--rio-accent", accent.to_hex()),
264        ("--rio-accent-hover", accent_hover.to_hex()),
265        ("--rio-accent-rgb", rgb_triple(&accent)),
266        ("--rio-accent-soft", accent.mix(&dark_bg, 0.86).to_hex()),
267        (
268            "--rio-accent-border",
269            accent.mix(&dark_surface, 0.35).to_hex(),
270        ),
271        ("--rio-info-bg", accent.mix(&dark_bg, 0.86).to_hex()),
272        // Surface ladder (brand-agnostic slate, inverted from light).
273        ("--rio-bg", DARK_BG.to_string()),
274        ("--rio-surface", DARK_SURFACE.to_string()),
275        ("--rio-surface-2", "#0b1120".to_string()),
276        ("--rio-surface-3", "#334155".to_string()),
277        ("--rio-surface-chrome", "#0f172a".to_string()),
278        ("--rio-surface-elevated", "#1e293b".to_string()),
279        // Text ladder (slate, light end).
280        ("--rio-text-strong", "#f1f5f9".to_string()),
281        ("--rio-text", "#cbd5e1".to_string()),
282        ("--rio-text-muted", "#94a3b8".to_string()),
283        ("--rio-text-subtle", "#64748b".to_string()),
284        // Borders.
285        ("--rio-border-soft", "#334155".to_string()),
286        ("--rio-border", "#334155".to_string()),
287        ("--rio-border-strong", "#64748b".to_string()),
288        // Semantics + their dark soft backgrounds.
289        ("--rio-success", success.to_hex()),
290        ("--rio-warning", warning.to_hex()),
291        ("--rio-danger", danger.to_hex()),
292        ("--rio-success-bg", success.mix(&dark_bg, 0.84).to_hex()),
293        ("--rio-warning-bg", warning.mix(&dark_bg, 0.84).to_hex()),
294        ("--rio-danger-bg", danger.mix(&dark_bg, 0.84).to_hex()),
295    ]
296}
297
298/// Write an ordered token list at a fixed indent.
299fn write_palette(s: &mut String, palette: &[(&'static str, String)], indent: &str) {
300    for (name, value) in palette {
301        let _ = writeln!(s, "{indent}{name}: {value};");
302    }
303}
304
305/// Lift an accent toward white until it clears `AA_TEXT` (4.5:1) against
306/// the dark surface, so links / accent text are readable on dark.
307/// Bounded; returns the best effort if 4.5 is unreachable.
308fn lift_for_dark(accent: &Color, dark_surface: &Color) -> Color {
309    let mut a = *accent;
310    for _ in 0..40 {
311        if contrast_ratio(&a, dark_surface) >= AA_TEXT {
312            return a;
313        }
314        a = a.lighten(0.05);
315    }
316    a
317}
318
319fn line(s: &mut String, name: &str, value: &str) {
320    // Fixed two-space indent and a single space after the colon. No
321    // alignment by length — golden-file stability beats prettiness.
322    let _ = writeln!(s, "  {name}: {value};");
323}
324
325/// Space-separated R G B 0..255 triple, matching the live
326/// `--rio-accent-rgb` convention (so `rgb(var(--rio-accent-rgb) / 0.2)`
327/// keeps working in alpha-tinted overlays).
328fn rgb_triple(color: &Color) -> String {
329    // Reparse the hex to get the quantized channels — guarantees the
330    // RGB triple agrees with the hex value the file already prints.
331    let hex = color.to_hex();
332    let r = u8::from_str_radix(&hex[1..3], 16).expect("emitted hex is valid");
333    let g = u8::from_str_radix(&hex[3..5], 16).expect("emitted hex is valid");
334    let b = u8::from_str_radix(&hex[5..7], 16).expect("emitted hex is valid");
335    format!("{r} {g} {b}")
336}
337
338/// Soft pill background derived from a semantic foreground.
339///
340/// Starts at 92% white (visually a soft tint in the foreground's hue
341/// family) and walks lighter in 1% increments until the foreground
342/// clears `AA_NON_TEXT` (3.0) against the resulting background.
343/// Bounded at 99% so we never collapse to pure white. The
344/// hand-tuned tailwind `-50` colors (`#ECFDF5`, `#FFFBEB`,
345/// `#FEF2F2`) pass at slightly lighter mixes than a flat 92% would
346/// produce — without the loop, amber warning-on-bg lands at 2.94,
347/// below the 3.0 threshold for pill text.
348fn soft_bg(fg: &Color) -> Color {
349    let white = Color::from_hex("#ffffff").expect("constant");
350    let mut amount = 0.92_f64;
351    loop {
352        let bg = fg.mix(&white, amount);
353        if amount >= 0.99
354            || crate::contrast::contrast_ratio(fg, &bg) >= crate::contrast::AA_NON_TEXT
355        {
356            return bg;
357        }
358        amount += 0.01;
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::engine::{resolve_theme, ThemeInput};
366
367    #[test]
368    fn emit_contains_every_canonical_brand_token() {
369        let css = emit(&resolve_theme(ThemeInput::empty()));
370        for name in [
371            "--rio-brand-light",
372            "--rio-brand-dark",
373            "--rio-brand-adaptive",
374            "--rio-brand-surface",
375            "--rio-brand-accent",
376            "--rio-brand-secondary",
377            "--rio-brand-hover",
378            "--rio-brand-active",
379            "--rio-brand-tint",
380            "--rio-brand-text",
381            "--rio-muted",
382        ] {
383            assert!(css.contains(name), "missing canonical {name}");
384        }
385    }
386
387    #[test]
388    fn emit_contains_every_live_template_token() {
389        // If this list ever drifts from the live `colors.css`, the
390        // generated file stops being a drop-in. Update both together.
391        let css = emit(&resolve_theme(ThemeInput::empty()));
392        for name in [
393            "--rio-accent",
394            "--rio-accent-hover",
395            "--rio-accent-rgb",
396            "--rio-accent-soft",
397            "--rio-accent-border",
398            "--rio-bg",
399            "--rio-surface",
400            "--rio-surface-2",
401            "--rio-surface-3",
402            "--rio-surface-chrome",
403            "--rio-surface-elevated",
404            "--rio-text-strong",
405            "--rio-text",
406            "--rio-text-muted",
407            "--rio-text-subtle",
408            "--rio-border-soft",
409            "--rio-border",
410            "--rio-border-strong",
411            "--rio-success",
412            "--rio-warning",
413            "--rio-danger",
414            "--rio-success-bg",
415            "--rio-warning-bg",
416            "--rio-danger-bg",
417            "--rio-info-bg",
418        ] {
419            assert!(css.contains(name), "missing drop-in alias {name}");
420        }
421    }
422
423    #[test]
424    fn accent_rgb_triple_agrees_with_accent_hex() {
425        // The triple is what `rgb(var(--rio-accent-rgb) / α)` uses,
426        // so it must quantize to the same bytes as `--rio-accent`'s
427        // hex. Drop-in `--rio-accent` aliases `brand_surface` (see
428        // the comment in `emit`).
429        let tokens = resolve_theme(ThemeInput::empty());
430        let css = emit(&tokens);
431        let hex = tokens.brand_surface.to_hex();
432        let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
433        let g = u8::from_str_radix(&hex[3..5], 16).unwrap();
434        let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
435        let expected = format!("--rio-accent-rgb: {r} {g} {b};");
436        assert!(css.contains(&expected), "expected `{expected}` in:\n{css}");
437    }
438
439    #[test]
440    fn dark_block_is_always_emitted() {
441        let css = emit(&resolve_theme(ThemeInput::empty()));
442        assert!(css.contains(":root[data-theme=\"dark\"]"));
443    }
444
445    /// Both dark guards must be present so the explicit toggle AND OS
446    /// auto-dark are covered — the whole point of the dual structure.
447    #[test]
448    fn dark_emits_both_explicit_and_auto_blocks() {
449        for policy in [
450            DarkPolicy::Auto,
451            DarkPolicy::LightOnly,
452            DarkPolicy::Explicit {
453                accent: Color::from_hex("#0E6B5B").unwrap(),
454            },
455        ] {
456            let css = emit_with(&resolve_theme(ThemeInput::empty()), &policy);
457            assert!(
458                css.contains(":root[data-theme=\"dark\"] {"),
459                "{policy:?}: missing explicit dark block"
460            );
461            assert!(
462                css.contains("@media (prefers-color-scheme: dark) {"),
463                "{policy:?}: missing auto dark block"
464            );
465        }
466    }
467
468    /// Whatever the policy, the dark blocks must re-assert a dark
469    /// `--rio-bg` — never let the light `:root` value leak through.
470    #[test]
471    fn dark_never_leaks_light_background() {
472        let tokens = resolve_theme(ThemeInput {
473            brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
474        });
475        for policy in [DarkPolicy::Auto, DarkPolicy::LightOnly] {
476            let css = emit_with(&tokens, &policy);
477            // The dark blocks set --rio-bg to the slate background.
478            assert!(
479                css.matches("--rio-bg: #0f172a;").count() >= 2,
480                "{policy:?}: dark blocks must pin --rio-bg to #0f172a (got:\n{css})"
481            );
482        }
483    }
484
485    /// `Auto` lifts the brand accent until it clears AA on the dark
486    /// surface — readable links/accent on dark.
487    #[test]
488    fn dark_auto_accent_clears_aa_on_dark_surface() {
489        let tokens = resolve_theme(ThemeInput {
490            brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
491        });
492        let pal = dark_palette(&tokens, &DarkPolicy::Auto);
493        let accent_hex = pal
494            .iter()
495            .find(|(n, _)| *n == "--rio-accent")
496            .map(|(_, v)| v.clone())
497            .unwrap();
498        let accent = Color::from_hex(&accent_hex).unwrap();
499        let dark_surface = Color::from_hex(DARK_SURFACE).unwrap();
500        assert!(
501            contrast_ratio(&accent, &dark_surface) >= AA_TEXT - 0.01,
502            "auto dark accent {accent_hex} only {:.2} on dark surface",
503            contrast_ratio(&accent, &dark_surface)
504        );
505    }
506
507    /// `LightOnly` pins the dark accent to the framework default — the
508    /// brand color does not appear in dark at all.
509    #[test]
510    fn dark_light_only_pins_framework_accent() {
511        let tokens = resolve_theme(ThemeInput {
512            brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
513        });
514        let css = emit_with(&tokens, &DarkPolicy::LightOnly);
515        // Light `:root` keeps the brand accent; the two dark blocks pin
516        // to the framework default teal — so the brand never appears in
517        // dark. Framework default shows up exactly twice (both blocks).
518        assert_eq!(
519            css.matches("--rio-accent: #2dd4bf;").count(),
520            2,
521            "LightOnly must pin both dark blocks to the framework accent"
522        );
523    }
524
525    /// `Explicit` derives the dark accent from the author's supplied
526    /// color (lifted for AA), independent of the brand input.
527    #[test]
528    fn dark_explicit_uses_supplied_accent() {
529        let tokens = resolve_theme(ThemeInput {
530            brand_colors: vec![Color::from_hex("#0E6B5B").unwrap()],
531        });
532        let explicit = Color::from_hex("#0d9488").unwrap();
533        let pal = dark_palette(&tokens, &DarkPolicy::Explicit { accent: explicit });
534        let accent_hex = pal
535            .iter()
536            .find(|(n, _)| *n == "--rio-accent")
537            .unwrap()
538            .1
539            .clone();
540        let accent = Color::from_hex(&accent_hex).unwrap();
541        let dark_surface = Color::from_hex(DARK_SURFACE).unwrap();
542        // Lifted version of the explicit color, AA-clear on dark.
543        assert!(contrast_ratio(&accent, &dark_surface) >= AA_TEXT - 0.01);
544        // …and it is NOT the LightOnly framework default.
545        assert_ne!(accent_hex, "#2dd4bf");
546    }
547
548    #[test]
549    fn soft_bg_always_clears_aa_non_text_against_its_foreground() {
550        // Property test for the contrast-aware `soft_bg` loop.
551        // Regression for the bug verification surfaced: a flat 92%
552        // white mix left amber warning at 2.94 (below AA-large 3.0)
553        // against its derived background. Every semantic bg must
554        // now clear 3.0 against its fg.
555        use crate::color::Color;
556        use crate::contrast::{contrast_ratio, AA_NON_TEXT};
557        for brand_hex in [
558            "#3f6089", "#0d9488", "#39ff14", "#0a1a2e", "#c9572e", "#888888", "#dc2626",
559        ] {
560            let tokens = resolve_theme(ThemeInput {
561                brand_colors: vec![Color::from_hex(brand_hex).unwrap()],
562            });
563            for (name, fg) in [
564                ("success", tokens.success),
565                ("warning", tokens.warning),
566                ("danger", tokens.danger),
567            ] {
568                let bg = super::soft_bg(&fg);
569                let r = contrast_ratio(&fg, &bg);
570                assert!(
571                    r >= AA_NON_TEXT - 0.01,
572                    "brand {brand_hex}: {name} {} on derived bg {} only {r:.2}",
573                    fg.to_hex(),
574                    bg.to_hex(),
575                );
576            }
577        }
578    }
579
580    #[test]
581    fn chart_tokens_index_from_one() {
582        use crate::color::Color;
583        let css = emit(&resolve_theme(ThemeInput {
584            brand_colors: vec![
585                Color::from_hex("#3f6089").unwrap(),
586                Color::from_hex("#c9572e").unwrap(),
587                Color::from_hex("#2e7d5b").unwrap(),
588            ],
589        }));
590        assert!(css.contains("--rio-chart-1"));
591        assert!(!css.contains("--rio-chart-0"));
592    }
593}