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}