Skip to main content

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}