//! Theme and colour palette. //! //! Two themes are provided — one for light terminal backgrounds //! and one for dark — per NFR-7. The palette is intentionally //! small for the walking skeleton; it grows as more views are //! added. Contrast is chosen against the target background so //! that foreground text meets WCAG-AA (NFR-5) on both variants. //! //! Per-token-class colours (the `tok_*` fields) drive ambient //! typing assistance (ADR-0022 §3). Each token class has a //! distinct colour so the user can tell keywords from //! identifiers from string literals at a glance, with punct and //! identifier intentionally close to `fg` to keep the surface //! quiet for the dominant content. The `tok_error` colour //! reuses the existing error palette so lex-error tokens and //! parse-error overlays read consistently with `[error]` //! lines elsewhere. use ratatui::style::Color; use crate::dsl::grammar::HighlightClass; /// Foreground of the demonstration-mode overlays (ADR-0047 D4). /// /// Deliberately a fixed, theme-independent high-contrast pair — black /// on yellow — so the badge / caption boxes are hard to overlook in a /// screencast on any background. pub const DEMO_OVERLAY_FG: Color = Color::Black; /// Background of the demonstration-mode overlays (ADR-0047 D4); see /// [`DEMO_OVERLAY_FG`]. pub const DEMO_OVERLAY_BG: Color = Color::Rgb(0xFF, 0xD7, 0x00); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Background { Light, Dark, } #[derive(Debug, Clone)] pub struct Theme { pub background: Background, pub bg: Color, pub fg: Color, pub muted: Color, pub border: Color, pub border_advanced: Color, pub mode_simple: Color, pub mode_advanced: Color, pub system: Color, pub error: Color, /// Validity-indicator WARNING colour (ADR-0027 §4) — an /// amber distinct from `error`'s red. Drives the `[WRN]` /// label; `[ERR]` reuses `error`. Also the query-plan /// "expensive step" colour (ADR-0028 §6). pub warning: Color, /// Query-plan "efficient step" colour (ADR-0028 §6) — a /// green deliberately distinct from `system` so green never /// reads as two things. Indexed lookups carry it; expensive /// steps reuse `warning`. pub plan_efficient: Color, // ---- Per-token-class colours (ADR-0022 §3) ------------------- pub tok_keyword: Color, pub tok_identifier: Color, /// Column data-type keyword colour (ADR-0022 Amendment 4) — /// a dedicated tone distinct from both `tok_keyword` and /// `tok_identifier` so a learner can tell a type from a /// clause keyword or an invented name at a glance. pub tok_type: Color, pub tok_number: Color, pub tok_string: Color, pub tok_punct: Color, pub tok_flag: Color, pub tok_error: Color, /// SQL function-name candidate colour (ADR-0022 Amendment 6, /// issue #15) — a dedicated tone distinct from `tok_keyword`, /// `tok_identifier`, and `tok_type` so a learner can tell a /// callable (`sum`, `upper`) apart from a clause keyword, a /// column reference, and a column type. Drives the /// `CandidateKind::Function` colour in the hint panel. pub tok_function: Color, } impl Theme { #[must_use] pub const fn dark() -> Self { Self { background: Background::Dark, bg: Color::Rgb(0x18, 0x1B, 0x22), fg: Color::Rgb(0xE6, 0xE6, 0xE6), muted: Color::Rgb(0x8B, 0x90, 0x9A), border: Color::Rgb(0x4A, 0x52, 0x65), border_advanced: Color::Rgb(0xE0, 0x60, 0x60), mode_simple: Color::Rgb(0x6E, 0xC4, 0xFF), mode_advanced: Color::Rgb(0xFF, 0x9E, 0x6B), system: Color::Rgb(0x9F, 0xD8, 0x91), error: Color::Rgb(0xFF, 0x6B, 0x6B), warning: Color::Rgb(0xF5, 0xA9, 0x4B), // amber plan_efficient: Color::Rgb(0x4D, 0xD0, 0xA8), // teal-green // Token classes — distinct enough to tell apart at a // glance, quiet enough that 80-char lines don't read // like a Christmas tree. Identifier and punct sit // close to `fg`/`muted` so the dominant content // remains restful; literals and flags get warm // accent tones; keyword takes a cool accent tone // distinct from the mode-banner blue. tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour tok_type: Color::Rgb(0xF5, 0x8A, 0xAE), // rose-pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier (ADR-0057: widened ΔE from keyword 14.5→18.4) tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error tok_function: Color::Rgb(0x6C, 0xB2, 0xFF), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + rose type (ADR-0057: widened ΔE from identifier 14.3→19.5) } } #[must_use] pub const fn light() -> Self { Self { background: Background::Light, bg: Color::Rgb(0xFA, 0xFA, 0xF7), fg: Color::Rgb(0x1A, 0x1F, 0x2C), muted: Color::Rgb(0x60, 0x66, 0x73), border: Color::Rgb(0xB6, 0xBC, 0xC8), border_advanced: Color::Rgb(0xC2, 0x3A, 0x3A), mode_simple: Color::Rgb(0x21, 0x69, 0xC7), mode_advanced: Color::Rgb(0xB0, 0x4A, 0x12), system: Color::Rgb(0x2E, 0x7C, 0x3C), error: Color::Rgb(0xC0, 0x39, 0x2B), warning: Color::Rgb(0xA6, 0x5A, 0x00), // burnt amber plan_efficient: Color::Rgb(0x0B, 0x80, 0x6A), // deep teal-green // Light-theme token palette: same intent as dark — // identifier/punct close to fg/muted; warm tones for // literals + flags; cool accent for keyword. tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange tok_string: Color::Rgb(0x1B, 0x7A, 0x33), // forest green (ADR-0057: darkened for WCAG-AA, 4.42→5.18:1) tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted tok_flag: Color::Rgb(0x7A, 0x5C, 0x00), // dark mustard/olive (ADR-0057: darkened for WCAG-AA, 3.15→5.98:1) tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type } } /// Map a walker `HighlightClass` to its display colour /// (ADR-0024 §architecture, Phase F). This is the walker-side /// equivalent of `token_color` — the renderer consumes /// `walker::highlight_runs` output, which produces /// `HighlightClass` per byte range, and looks up colours /// through this method. #[must_use] pub const fn highlight_class_color(&self, class: HighlightClass) -> Color { match class { HighlightClass::Keyword => self.tok_keyword, HighlightClass::Identifier => self.tok_identifier, HighlightClass::Type => self.tok_type, HighlightClass::Number => self.tok_number, HighlightClass::String => self.tok_string, HighlightClass::Punct => self.tok_punct, HighlightClass::Flag => self.tok_flag, HighlightClass::Function => self.tok_function, HighlightClass::Error => self.tok_error, } } } impl Default for Theme { fn default() -> Self { Self::dark() } } #[cfg(test)] mod tests { use super::*; #[test] fn dark_theme_token_colours_differ_from_background() { let t = Theme::dark(); for (name, c) in [ ("tok_keyword", t.tok_keyword), ("tok_type", t.tok_type), ("tok_number", t.tok_number), ("tok_string", t.tok_string), ("tok_flag", t.tok_flag), ("tok_error", t.tok_error), ("tok_function", t.tok_function), ("warning", t.warning), ] { assert_ne!(c, t.bg, "{name} must contrast against bg in dark theme",); } } #[test] fn light_theme_token_colours_differ_from_background() { let t = Theme::light(); for (name, c) in [ ("tok_keyword", t.tok_keyword), ("tok_type", t.tok_type), ("tok_number", t.tok_number), ("tok_string", t.tok_string), ("tok_flag", t.tok_flag), ("tok_error", t.tok_error), ("tok_function", t.tok_function), ("warning", t.warning), ] { assert_ne!(c, t.bg, "{name} must contrast against bg in light theme",); } } #[test] fn highlight_class_color_maps_each_variant() { let t = Theme::dark(); assert_eq!( t.highlight_class_color(HighlightClass::Keyword), t.tok_keyword ); assert_eq!( t.highlight_class_color(HighlightClass::Identifier), t.tok_identifier ); assert_eq!(t.highlight_class_color(HighlightClass::Type), t.tok_type); assert_eq!( t.highlight_class_color(HighlightClass::Number), t.tok_number ); assert_eq!( t.highlight_class_color(HighlightClass::String), t.tok_string ); assert_eq!(t.highlight_class_color(HighlightClass::Punct), t.tok_punct); assert_eq!(t.highlight_class_color(HighlightClass::Flag), t.tok_flag); assert_eq!( t.highlight_class_color(HighlightClass::Function), t.tok_function ); assert_eq!(t.highlight_class_color(HighlightClass::Error), t.tok_error); } #[test] fn type_colour_is_distinct_from_keyword_and_identifier() { // ADR-0022 Amendment 4 / issue #8: the whole point of a // dedicated type class is that types do NOT share a colour // with clause keywords or invented identifiers. for t in [Theme::dark(), Theme::light()] { assert_ne!(t.tok_type, t.tok_keyword); assert_ne!(t.tok_type, t.tok_identifier); } } #[test] fn function_colour_is_distinct_from_keyword_identifier_and_type() { // ADR-0022 Amendment 6 / issue #15: function-name candidates // get their own tone so a callable reads apart from a clause // keyword, a column reference, and a column type. for t in [Theme::dark(), Theme::light()] { assert_ne!(t.tok_function, t.tok_keyword); assert_ne!(t.tok_function, t.tok_identifier); assert_ne!(t.tok_function, t.tok_type); } } // ---- NFR-5 / NFR-7 (ADR-0057): WCAG-AA contrast gate ---- // // Every text-bearing foreground must clear WCAG-AA 4.5:1 against // the panel background, in BOTH themes, so the colour scheme stays // legible on light and dark terminals (no dark-on-dark / light-on- // light failures). Borders are non-text structural chrome and are // handled by `advanced_mode_border_meets_ui_contrast` below; the // plain `border` is intentionally exempt (decorative — see ADR-0057). // // These checks rely on every `Theme` colour being `Color::Rgb` // (true-colour). `rgb_channels` panics on any other variant, which // also guards against a future regression to a named palette colour // (whose real contrast can't be known). fn rgb_channels(c: Color) -> (f64, f64, f64) { match c { Color::Rgb(r, g, b) => (f64::from(r), f64::from(g), f64::from(b)), other => panic!("theme colours must be Color::Rgb for contrast checks; got {other:?}"), } } /// WCAG 2.x relative luminance of an sRGB colour. // The luminance/Lab/CIEDE2000 maths below are published standards; we // keep them in canonical `a*x + b*y` form (verified against reference // vectors) rather than refactoring into `mul_add`, which would obscure // the formula for a negligible test-only gain. #[allow(clippy::suboptimal_flops)] fn relative_luminance(c: Color) -> f64 { let chan = |v: f64| { let v = v / 255.0; if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }; let (r, g, b) = rgb_channels(c); 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b) } /// WCAG 2.x contrast ratio between two colours (1.0 ..= 21.0). fn contrast_ratio(a: Color, b: Color) -> f64 { let (la, lb) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } fn theme_label(t: &Theme) -> &'static str { match t.background { Background::Dark => "dark", Background::Light => "light", } } /// Every text foreground (incl. dimmed `muted`/`tok_punct` and the /// syntax-token classes) on `bg`, both themes. fn text_foregrounds(t: &Theme) -> [(&'static str, Color); 17] { [ ("fg", t.fg), ("muted", t.muted), ("mode_simple", t.mode_simple), ("mode_advanced", t.mode_advanced), ("system", t.system), ("error", t.error), ("warning", t.warning), ("plan_efficient", t.plan_efficient), ("tok_keyword", t.tok_keyword), ("tok_identifier", t.tok_identifier), ("tok_type", t.tok_type), ("tok_number", t.tok_number), ("tok_string", t.tok_string), ("tok_punct", t.tok_punct), ("tok_flag", t.tok_flag), ("tok_error", t.tok_error), ("tok_function", t.tok_function), ] } #[test] fn all_text_colours_meet_wcag_aa_contrast() { for t in [Theme::dark(), Theme::light()] { let label = theme_label(&t); for (name, c) in text_foregrounds(&t) { let ratio = contrast_ratio(c, t.bg); assert!( ratio >= 4.5, "{label} theme: {name} contrast {ratio:.2}:1 on bg is below WCAG-AA 4.5:1", ); } } } #[test] fn advanced_mode_border_meets_ui_contrast() { // The advanced-mode border carries a mode-warning signal, so it // meets WCAG's 3:1 non-text / UI-component threshold in both // themes. The plain `border` is decorative structural chrome and // is intentionally exempt (recorded in ADR-0057). for t in [Theme::dark(), Theme::light()] { let label = theme_label(&t); let ratio = contrast_ratio(t.border_advanced, t.bg); assert!( ratio >= 3.0, "{label} theme: border_advanced contrast {ratio:.2}:1 below the 3:1 UI bar", ); } } // ---- NFR-5 perceptual distinctness (ADR-0057) ---- // // Contrast-against-background is necessary but not sufficient: two // token colours can each clear 4.5:1 yet be hard to tell APART from // each other (a real pain reported on high-quality screens). We lock // that in with a CIEDE2000 (ΔE2000) floor between every pair of // syntax-token classes that can share a line. The metric itself is // validated against the Sharma et al. reference vectors. /// sRGB `Color` → CIELAB (D65), for perceptual-difference checks. #[allow(clippy::suboptimal_flops)] fn rgb_to_lab(c: Color) -> (f64, f64, f64) { let lin = |v: f64| { let v = v / 255.0; if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }; let (r0, g0, b0) = rgb_channels(c); let (r, g, b) = (lin(r0), lin(g0), lin(b0)); let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.950_47; let y = r * 0.2126 + g * 0.7152 + b * 0.0722; let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.088_83; let f = |t: f64| { if t > 0.008_856 { t.cbrt() } else { 7.787 * t + 16.0 / 116.0 } }; let (fx, fy, fz) = (f(x), f(y), f(z)); (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)) } /// CIEDE2000 colour difference between two CIELAB values. #[allow(clippy::suboptimal_flops)] fn delta_e_2000(lab1: (f64, f64, f64), lab2: (f64, f64, f64)) -> f64 { let (l1, a1, b1) = lab1; let (l2, a2, b2) = lab2; let pow7 = |v: f64| v.powi(7); let k_l = pow7(25.0); let avg_lp = (l1 + l2) / 2.0; let c1 = a1.hypot(b1); let c2 = a2.hypot(b2); let avg_c = (c1 + c2) / 2.0; let g = if avg_c > 0.0 { 0.5 * (1.0 - (pow7(avg_c) / (pow7(avg_c) + k_l)).sqrt()) } else { 0.0 }; let a1p = (1.0 + g) * a1; let a2p = (1.0 + g) * a2; let c1p = a1p.hypot(b1); let c2p = a2p.hypot(b2); let avg_cp = (c1p + c2p) / 2.0; let hp = |ap: f64, b: f64| -> f64 { if ap == 0.0 && b == 0.0 { return 0.0; } let ang = b.atan2(ap).to_degrees(); if ang < 0.0 { ang + 360.0 } else { ang } }; let h1p = hp(a1p, b1); let h2p = hp(a2p, b2); let dlp = l2 - l1; let dcp = c2p - c1p; let dhp = if c1p * c2p == 0.0 { 0.0 } else if (h2p - h1p).abs() <= 180.0 { h2p - h1p } else if h2p - h1p > 180.0 { h2p - h1p - 360.0 } else { h2p - h1p + 360.0 }; let d_big_hp = 2.0 * (c1p * c2p).sqrt() * (dhp.to_radians() / 2.0).sin(); let avg_hp = if c1p * c2p == 0.0 { h1p + h2p } else if (h1p - h2p).abs() <= 180.0 { (h1p + h2p) / 2.0 } else if h1p + h2p < 360.0 { (h1p + h2p + 360.0) / 2.0 } else { (h1p + h2p - 360.0) / 2.0 }; let t = 1.0 - 0.17 * (avg_hp - 30.0).to_radians().cos() + 0.24 * (2.0 * avg_hp).to_radians().cos() + 0.32 * (3.0 * avg_hp + 6.0).to_radians().cos() - 0.20 * (4.0 * avg_hp - 63.0).to_radians().cos(); let d_ro = 30.0 * (-((avg_hp - 275.0) / 25.0).powi(2)).exp(); let rc = if avg_cp > 0.0 { 2.0 * (pow7(avg_cp) / (pow7(avg_cp) + k_l)).sqrt() } else { 0.0 }; let sl = 1.0 + (0.015 * (avg_lp - 50.0).powi(2)) / (20.0 + (avg_lp - 50.0).powi(2)).sqrt(); let sc = 1.0 + 0.045 * avg_cp; let sh = 1.0 + 0.015 * avg_cp * t; let rt = -(2.0 * d_ro.to_radians()).sin() * rc; ((dlp / sl).powi(2) + (dcp / sc).powi(2) + (d_big_hp / sh).powi(2) + rt * (dcp / sc) * (d_big_hp / sh)) .sqrt() } #[test] fn delta_e_2000_matches_reference_vectors() { // Sharma, Wu & Dalal (2005) CIEDE2000 test data — validates the // metric so the distinctness gate below rests on a correct base. let cases = [ ((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425), ((50.0, 3.1571, -77.2803), (50.0, 0.0, -82.7485), 2.8615), ((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0009), 7.1792), ((50.0, -1.0, 2.0), (50.0, 0.0, 0.0), 2.3669), ]; for (l1, l2, exp) in cases { let got = delta_e_2000(l1, l2); assert!( (got - exp).abs() < 0.01, "ΔE2000 {got:.4} != reference {exp:.4}", ); } } #[test] fn syntax_token_colours_are_perceptually_distinct() { // Every pair of syntax-token classes that can appear together on // one line must be perceptually separable, not merely unequal in // hex. Floor at ΔE2000 >= 15 (post-ADR-0057 minimum is ~18). // Excludes tok_punct (== muted) and tok_error (== error), which // deliberately alias other roles. const MIN_DE: f64 = 15.0; for t in [Theme::dark(), Theme::light()] { let label = theme_label(&t); let toks = [ ("tok_keyword", t.tok_keyword), ("tok_identifier", t.tok_identifier), ("tok_type", t.tok_type), ("tok_number", t.tok_number), ("tok_string", t.tok_string), ("tok_flag", t.tok_flag), ("tok_function", t.tok_function), ]; for (i, (name_a, ca)) in toks.iter().enumerate() { for (name_b, cb) in toks.iter().skip(i + 1) { let de = delta_e_2000(rgb_to_lab(*ca), rgb_to_lab(*cb)); assert!( de >= MIN_DE, "{label} theme: {name_a} vs {name_b} ΔE2000 {de:.1} below {MIN_DE} — too similar", ); } } } } }