rio_theme/engine.rs
1//! Pipeline orchestrator.
2//!
3//! Pure function `resolve_theme(ThemeInput) -> ThemeTokens`: every
4//! stage's input is the previous stage's output, no I/O, no globals.
5//! That purity is what makes the golden-file tests stable and what
6//! lets the CLI report each case's effect deterministically.
7//!
8//! Stage order matters. See §10 of the implementation brief.
9
10use crate::adaptive::{adaptive_brand, AdaptiveBrand};
11use crate::color::Color;
12use crate::contrast::{contrast_ratio, AA_NON_TEXT, AA_TEXT, DARK_BG, LIGHT_BG};
13use crate::derive::{derive_palette, DerivedPalette};
14use crate::guard::{readable_text, resolve_text_token};
15use crate::hierarchy::{assign_roles, RoleAssignment};
16use crate::semantic::{resolve_semantics, SemanticPalette};
17use crate::vivid::{split_vivid_roles, VividSplit};
18
19/// Safe default brand color used when the client supplies none
20/// (Case 7). A quiet blue-gray that passes contrast against white
21/// automatically and sits in a mid lightness band so it survives a
22/// future dark mode without adjustment.
23pub const DEFAULT_BRAND: &str = "#3f6089";
24
25/// The client's raw theme request.
26#[derive(Debug, Clone)]
27pub struct ThemeInput {
28 /// Zero or more raw brand colors, in stated priority order.
29 /// Empty inputs trip Case 7 and substitute [`DEFAULT_BRAND`].
30 pub brand_colors: Vec<Color>,
31}
32
33impl ThemeInput {
34 /// Convenience constructor for the empty input (Case 7).
35 pub fn empty() -> Self {
36 ThemeInput {
37 brand_colors: Vec::new(),
38 }
39 }
40}
41
42/// The fully-resolved, safe set of tokens the UI consumes. Field
43/// names match the `--rio-*` custom properties emitted by `emit.rs`.
44#[derive(Debug, Clone)]
45pub struct ThemeTokens {
46 /// Brand variant for light-mode surfaces.
47 pub brand_light: Color,
48 /// Brand variant for dark-mode surfaces.
49 pub brand_dark: Color,
50 /// Tamed brand for large fills (topbar, primary button bg).
51 pub brand_surface: Color,
52 /// Raw brand for small touches (icons, dots, focus rings).
53 pub brand_accent: Color,
54 /// Secondary brand from a multi-color input. For single-color
55 /// inputs this is a derived hover-darkened variant of the primary.
56 pub brand_secondary: Color,
57 /// Solid brand background for hover states.
58 pub brand_hover: Color,
59 /// Solid brand background for active / pressed states.
60 pub brand_active: Color,
61 /// Light brand-tinted surface (focus ring backgrounds, soft fills).
62 pub brand_tint: Color,
63 /// Brand-family text color usable on light surfaces.
64 pub brand_text: Color,
65 /// Page canvas (brand-tinted near-white).
66 pub bg: Color,
67 /// Hairline border in the brand family.
68 pub border: Color,
69 /// Muted neutral with a hint of brand temperature.
70 pub muted: Color,
71 /// Success semantic foreground.
72 pub success: Color,
73 /// Warning semantic foreground.
74 pub warning: Color,
75 /// Danger / destructive semantic foreground.
76 pub danger: Color,
77 /// Data-series fills. Empty for fewer than three brand inputs.
78 pub chart: Vec<Color>,
79}
80
81/// Per-case effects recorded during a pipeline run, intended for the
82/// CLI to surface to the developer. Building this alongside the
83/// tokens means the engine's reasoning is transparent — no separate
84/// "explain" pass that could diverge.
85#[derive(Debug, Clone, Default)]
86pub struct ResolveReport {
87 /// True when Case 7 (default-brand fallback) fired.
88 pub default_brand_used: bool,
89 /// True when Case 3 reduced chroma on the surface brand.
90 pub vivid_tamed: bool,
91 /// True when Case 5 nudged the light variant.
92 pub light_adjusted: bool,
93 /// True when Case 5 nudged the dark variant.
94 pub dark_adjusted: bool,
95 /// True when the (adjusted) light variant still doesn't clear AA.
96 pub light_still_failing: bool,
97 /// True when the (adjusted) dark variant still doesn't clear AA.
98 pub dark_still_failing: bool,
99 /// True when Case 1 had to substitute the text fallback.
100 pub text_substituted: bool,
101 /// True when the raw vivid accent failed `AA_NON_TEXT` against
102 /// the page bg and was substituted by the tamed surface.
103 pub accent_substituted: bool,
104 /// Brand vs LIGHT_BG contrast (post-adaptive).
105 pub light_contrast: f64,
106 /// Brand vs DARK_BG contrast (post-adaptive).
107 pub dark_contrast: f64,
108 /// brand_text vs bg contrast (after the Case 1 final pass).
109 pub text_on_bg_contrast: f64,
110 /// brand_accent vs bg contrast — non-text guard.
111 pub accent_on_bg_contrast: f64,
112 /// success/warning/danger contrast vs LIGHT_BG.
113 pub success_contrast: f64,
114 pub warning_contrast: f64,
115 pub danger_contrast: f64,
116}
117
118/// Top-level pipeline. Pure; same input always yields the same output.
119pub fn resolve_theme(input: ThemeInput) -> ThemeTokens {
120 let (tokens, _report) = resolve_theme_with_report(input);
121 tokens
122}
123
124/// Pipeline plus per-case effect log. The CLI uses this; tests use
125/// the report fields to assert which stages fired.
126pub fn resolve_theme_with_report(input: ThemeInput) -> (ThemeTokens, ResolveReport) {
127 let mut report = ResolveReport::default();
128
129 // --- Case 7: no brand → safe default ---
130 let brand_colors: Vec<Color> = if input.brand_colors.is_empty() {
131 report.default_brand_used = true;
132 vec![Color::from_hex(DEFAULT_BRAND).expect("constant")]
133 } else {
134 input.brand_colors
135 };
136
137 // --- Case 6: role assignment ---
138 let RoleAssignment {
139 primary,
140 secondary,
141 chart,
142 } = assign_roles(&brand_colors);
143
144 // --- Case 3: vivid split on primary ---
145 let VividSplit {
146 accent: raw_accent,
147 surface: surface_brand,
148 was_tamed,
149 } = split_vivid_roles(&primary);
150 report.vivid_tamed = was_tamed;
151
152 // --- Case 5: mode-adaptive on the surface variant ---
153 let AdaptiveBrand {
154 light: brand_light,
155 dark: brand_dark,
156 light_adjusted,
157 dark_adjusted,
158 light_clears_aa,
159 dark_clears_aa,
160 } = adaptive_brand(&surface_brand);
161 report.light_adjusted = light_adjusted;
162 report.dark_adjusted = dark_adjusted;
163 report.light_still_failing = !light_clears_aa;
164 report.dark_still_failing = !dark_clears_aa;
165
166 // --- Case 2: derived shades from the *tamed* surface (brief
167 // §10 step 5 — derive runs on `brand_surface`, not on the
168 // mode-adapted variant; otherwise hover/active drift
169 // differently in light vs dark mode).
170 let DerivedPalette {
171 brand: _,
172 brand_tint,
173 brand_hover,
174 brand_active,
175 brand_text,
176 bg,
177 border,
178 muted,
179 } = derive_palette(&surface_brand);
180
181 // --- Case 4: semantic anchors, pushed away from brand hue ---
182 let SemanticPalette {
183 success,
184 warning,
185 danger,
186 } = resolve_semantics(&primary);
187
188 // --- Case 1: final guard pass. Every text-on-surface pairing the
189 // engine is about to emit goes through `resolve_text_token`
190 // and any substitution is reflected in the report.
191 let safe_brand_text = resolve_text_token(&bg, &brand_text);
192 if safe_brand_text.to_hex() != brand_text.to_hex() {
193 report.text_substituted = true;
194 }
195
196 // brand_accent is a non-text role (icons/dots/borders). Threshold
197 // is AA_NON_TEXT (3.0). For vivid-tamed inputs this is where the
198 // raw neon would otherwise leak through.
199 let safe_brand_accent = if contrast_ratio(&bg, &raw_accent) >= AA_NON_TEXT {
200 raw_accent
201 } else {
202 // Substitute the tamed surface — same hue family, guaranteed
203 // to pass AA_NON_TEXT against light backgrounds because Case
204 // 3 already chose its lightness for that.
205 log::warn!(
206 "rio-theme: brand_accent {} fails AA_NON_TEXT on bg {} (ratio {:.2}); substituting tamed surface {}",
207 raw_accent.to_hex(),
208 bg.to_hex(),
209 contrast_ratio(&bg, &raw_accent),
210 surface_brand.to_hex(),
211 );
212 report.accent_substituted = true;
213 surface_brand
214 };
215
216 // Secondary role: multi-color inputs supply a real second colour,
217 // single-color inputs reuse the primary's hover-darkened variant.
218 // Either way the token is populated — the live admin's
219 // `--rio-accent-border` can lean on this.
220 let brand_secondary = if secondary.to_hex() == primary.to_hex() {
221 brand_hover
222 } else {
223 secondary
224 };
225
226 // Contrast measurements for the CLI report — post-resolution so
227 // the numbers match what the emitted tokens will actually produce.
228 let light_bg = Color::from_hex(LIGHT_BG).expect("constant");
229 let dark_bg = Color::from_hex(DARK_BG).expect("constant");
230 report.light_contrast = contrast_ratio(&light_bg, &brand_light);
231 report.dark_contrast = contrast_ratio(&dark_bg, &brand_dark);
232 report.text_on_bg_contrast = contrast_ratio(&bg, &safe_brand_text);
233 report.accent_on_bg_contrast = contrast_ratio(&bg, &safe_brand_accent);
234 report.success_contrast = contrast_ratio(&light_bg, &success);
235 report.warning_contrast = contrast_ratio(&light_bg, &warning);
236 report.danger_contrast = contrast_ratio(&light_bg, &danger);
237
238 let tokens = ThemeTokens {
239 brand_light,
240 brand_dark,
241 brand_surface: surface_brand,
242 brand_accent: safe_brand_accent,
243 brand_secondary,
244 brand_hover,
245 brand_active,
246 brand_tint,
247 brand_text: safe_brand_text,
248 bg,
249 border,
250 muted,
251 success,
252 warning,
253 danger,
254 chart,
255 };
256
257 // Guard sanity: every text-on-surface pairing we emit should
258 // satisfy at least AA_TEXT. The substitution above covers
259 // brand_text on bg; touch every other emitted text pairing here
260 // so future additions to ThemeTokens fail loudly in tests if
261 // they slip an unmeasured pair past the guard.
262 debug_assert!(
263 contrast_ratio(&tokens.bg, &tokens.brand_text) >= AA_TEXT - 0.01,
264 "brand_text {} fails AA on bg {}",
265 tokens.brand_text.to_hex(),
266 tokens.bg.to_hex(),
267 );
268 // The chrome surface (slate-900 in the drop-in scaffold) carries
269 // a near-white text. That pairing is fixed and known-safe; we
270 // don't recompute it here. If the scaffold ever becomes
271 // brand-derived, run `readable_text` on it the same way.
272 let _ = readable_text;
273
274 (tokens, report)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn empty_input_uses_default_brand() {
283 let (tokens, report) = resolve_theme_with_report(ThemeInput::empty());
284 assert!(report.default_brand_used);
285 assert!(!report.vivid_tamed);
286 assert!(!report.light_adjusted);
287 assert!(tokens.bg.l > 0.95);
288 }
289
290 #[test]
291 fn neon_input_trips_vivid_taming_and_guards_accent() {
292 let lime = Color::from_hex("#39ff14").unwrap();
293 let (tokens, report) = resolve_theme_with_report(ThemeInput {
294 brand_colors: vec![lime],
295 });
296 assert!(report.vivid_tamed);
297 // The drop-in accent must not be the raw neon when it would
298 // fail AA_NON_TEXT against the page bg.
299 let raw_accent_contrast = contrast_ratio(&tokens.bg, &lime);
300 if raw_accent_contrast < AA_NON_TEXT {
301 assert_ne!(
302 tokens.brand_accent.to_hex(),
303 lime.to_hex(),
304 "neon should have been substituted"
305 );
306 }
307 }
308
309 #[test]
310 fn two_color_input_populates_brand_secondary_distinctly() {
311 let (tokens, _) = resolve_theme_with_report(ThemeInput {
312 brand_colors: vec![
313 Color::from_hex("#3f6089").unwrap(),
314 Color::from_hex("#c9572e").unwrap(),
315 ],
316 });
317 // Both inputs must end up reachable via a token — primary
318 // surfaces as brand_surface, secondary as brand_secondary.
319 let surface_hex = tokens.brand_surface.to_hex();
320 let secondary_hex = tokens.brand_secondary.to_hex();
321 assert_ne!(surface_hex, secondary_hex);
322 }
323
324 #[test]
325 fn report_contrast_fields_are_populated() {
326 // The CLI surfaces these — a zero value would silently mean
327 // "we forgot to measure". Confirm all four are nonzero for a
328 // generic input.
329 let (_, report) = resolve_theme_with_report(ThemeInput::empty());
330 assert!(report.light_contrast > 0.0);
331 assert!(report.dark_contrast > 0.0);
332 assert!(report.text_on_bg_contrast > 0.0);
333 assert!(report.accent_on_bg_contrast > 0.0);
334 assert!(report.success_contrast > 0.0);
335 assert!(report.warning_contrast > 0.0);
336 assert!(report.danger_contrast > 0.0);
337 }
338
339 #[test]
340 fn brand_text_always_clears_aa_on_bg() {
341 // The central guarantee of Case 1. Run it against varied
342 // brands, including the failure-prone neon, dark navy, and
343 // the default.
344 for hex in [
345 "#3f6089", "#0d9488", "#39ff14", "#0a1a2e", "#c9572e", "#888888",
346 ] {
347 let brand = Color::from_hex(hex).unwrap();
348 let tokens = resolve_theme(ThemeInput {
349 brand_colors: vec![brand],
350 });
351 let ratio = contrast_ratio(&tokens.bg, &tokens.brand_text);
352 assert!(
353 ratio >= AA_TEXT - 0.01,
354 "brand={hex}: brand_text {} on bg {} only {ratio:.2}",
355 tokens.brand_text.to_hex(),
356 tokens.bg.to_hex(),
357 );
358 }
359 }
360}