Skip to main content

coding_agent_search/ui/
style_system.rs

1//! FrankenTUI style-system scaffolding for cass.
2//!
3//! Centralizes:
4//! - theme preset selection (18 gorgeous built-in presets)
5//! - color profile downgrade (mono / ansi16 / ansi256 / truecolor)
6//! - env opt-outs (`NO_COLOR`, `CASS_NO_COLOR`, `CASS_NO_ICONS`, `CASS_NO_GRADIENT`)
7//! - accessibility text markers (`CASS_A11Y`)
8//! - semantic `StyleSheet` tokens consumed by all ftui views
9//! - [`StyleContext`] facade for theme-aware style resolution in view code
10//!
11//! Widgets reference semantic token names (e.g. `STYLE_STATUS_SUCCESS`) rather
12//! than raw colors, so preset changes and color profile downgrades propagate
13//! automatically. F2 / Shift+F2 cycle forward/backward through presets.
14
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use ftui::render::cell::PackedRgba;
19use ftui::style::theme::themes;
20use ftui::{
21    AdaptiveColor, Color, ColorProfile, ResolvedTheme, Style, StyleSheet, TableTheme,
22    TerminalCapabilities, Theme, ThemeBuilder,
23};
24use ftui_extras::markdown::MarkdownTheme;
25use ftui_extras::syntax::HighlightTheme;
26use serde::{Deserialize, Serialize};
27
28pub const STYLE_APP_ROOT: &str = "app.root";
29pub const STYLE_PANE_BASE: &str = "pane.base";
30pub const STYLE_PANE_FOCUSED: &str = "pane.focused";
31pub const STYLE_PANE_TITLE_FOCUSED: &str = "pane.title.focused";
32pub const STYLE_PANE_TITLE_UNFOCUSED: &str = "pane.title.unfocused";
33pub const STYLE_SPLIT_HANDLE: &str = "split.handle";
34pub const STYLE_TEXT_PRIMARY: &str = "text.primary";
35pub const STYLE_TEXT_MUTED: &str = "text.muted";
36pub const STYLE_TEXT_SUBTLE: &str = "text.subtle";
37pub const STYLE_STATUS_SUCCESS: &str = "status.success";
38pub const STYLE_STATUS_WARNING: &str = "status.warning";
39pub const STYLE_STATUS_ERROR: &str = "status.error";
40pub const STYLE_STATUS_INFO: &str = "status.info";
41pub const STYLE_RESULT_ROW: &str = "result.row";
42pub const STYLE_RESULT_ROW_ALT: &str = "result.row.alt";
43pub const STYLE_RESULT_ROW_SELECTED: &str = "result.row.selected";
44pub const STYLE_ROLE_USER: &str = "role.user";
45pub const STYLE_ROLE_ASSISTANT: &str = "role.assistant";
46pub const STYLE_ROLE_TOOL: &str = "role.tool";
47pub const STYLE_ROLE_SYSTEM: &str = "role.system";
48pub const STYLE_ROLE_GUTTER_USER: &str = "role.gutter.user";
49pub const STYLE_ROLE_GUTTER_ASSISTANT: &str = "role.gutter.assistant";
50pub const STYLE_ROLE_GUTTER_TOOL: &str = "role.gutter.tool";
51pub const STYLE_ROLE_GUTTER_SYSTEM: &str = "role.gutter.system";
52pub const STYLE_SCORE_HIGH: &str = "score.high";
53pub const STYLE_SCORE_MID: &str = "score.mid";
54pub const STYLE_SCORE_LOW: &str = "score.low";
55pub const STYLE_SOURCE_LOCAL: &str = "source.local";
56pub const STYLE_SOURCE_REMOTE: &str = "source.remote";
57pub const STYLE_LOCATION: &str = "location";
58pub const STYLE_PILL_ACTIVE: &str = "pill.active";
59pub const STYLE_PILL_INACTIVE: &str = "pill.inactive";
60pub const STYLE_PILL_LABEL: &str = "pill.label";
61pub const STYLE_CRUMB_ACTIVE: &str = "crumb.active";
62pub const STYLE_CRUMB_INACTIVE: &str = "crumb.inactive";
63pub const STYLE_CRUMB_SEPARATOR: &str = "crumb.separator";
64pub const STYLE_TAB_ACTIVE: &str = "tab.active";
65pub const STYLE_TAB_INACTIVE: &str = "tab.inactive";
66pub const STYLE_DETAIL_FIND_CONTAINER: &str = "detail.find.container";
67pub const STYLE_DETAIL_FIND_QUERY: &str = "detail.find.query";
68pub const STYLE_DETAIL_FIND_MATCH_ACTIVE: &str = "detail.find.match.active";
69pub const STYLE_DETAIL_FIND_MATCH_INACTIVE: &str = "detail.find.match.inactive";
70pub const STYLE_QUERY_HIGHLIGHT: &str = "query.highlight";
71pub const STYLE_SEARCH_FOCUS: &str = "search.focus";
72pub const STYLE_MODAL_BACKDROP: &str = "modal.backdrop";
73pub const STYLE_KBD_KEY: &str = "kbd.key";
74pub const STYLE_KBD_DESC: &str = "kbd.desc";
75pub const THEME_CONFIG_VERSION: u32 = 1;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum UiThemePreset {
80    #[default]
81    #[serde(alias = "dark")]
82    TokyoNight,
83    #[serde(alias = "light")]
84    Daylight,
85    #[serde(alias = "high_contrast", alias = "highcontrast", alias = "hc")]
86    HighContrast,
87    #[serde(alias = "cat")]
88    Catppuccin,
89    Dracula,
90    Nord,
91    SolarizedDark,
92    SolarizedLight,
93    Monokai,
94    GruvboxDark,
95    OneDark,
96    RosePine,
97    Everforest,
98    Kanagawa,
99    AyuMirage,
100    Nightfox,
101    CyberpunkAurora,
102    Synthwave84,
103    #[serde(alias = "cb", alias = "cvd")]
104    Colorblind,
105}
106
107impl UiThemePreset {
108    pub const fn all() -> [Self; 19] {
109        [
110            Self::TokyoNight,
111            Self::Daylight,
112            Self::Catppuccin,
113            Self::Dracula,
114            Self::Nord,
115            Self::SolarizedDark,
116            Self::SolarizedLight,
117            Self::Monokai,
118            Self::GruvboxDark,
119            Self::OneDark,
120            Self::RosePine,
121            Self::Everforest,
122            Self::Kanagawa,
123            Self::AyuMirage,
124            Self::Nightfox,
125            Self::CyberpunkAurora,
126            Self::Synthwave84,
127            Self::HighContrast,
128            Self::Colorblind,
129        ]
130    }
131
132    pub const fn name(self) -> &'static str {
133        match self {
134            Self::TokyoNight => "Tokyo Night",
135            Self::Daylight => "Daylight",
136            Self::HighContrast => "High Contrast",
137            Self::Catppuccin => "Catppuccin Mocha",
138            Self::Dracula => "Dracula",
139            Self::Nord => "Nord",
140            Self::SolarizedDark => "Solarized Dark",
141            Self::SolarizedLight => "Solarized Light",
142            Self::Monokai => "Monokai",
143            Self::GruvboxDark => "Gruvbox Dark",
144            Self::OneDark => "One Dark",
145            Self::RosePine => "Ros\u{e9} Pine",
146            Self::Everforest => "Everforest",
147            Self::Kanagawa => "Kanagawa",
148            Self::AyuMirage => "Ayu Mirage",
149            Self::Nightfox => "Nightfox",
150            Self::CyberpunkAurora => "Cyberpunk Aurora",
151            Self::Synthwave84 => "Synthwave '84",
152            Self::Colorblind => "Colorblind",
153        }
154    }
155
156    pub fn next(self) -> Self {
157        let all = Self::all();
158        let idx = all.iter().position(|preset| *preset == self).unwrap_or(0);
159        all[(idx + 1) % all.len()]
160    }
161
162    pub fn previous(self) -> Self {
163        let all = Self::all();
164        let idx = all.iter().position(|preset| *preset == self).unwrap_or(0);
165        all[(idx + all.len() - 1) % all.len()]
166    }
167
168    pub fn parse(value: &str) -> Option<Self> {
169        match value.trim().to_ascii_lowercase().as_str() {
170            "dark" | "tokyo-night" | "tokyo_night" | "tokyonight" => Some(Self::TokyoNight),
171            "light" | "daylight" => Some(Self::Daylight),
172            "high-contrast" | "high_contrast" | "highcontrast" | "hc" => Some(Self::HighContrast),
173            "catppuccin" | "cat" | "catppuccin-mocha" => Some(Self::Catppuccin),
174            "dracula" => Some(Self::Dracula),
175            "nord" => Some(Self::Nord),
176            "solarized-dark" | "solarized_dark" => Some(Self::SolarizedDark),
177            "solarized-light" | "solarized_light" => Some(Self::SolarizedLight),
178            "monokai" => Some(Self::Monokai),
179            "gruvbox-dark" | "gruvbox_dark" | "gruvbox" => Some(Self::GruvboxDark),
180            "one-dark" | "one_dark" | "onedark" => Some(Self::OneDark),
181            "rose-pine" | "rose_pine" | "rosepine" => Some(Self::RosePine),
182            "everforest" => Some(Self::Everforest),
183            "kanagawa" => Some(Self::Kanagawa),
184            "ayu-mirage" | "ayu_mirage" | "ayumirage" => Some(Self::AyuMirage),
185            "nightfox" => Some(Self::Nightfox),
186            "cyberpunk-aurora" | "cyberpunk_aurora" | "cyberpunk" => Some(Self::CyberpunkAurora),
187            "synthwave-84" | "synthwave_84" | "synthwave84" | "synthwave" => {
188                Some(Self::Synthwave84)
189            }
190            "colorblind" | "colour-blind" | "color-blind" | "cb" | "cvd" => Some(Self::Colorblind),
191            _ => None,
192        }
193    }
194
195    fn base_theme(self) -> Theme {
196        match self {
197            Self::TokyoNight => tokyo_night_theme(),
198            Self::Daylight => themes::light(),
199            Self::HighContrast => high_contrast_theme(),
200            Self::Catppuccin => catppuccin_theme(),
201            Self::Dracula => themes::dracula(),
202            Self::Nord => themes::nord(),
203            Self::SolarizedDark => themes::solarized_dark(),
204            Self::SolarizedLight => themes::solarized_light(),
205            Self::Monokai => themes::monokai(),
206            Self::GruvboxDark => gruvbox_dark_theme(),
207            Self::OneDark => one_dark_theme(),
208            Self::RosePine => rose_pine_theme(),
209            Self::Everforest => everforest_theme(),
210            Self::Kanagawa => kanagawa_theme(),
211            Self::AyuMirage => ayu_mirage_theme(),
212            Self::Nightfox => nightfox_theme(),
213            Self::CyberpunkAurora => cyberpunk_aurora_theme(),
214            Self::Synthwave84 => synthwave_84_theme(),
215            Self::Colorblind => colorblind_theme(),
216        }
217    }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct ThemeConfig {
222    #[serde(default = "default_theme_config_version")]
223    pub version: u32,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub base_preset: Option<UiThemePreset>,
226}
227
228impl ThemeConfig {
229    pub fn from_json_str(raw: &str) -> Result<Self, ThemeConfigError> {
230        let config: Self =
231            serde_json::from_str(raw).map_err(|source| ThemeConfigError::ParseJson { source })?;
232        config.validate()?;
233        Ok(config)
234    }
235
236    pub fn to_json_pretty(&self) -> Result<String, ThemeConfigError> {
237        self.validate()?;
238        serde_json::to_string_pretty(self)
239            .map_err(|source| ThemeConfigError::SerializeJson { source })
240    }
241
242    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ThemeConfigError> {
243        let path = path.as_ref();
244        let raw = fs::read_to_string(path).map_err(|source| ThemeConfigError::ReadConfig {
245            path: path.to_path_buf(),
246            source,
247        })?;
248        Self::from_json_str(&raw)
249    }
250
251    pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ThemeConfigError> {
252        let path = path.as_ref();
253        if let Some(parent) = path.parent() {
254            fs::create_dir_all(parent).map_err(|source| ThemeConfigError::WriteConfig {
255                path: parent.to_path_buf(),
256                source,
257            })?;
258        }
259
260        let payload = self.to_json_pretty()?;
261        fs::write(path, payload).map_err(|source| ThemeConfigError::WriteConfig {
262            path: path.to_path_buf(),
263            source,
264        })
265    }
266
267    pub fn validate(&self) -> Result<(), ThemeConfigError> {
268        if self.version != THEME_CONFIG_VERSION {
269            return Err(ThemeConfigError::UnsupportedVersion {
270                found: self.version,
271                expected: THEME_CONFIG_VERSION,
272            });
273        }
274        Ok(())
275    }
276}
277
278impl Default for ThemeConfig {
279    fn default() -> Self {
280        Self {
281            version: THEME_CONFIG_VERSION,
282            base_preset: None,
283        }
284    }
285}
286
287#[derive(Debug, Clone, PartialEq)]
288pub struct ThemeContrastCheck {
289    pub pair: &'static str,
290    pub ratio: f64,
291    pub minimum: f64,
292    pub passes: bool,
293}
294
295#[derive(Debug, Clone, PartialEq)]
296pub struct ThemeContrastReport {
297    pub checks: Vec<ThemeContrastCheck>,
298}
299
300impl ThemeContrastReport {
301    pub fn has_failures(&self) -> bool {
302        self.checks.iter().any(|check| !check.passes)
303    }
304
305    pub fn failing_pairs(&self) -> Vec<&'static str> {
306        self.checks
307            .iter()
308            .filter(|check| !check.passes)
309            .map(|check| check.pair)
310            .collect()
311    }
312}
313
314#[derive(Debug, thiserror::Error)]
315pub enum ThemeConfigError {
316    #[error("unsupported theme config version {found}; expected {expected}")]
317    UnsupportedVersion { found: u32, expected: u32 },
318    #[error("failed to parse theme config JSON: {source}")]
319    ParseJson { source: serde_json::Error },
320    #[error("failed to serialize theme config JSON: {source}")]
321    SerializeJson { source: serde_json::Error },
322    #[error("failed to read theme config `{path}`: {source}")]
323    ReadConfig {
324        path: PathBuf,
325        source: std::io::Error,
326    },
327    #[error("failed to write theme config `{path}`: {source}")]
328    WriteConfig {
329        path: PathBuf,
330        source: std::io::Error,
331    },
332}
333
334fn default_theme_config_version() -> u32 {
335    THEME_CONFIG_VERSION
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct StyleOptions {
340    pub preset: UiThemePreset,
341    pub dark_mode: bool,
342    pub color_profile: ColorProfile,
343    pub no_color: bool,
344    pub no_icons: bool,
345    pub no_gradient: bool,
346    pub a11y: bool,
347}
348
349impl Default for StyleOptions {
350    fn default() -> Self {
351        Self {
352            preset: UiThemePreset::TokyoNight,
353            dark_mode: true,
354            color_profile: ColorProfile::detect(),
355            no_color: false,
356            no_icons: false,
357            no_gradient: false,
358            a11y: false,
359        }
360    }
361}
362
363#[derive(Debug, Clone, Copy, Default)]
364struct EnvValues<'a> {
365    no_color: Option<&'a str>,
366    cass_respect_no_color: Option<&'a str>,
367    cass_no_color: Option<&'a str>,
368    colorterm: Option<&'a str>,
369    term: Option<&'a str>,
370    cass_no_icons: Option<&'a str>,
371    cass_no_gradient: Option<&'a str>,
372    cass_a11y: Option<&'a str>,
373    cass_theme: Option<&'a str>,
374    cass_color_profile: Option<&'a str>,
375}
376
377impl StyleOptions {
378    pub fn from_env() -> Self {
379        let no_color = dotenvy::var("NO_COLOR").ok();
380        let cass_respect_no_color = dotenvy::var("CASS_RESPECT_NO_COLOR").ok();
381        let cass_no_color = dotenvy::var("CASS_NO_COLOR").ok();
382        let colorterm = dotenvy::var("COLORTERM").ok();
383        let term = dotenvy::var("TERM").ok();
384        let cass_no_icons = dotenvy::var("CASS_NO_ICONS").ok();
385        let cass_no_gradient = dotenvy::var("CASS_NO_GRADIENT").ok();
386        let cass_a11y = dotenvy::var("CASS_A11Y").ok();
387        let cass_theme = dotenvy::var("CASS_THEME").ok();
388        let cass_color_profile = dotenvy::var("CASS_COLOR_PROFILE").ok();
389
390        let mut options = Self::from_env_values(EnvValues {
391            no_color: no_color.as_deref(),
392            cass_respect_no_color: cass_respect_no_color.as_deref(),
393            cass_no_color: cass_no_color.as_deref(),
394            colorterm: colorterm.as_deref(),
395            term: term.as_deref(),
396            cass_no_icons: cass_no_icons.as_deref(),
397            cass_no_gradient: cass_no_gradient.as_deref(),
398            cass_a11y: cass_a11y.as_deref(),
399            cass_theme: cass_theme.as_deref(),
400            cass_color_profile: cass_color_profile.as_deref(),
401        });
402
403        // Prefer runtime terminal capability detection for interactive TUI.
404        // This yields the best supported profile even when wrapper shells
405        // inherit conservative TERM values.
406        if !options.no_color && cass_color_profile.is_none() {
407            let caps = TerminalCapabilities::with_overrides();
408            options.color_profile = if caps.true_color {
409                ColorProfile::TrueColor
410            } else if caps.colors_256 {
411                ColorProfile::Ansi256
412            } else {
413                ColorProfile::Ansi16
414            };
415        }
416
417        options
418    }
419
420    /// Resolve `StyleOptions` from a snapshot of environment variables.
421    ///
422    /// ## Precedence rules (evaluated top-to-bottom, first match wins)
423    ///
424    /// | Priority | Condition | `color_profile` | `no_color` |
425    /// |----------|-----------|------------------|------------|
426    /// | 1 (highest) | `CASS_NO_COLOR` is truthy | Mono | true |
427    /// | 2 | `CASS_RESPECT_NO_COLOR` is truthy **and** `NO_COLOR` is set | Mono | true |
428    /// | 3 | `CASS_COLOR_PROFILE` is set to a valid value | that value | false |
429    /// | 4 (lowest) | None of the above | detect from COLORTERM/TERM | false |
430    ///
431    /// ## Cascade effects
432    ///
433    /// - `no_gradient` = `CASS_NO_GRADIENT` (truthy) **or** `no_color` **or** `a11y`
434    /// - `no_icons` = `CASS_NO_ICONS` (truthy; independent of color state)
435    /// - `a11y` = `CASS_A11Y` is truthy (adds bold/underline accents, text role markers)
436    /// - `dark_mode` = `false` only for `Light` preset; `HighContrast` auto-detects
437    ///
438    /// ## Notes
439    ///
440    /// - `NO_COLOR` alone is intentionally ignored; `CASS_RESPECT_NO_COLOR` must opt in.
441    /// - `CASS_NO_COLOR` trumps `CASS_COLOR_PROFILE` even when set to "truecolor".
442    /// - Invalid `CASS_COLOR_PROFILE` values silently fall back to env detection.
443    /// - `CASS_A11Y` uses `env_truthy()`: "0"/"false"/"off"/"no" → false, anything else → true.
444    fn from_env_values(values: EnvValues<'_>) -> Self {
445        let preset = values
446            .cass_theme
447            .and_then(UiThemePreset::parse)
448            .unwrap_or(UiThemePreset::TokyoNight);
449
450        let no_color_enabled = env_truthy(values.cass_no_color)
451            || (env_truthy(values.cass_respect_no_color) && values.no_color.is_some());
452
453        let detected_profile = ColorProfile::detect_from_env(None, values.colorterm, values.term);
454        let profile_override = values.cass_color_profile.and_then(parse_color_profile);
455        let color_profile = if no_color_enabled {
456            ColorProfile::Mono
457        } else {
458            profile_override.unwrap_or(detected_profile)
459        };
460
461        let a11y = env_truthy(values.cass_a11y);
462        let no_icons = env_truthy(values.cass_no_icons);
463        let no_gradient = env_truthy(values.cass_no_gradient) || no_color_enabled || a11y;
464
465        let dark_mode = match preset {
466            UiThemePreset::Daylight | UiThemePreset::SolarizedLight => false,
467            UiThemePreset::HighContrast => Theme::detect_dark_mode(),
468            _ => true,
469        };
470
471        Self {
472            preset,
473            dark_mode,
474            color_profile,
475            no_color: no_color_enabled,
476            no_icons,
477            no_gradient,
478            a11y,
479        }
480    }
481
482    pub const fn gradients_enabled(self) -> bool {
483        !self.no_gradient && self.color_profile.supports_color()
484    }
485}
486
487// ---------------------------------------------------------------------------
488// Decorative policy — capability/degradation/breakpoint guardrails (2dccg.10.6)
489// ---------------------------------------------------------------------------
490
491/// Border rendering strategy, from richest to most minimal.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
493pub enum BorderTier {
494    /// Unicode rounded corners (`╭─╮`).
495    Rounded,
496    /// Plain box-drawing (`┌─┐`).
497    Square,
498    /// No borders at all.
499    None,
500}
501
502/// Resolved decorative policy for the current frame.
503///
504/// Computed from [`StyleOptions`], the ftui `DegradationLevel`, and the
505/// [`LayoutBreakpoint`] so that rendering code never makes ad-hoc decisions
506/// about what decorative elements to show.
507///
508/// ## Policy table
509///
510/// | Degradation       | Breakpoint   | fancy_borders | `border_tier` | `show_icons` | `use_styling` |
511/// |-------------------|--------------|---------------|---------------|--------------|---------------|
512/// | Full              | any          | true          | Rounded       | true         | true          |
513/// | Full              | Narrow       | true          | Square        | true         | true          |
514/// | Full              | any          | false         | Square        | true         | true          |
515/// | SimpleBorders     | any          | _             | Square        | true         | true          |
516/// | NoStyling         | any          | _             | Square        | true         | false         |
517/// | EssentialOnly+    | any          | _             | None          | false        | false         |
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub struct DecorativePolicy {
520    /// Which border rendering tier to use.
521    pub border_tier: BorderTier,
522    /// Whether to render icons and decorative Unicode glyphs.
523    pub show_icons: bool,
524    /// Whether to apply color styling (fg/bg).
525    pub use_styling: bool,
526    /// Whether gradients are allowed (requires TrueColor + not a11y + not no_gradient).
527    pub use_gradients: bool,
528    /// Whether to render content at all (false at Skeleton/SkipFrame).
529    pub render_content: bool,
530}
531
532impl DecorativePolicy {
533    /// Resolve policy from the current style options, degradation level, and breakpoint.
534    ///
535    /// Uses `fancy_borders` as the user-preference toggle (Ctrl+B in TUI).
536    pub fn resolve(
537        options: StyleOptions,
538        degradation: ftui::render::budget::DegradationLevel,
539        breakpoint: super::app::LayoutBreakpoint,
540        fancy_borders: bool,
541    ) -> Self {
542        use crate::ui::app::LayoutBreakpoint as LB;
543
544        let render_content = degradation.render_content();
545
546        // Border tier: EssentialOnly+ strips all borders.
547        let border_tier = if !degradation.render_decorative() {
548            BorderTier::None
549        } else if !degradation.use_unicode_borders() {
550            // SimpleBorders+ forces plain box-drawing.
551            BorderTier::Square
552        } else if !fancy_borders {
553            BorderTier::Square
554        } else if breakpoint == LB::Narrow {
555            // Narrow terminals: use square borders to save horizontal space.
556            BorderTier::Square
557        } else {
558            BorderTier::Rounded
559        };
560
561        let show_icons = degradation.render_decorative() && !options.no_icons;
562        let use_styling = degradation.apply_styling() && !options.no_color;
563        let use_gradients = options.gradients_enabled() && degradation.apply_styling();
564
565        Self {
566            border_tier,
567            show_icons,
568            use_styling,
569            use_gradients,
570            render_content,
571        }
572    }
573}
574
575/// Input axes for capability-matrix diagnostics.
576///
577/// This mirrors the environment-driven style inputs that affect policy
578/// resolution and can be used in deterministic tests for representative
579/// terminal profiles.
580#[derive(Debug, Clone, Copy, Default)]
581pub struct CapabilityMatrixInputs<'a> {
582    /// TERM value used for profile detection.
583    pub term: Option<&'a str>,
584    /// COLORTERM value used for profile detection.
585    pub colorterm: Option<&'a str>,
586    /// Whether `NO_COLOR` is set.
587    pub no_color: bool,
588    /// Whether `CASS_RESPECT_NO_COLOR` is set/truthy.
589    pub cass_respect_no_color: bool,
590    /// Whether `CASS_NO_COLOR` is set.
591    pub cass_no_color: bool,
592    /// Whether `CASS_NO_ICONS` is set.
593    pub cass_no_icons: bool,
594    /// Whether `CASS_NO_GRADIENT` is set.
595    pub cass_no_gradient: bool,
596    /// Whether `CASS_A11Y` is set/truthy.
597    pub cass_a11y: bool,
598    /// Optional explicit theme preset override.
599    pub cass_theme: Option<&'a str>,
600    /// Optional explicit color profile override.
601    pub cass_color_profile: Option<&'a str>,
602}
603
604/// Machine-readable diagnostic summary for a resolved style policy decision.
605#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
606pub struct StylePolicyDiagnostic {
607    /// Terminal capability profile id (`xterm-256color`, `dumb`, `kitty`, ...).
608    pub terminal_profile: String,
609    /// TERM from diagnostic input.
610    pub term: Option<String>,
611    /// COLORTERM from diagnostic input.
612    pub colorterm: Option<String>,
613    /// Current degradation level label.
614    pub degradation: &'static str,
615    /// Current responsive breakpoint label.
616    pub breakpoint: &'static str,
617    /// Whether rounded borders are user-enabled.
618    pub fancy_borders: bool,
619    /// Capability flag: supports truecolor.
620    pub capability_true_color: bool,
621    /// Capability flag: supports 256-color palette.
622    pub capability_colors_256: bool,
623    /// Capability flag: supports Unicode box drawing.
624    pub capability_unicode_box_drawing: bool,
625    /// Input env axis: `NO_COLOR`.
626    pub env_no_color: bool,
627    /// Input env axis: `CASS_RESPECT_NO_COLOR`.
628    pub env_cass_respect_no_color: bool,
629    /// Input env axis: `CASS_NO_COLOR`.
630    pub env_cass_no_color: bool,
631    /// Resolved color profile after precedence rules.
632    pub resolved_color_profile: &'static str,
633    /// Resolved style options: no_color.
634    pub resolved_no_color: bool,
635    /// Resolved style options: no_icons.
636    pub resolved_no_icons: bool,
637    /// Resolved style options: no_gradient.
638    pub resolved_no_gradient: bool,
639    /// Resolved policy: border tier.
640    pub policy_border_tier: &'static str,
641    /// Resolved policy: icon rendering.
642    pub policy_show_icons: bool,
643    /// Resolved policy: fg/bg styling.
644    pub policy_use_styling: bool,
645    /// Resolved policy: gradients.
646    pub policy_use_gradients: bool,
647    /// Resolved policy: content rendering.
648    pub policy_render_content: bool,
649}
650
651fn env_flag(value: bool) -> Option<&'static str> {
652    if value { Some("1") } else { None }
653}
654
655fn color_profile_name(profile: ColorProfile) -> &'static str {
656    match profile {
657        ColorProfile::Mono => "mono",
658        ColorProfile::Ansi16 => "ansi16",
659        ColorProfile::Ansi256 => "ansi256",
660        ColorProfile::TrueColor => "truecolor",
661    }
662}
663
664fn border_tier_name(tier: BorderTier) -> &'static str {
665    match tier {
666        BorderTier::Rounded => "rounded",
667        BorderTier::Square => "square",
668        BorderTier::None => "none",
669    }
670}
671
672fn breakpoint_name(breakpoint: super::app::LayoutBreakpoint) -> &'static str {
673    use crate::ui::app::LayoutBreakpoint as LB;
674    match breakpoint {
675        LB::Narrow => "narrow",
676        LB::MediumNarrow => "medium-narrow",
677        LB::Medium => "medium",
678        LB::Wide => "wide",
679        LB::UltraWide => "ultra-wide",
680    }
681}
682
683fn degradation_name(level: ftui::render::budget::DegradationLevel) -> &'static str {
684    use ftui::render::budget::DegradationLevel as DL;
685    match level {
686        DL::Full => "full",
687        DL::SimpleBorders => "simple-borders",
688        DL::NoStyling => "no-styling",
689        DL::EssentialOnly => "essential-only",
690        DL::Skeleton => "skeleton",
691        DL::SkipFrame => "skip-frame",
692    }
693}
694
695/// Build a policy diagnostic payload for a specific capability/profile fixture.
696///
697/// This intentionally accepts explicit capability and env inputs so tests can
698/// validate style-policy decisions deterministically without depending on host
699/// terminal state.
700pub fn style_policy_diagnostic(
701    capabilities: TerminalCapabilities,
702    inputs: CapabilityMatrixInputs<'_>,
703    degradation: ftui::render::budget::DegradationLevel,
704    breakpoint: super::app::LayoutBreakpoint,
705    fancy_borders: bool,
706) -> StylePolicyDiagnostic {
707    let env_values = EnvValues {
708        no_color: env_flag(inputs.no_color),
709        cass_respect_no_color: env_flag(inputs.cass_respect_no_color),
710        cass_no_color: env_flag(inputs.cass_no_color),
711        colorterm: inputs.colorterm,
712        term: inputs.term,
713        cass_no_icons: env_flag(inputs.cass_no_icons),
714        cass_no_gradient: env_flag(inputs.cass_no_gradient),
715        cass_a11y: env_flag(inputs.cass_a11y),
716        cass_theme: inputs.cass_theme,
717        cass_color_profile: inputs.cass_color_profile,
718    };
719
720    let mut options = StyleOptions::from_env_values(env_values);
721
722    // In diagnostics, keep profile resolution deterministic from explicit
723    // capabilities when no direct CASS_COLOR_PROFILE override is provided.
724    if !options.no_color && inputs.cass_color_profile.is_none() {
725        options.color_profile = if capabilities.true_color {
726            ColorProfile::TrueColor
727        } else if capabilities.colors_256 {
728            ColorProfile::Ansi256
729        } else {
730            ColorProfile::Ansi16
731        };
732    }
733
734    let policy = DecorativePolicy::resolve(options, degradation, breakpoint, fancy_borders);
735
736    StylePolicyDiagnostic {
737        terminal_profile: capabilities.profile().as_str().to_string(),
738        term: inputs.term.map(ToString::to_string),
739        colorterm: inputs.colorterm.map(ToString::to_string),
740        degradation: degradation_name(degradation),
741        breakpoint: breakpoint_name(breakpoint),
742        fancy_borders,
743        capability_true_color: capabilities.true_color,
744        capability_colors_256: capabilities.colors_256,
745        capability_unicode_box_drawing: capabilities.unicode_box_drawing,
746        env_no_color: inputs.no_color,
747        env_cass_respect_no_color: inputs.cass_respect_no_color,
748        env_cass_no_color: inputs.cass_no_color,
749        resolved_color_profile: color_profile_name(options.color_profile),
750        resolved_no_color: options.no_color,
751        resolved_no_icons: options.no_icons,
752        resolved_no_gradient: options.no_gradient,
753        policy_border_tier: border_tier_name(policy.border_tier),
754        policy_show_icons: policy.show_icons,
755        policy_use_styling: policy.use_styling,
756        policy_use_gradients: policy.use_gradients,
757        policy_render_content: policy.render_content,
758    }
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub struct RoleMarkers {
763    pub user: &'static str,
764    pub assistant: &'static str,
765    pub tool: &'static str,
766    pub system: &'static str,
767}
768
769impl RoleMarkers {
770    fn from_options(options: StyleOptions) -> Self {
771        if options.a11y {
772            return Self {
773                user: "[user]",
774                assistant: "[assistant]",
775                tool: "[tool]",
776                system: "[system]",
777            };
778        }
779
780        if options.no_icons {
781            return Self {
782                user: "",
783                assistant: "",
784                tool: "",
785                system: "",
786            };
787        }
788
789        Self {
790            user: "U>",
791            assistant: "A>",
792            tool: "T>",
793            system: "S>",
794        }
795    }
796}
797
798#[derive(Debug, Clone)]
799pub struct StyleContext {
800    pub options: StyleOptions,
801    pub theme: Theme,
802    pub resolved: ResolvedTheme,
803    pub sheet: StyleSheet,
804    pub role_markers: RoleMarkers,
805}
806
807impl StyleContext {
808    pub fn from_options(options: StyleOptions) -> Self {
809        Self::build(options)
810    }
811
812    pub fn from_options_with_theme_config(mut options: StyleOptions, config: &ThemeConfig) -> Self {
813        if let Some(base_preset) = config.base_preset {
814            options.preset = base_preset;
815            options.dark_mode = match base_preset {
816                UiThemePreset::Daylight | UiThemePreset::SolarizedLight => false,
817                UiThemePreset::HighContrast => Theme::detect_dark_mode(),
818                _ => true,
819            };
820        }
821
822        Self::build(options)
823    }
824
825    fn build(options: StyleOptions) -> Self {
826        let mut theme = options.preset.base_theme();
827
828        if options.a11y && options.preset != UiThemePreset::HighContrast {
829            theme = apply_a11y_overrides(theme);
830        }
831
832        theme = downgrade_theme_for_profile(theme, options.color_profile);
833
834        let dark_mode = match options.preset {
835            UiThemePreset::Daylight | UiThemePreset::SolarizedLight => false,
836            _ => options.dark_mode,
837        };
838        let resolved = theme.resolve(dark_mode);
839        let sheet = build_stylesheet(resolved, options);
840        let role_markers = RoleMarkers::from_options(options);
841
842        Self {
843            options,
844            theme,
845            resolved,
846            sheet,
847            role_markers,
848        }
849    }
850
851    pub fn from_env() -> Self {
852        Self::from_options(StyleOptions::from_env())
853    }
854
855    pub fn style(&self, name: &str) -> Style {
856        self.sheet.get_or_default(name)
857    }
858
859    /// Return an accent-colored style for the given agent slug.
860    pub fn agent_accent_style(&self, agent: &str) -> Style {
861        let pane = super::components::theme::ThemePalette::agent_pane(agent);
862        if self.options.no_color
863            || self.options.a11y
864            || !self.options.color_profile.supports_color()
865        {
866            return Style::new().fg(pane.accent).bold();
867        }
868
869        let accent = Color::rgb(pane.accent.r(), pane.accent.g(), pane.accent.b());
870        let badge_bg = blend(
871            self.resolved.surface,
872            accent,
873            if self.options.gradients_enabled() {
874                0.22
875            } else {
876                0.14
877            },
878        );
879
880        // Pick whichever semantic foreground keeps the strongest contrast.
881        let mut best_fg = self.resolved.text;
882        let mut best_ratio =
883            ftui::style::contrast_ratio_packed(to_packed(best_fg), to_packed(badge_bg));
884        for candidate in [self.resolved.selection_fg, accent] {
885            let ratio =
886                ftui::style::contrast_ratio_packed(to_packed(candidate), to_packed(badge_bg));
887            if ratio > best_ratio {
888                best_ratio = ratio;
889                best_fg = candidate;
890            }
891        }
892
893        Style::new()
894            .fg(to_packed(best_fg))
895            .bg(to_packed(badge_bg))
896            .bold()
897    }
898
899    /// Tint a base results-row style with a subtle per-agent accent.
900    ///
901    /// This restores scan-friendly visual grouping in the list without
902    /// sacrificing legibility or selected-row affordances.
903    pub fn result_row_style_for_agent(&self, base: Style, agent: &str) -> Style {
904        let Some(base_bg) = base.bg else {
905            return base;
906        };
907        if self.options.no_color
908            || self.options.a11y
909            || !self.options.color_profile.supports_color()
910        {
911            return base;
912        }
913
914        let pane = super::components::theme::ThemePalette::agent_pane(agent);
915        let accent = Color::rgb(pane.accent.r(), pane.accent.g(), pane.accent.b());
916        let pane_bg = Color::rgb(pane.bg.r(), pane.bg.g(), pane.bg.b());
917        let base_bg_color = Color::rgb(base_bg.r(), base_bg.g(), base_bg.b());
918        let mut tint_mix = if self.options.gradients_enabled() {
919            0.12
920        } else {
921            0.08
922        };
923        let selected_bg = self.resolved.selection_bg;
924        let base_separation =
925            ftui::style::contrast_ratio_packed(to_packed(selected_bg), to_packed(base_bg_color));
926        let min_allowed_separation = (base_separation - 0.03).max(1.01);
927        let mut tint = blend(base_bg_color, pane_bg, tint_mix);
928        let mut tint_separation =
929            ftui::style::contrast_ratio_packed(to_packed(selected_bg), to_packed(tint));
930
931        // Keep selected-row affordances visually dominant: if tinting moves row
932        // background too close to selection background, taper tint intensity.
933        if tint_separation < min_allowed_separation {
934            for _ in 0..4 {
935                tint_mix *= 0.55;
936                let candidate = blend(base_bg_color, pane_bg, tint_mix);
937                let candidate_separation = ftui::style::contrast_ratio_packed(
938                    to_packed(selected_bg),
939                    to_packed(candidate),
940                );
941                tint = candidate;
942                tint_separation = candidate_separation;
943                if candidate_separation >= min_allowed_separation {
944                    break;
945                }
946            }
947        }
948
949        // Preserve deterministic per-agent differentiation even when selection
950        // safety logic dampens the primary tint significantly.
951        let agent_hash = agent.as_bytes().iter().fold(0u32, |acc, b| {
952            acc.wrapping_mul(131).wrapping_add(u32::from(*b))
953        });
954        let signature_mix_base = if self.options.gradients_enabled() {
955            0.010
956        } else {
957            0.006
958        };
959        let signature_mix = signature_mix_base + (agent_hash % 5) as f32 * 0.0015;
960        let signature_tint = blend(tint, accent, signature_mix);
961        let signature_separation =
962            ftui::style::contrast_ratio_packed(to_packed(selected_bg), to_packed(signature_tint));
963        if signature_separation >= min_allowed_separation {
964            tint = signature_tint;
965            tint_separation = signature_separation;
966        }
967
968        debug_assert!(
969            tint_separation > 0.0,
970            "contrast ratios should always be positive"
971        );
972        Style::new()
973            .fg(base.fg.unwrap_or_else(|| to_packed(self.resolved.text)))
974            .bg(to_packed(tint))
975    }
976
977    /// Return a score-magnitude style (high/mid/low).
978    pub fn score_style(&self, score: f32) -> Style {
979        if score >= 8.0 {
980            self.style(STYLE_SCORE_HIGH)
981        } else if score >= 5.0 {
982            self.style(STYLE_SCORE_MID)
983        } else {
984            self.style(STYLE_SCORE_LOW)
985        }
986    }
987
988    pub fn contrast_report(&self) -> ThemeContrastReport {
989        build_contrast_report(self.resolved)
990    }
991
992    /// Build a [`MarkdownTheme`] derived from the active resolved theme so
993    /// markdown content renders in theme-coherent colors.
994    pub fn markdown_theme(&self) -> MarkdownTheme {
995        let r = &self.resolved;
996        MarkdownTheme {
997            h1: Style::new().fg(to_packed(r.primary)).bold(),
998            h2: Style::new().fg(to_packed(r.info)).bold(),
999            h3: Style::new().fg(to_packed(r.success)).bold(),
1000            h4: Style::new().fg(to_packed(r.warning)).bold(),
1001            h5: Style::new().fg(to_packed(r.text)).bold(),
1002            h6: Style::new().fg(to_packed(r.text_muted)).bold(),
1003            code_inline: Style::new()
1004                .fg(to_packed(r.text))
1005                .bg(to_packed(blend(r.surface, r.text, 0.08))),
1006            code_block: Style::new().fg(to_packed(r.text)).bg(to_packed(blend(
1007                r.background,
1008                r.surface,
1009                0.5,
1010            ))),
1011            blockquote: Style::new().fg(to_packed(r.text_muted)).italic(),
1012            link: Style::new().fg(to_packed(r.info)).underline(),
1013            emphasis: Style::new().fg(to_packed(r.text)).italic(),
1014            strong: Style::new().fg(to_packed(r.text)).bold(),
1015            strikethrough: Style::new().fg(to_packed(r.text_muted)).strikethrough(),
1016            list_bullet: Style::new().fg(to_packed(r.info)),
1017            horizontal_rule: Style::new().fg(to_packed(r.border)).dim(),
1018            table_theme: TableTheme {
1019                border: Style::new().fg(to_packed(r.border)),
1020                header: Style::new()
1021                    .fg(to_packed(r.text))
1022                    .bg(to_packed(blend(r.surface, r.primary, 0.15)))
1023                    .bold(),
1024                row: Style::new().fg(to_packed(r.text)),
1025                row_alt: Style::new().fg(to_packed(r.text)).bg(to_packed(blend(
1026                    r.background,
1027                    r.surface,
1028                    0.3,
1029                ))),
1030                divider: Style::new().fg(to_packed(r.border)).dim(),
1031                ..TableTheme::default()
1032            },
1033            task_done: Style::new().fg(to_packed(r.success)),
1034            task_todo: Style::new().fg(to_packed(r.text_muted)),
1035            math_inline: Style::new().fg(to_packed(r.warning)).italic(),
1036            math_block: Style::new().fg(to_packed(r.warning)).bold(),
1037            footnote_ref: Style::new().fg(to_packed(r.info)).dim(),
1038            footnote_def: Style::new().fg(to_packed(r.text_muted)),
1039            admonition_note: Style::new().fg(to_packed(r.info)).bold(),
1040            admonition_tip: Style::new().fg(to_packed(r.success)).bold(),
1041            admonition_important: Style::new().fg(to_packed(r.primary)).bold(),
1042            admonition_warning: Style::new().fg(to_packed(r.warning)).bold(),
1043            admonition_caution: Style::new().fg(to_packed(r.error)).bold(),
1044        }
1045    }
1046
1047    /// Return a syntax highlight theme matching the current UI theme brightness.
1048    pub fn syntax_highlight_theme(&self) -> HighlightTheme {
1049        if self.options.dark_mode {
1050            HighlightTheme::dark()
1051        } else {
1052            HighlightTheme::light()
1053        }
1054    }
1055}
1056
1057fn parse_color_profile(value: &str) -> Option<ColorProfile> {
1058    match value.trim().to_ascii_lowercase().as_str() {
1059        "mono" | "none" => Some(ColorProfile::Mono),
1060        "ansi16" | "16" => Some(ColorProfile::Ansi16),
1061        "ansi256" | "256" => Some(ColorProfile::Ansi256),
1062        "truecolor" | "24bit" | "rgb" => Some(ColorProfile::TrueColor),
1063        _ => None,
1064    }
1065}
1066
1067fn env_truthy(value: Option<&str>) -> bool {
1068    match value {
1069        Some(raw) => {
1070            let normalized = raw.trim().to_ascii_lowercase();
1071            if normalized.is_empty() {
1072                return false;
1073            }
1074            !(normalized == "0"
1075                || normalized == "false"
1076                || normalized == "off"
1077                || normalized == "no")
1078        }
1079        None => false,
1080    }
1081}
1082
1083fn tokyo_night_theme() -> Theme {
1084    ThemeBuilder::from_theme(themes::dark())
1085        .primary(Color::rgb(122, 162, 247))
1086        .secondary(Color::rgb(187, 154, 247))
1087        .accent(Color::rgb(125, 207, 255))
1088        .background(Color::rgb(26, 27, 38))
1089        .surface(Color::rgb(36, 40, 59))
1090        .overlay(Color::rgb(41, 46, 66))
1091        .text(Color::rgb(192, 202, 245))
1092        .text_muted(Color::rgb(169, 177, 214))
1093        .text_subtle(Color::rgb(105, 114, 158))
1094        .success(Color::rgb(115, 218, 202))
1095        .warning(Color::rgb(224, 175, 104))
1096        .error(Color::rgb(247, 118, 142))
1097        .info(Color::rgb(125, 207, 255))
1098        .border(Color::rgb(59, 66, 97))
1099        .border_focused(Color::rgb(125, 145, 200))
1100        .selection_bg(Color::rgb(122, 162, 247))
1101        .selection_fg(Color::rgb(26, 27, 38))
1102        .scrollbar_track(Color::rgb(41, 46, 66))
1103        .scrollbar_thumb(Color::rgb(125, 145, 200))
1104        .build()
1105}
1106
1107fn catppuccin_theme() -> Theme {
1108    ThemeBuilder::from_theme(themes::dark())
1109        .primary(Color::rgb(137, 180, 250))
1110        .secondary(Color::rgb(245, 194, 231))
1111        .accent(Color::rgb(203, 166, 247))
1112        .background(Color::rgb(30, 30, 46))
1113        .surface(Color::rgb(49, 50, 68))
1114        .overlay(Color::rgb(69, 71, 90))
1115        .text(Color::rgb(205, 214, 244))
1116        .text_muted(Color::rgb(166, 173, 200))
1117        .text_subtle(Color::rgb(127, 132, 156))
1118        .success(Color::rgb(166, 227, 161))
1119        .warning(Color::rgb(249, 226, 175))
1120        .error(Color::rgb(243, 139, 168))
1121        .info(Color::rgb(137, 220, 235))
1122        .border(Color::rgb(88, 91, 112))
1123        .border_focused(Color::rgb(180, 190, 254))
1124        .selection_bg(Color::rgb(116, 199, 236))
1125        .selection_fg(Color::rgb(30, 30, 46))
1126        .scrollbar_track(Color::rgb(49, 50, 68))
1127        .scrollbar_thumb(Color::rgb(88, 91, 112))
1128        .build()
1129}
1130
1131fn high_contrast_theme() -> Theme {
1132    ThemeBuilder::from_theme(themes::dark())
1133        .primary(AdaptiveColor::adaptive(
1134            Color::rgb(0, 0, 0),
1135            Color::rgb(255, 255, 255),
1136        ))
1137        .secondary(AdaptiveColor::adaptive(
1138            Color::rgb(0, 0, 0),
1139            Color::rgb(255, 255, 255),
1140        ))
1141        .accent(AdaptiveColor::adaptive(
1142            Color::rgb(0, 0, 0),
1143            Color::rgb(255, 255, 0),
1144        ))
1145        .background(AdaptiveColor::adaptive(
1146            Color::rgb(255, 255, 255),
1147            Color::rgb(0, 0, 0),
1148        ))
1149        .surface(AdaptiveColor::adaptive(
1150            Color::rgb(245, 245, 245),
1151            Color::rgb(0, 0, 0),
1152        ))
1153        .overlay(AdaptiveColor::adaptive(
1154            Color::rgb(235, 235, 235),
1155            Color::rgb(0, 0, 0),
1156        ))
1157        .text(AdaptiveColor::adaptive(
1158            Color::rgb(0, 0, 0),
1159            Color::rgb(255, 255, 255),
1160        ))
1161        .text_muted(AdaptiveColor::adaptive(
1162            Color::rgb(30, 30, 30),
1163            Color::rgb(215, 215, 215),
1164        ))
1165        .text_subtle(AdaptiveColor::adaptive(
1166            Color::rgb(45, 45, 45),
1167            Color::rgb(200, 200, 200),
1168        ))
1169        .success(AdaptiveColor::adaptive(
1170            Color::rgb(0, 96, 0),
1171            Color::rgb(64, 255, 64),
1172        ))
1173        .warning(AdaptiveColor::adaptive(
1174            Color::rgb(110, 70, 0),
1175            Color::rgb(255, 220, 64),
1176        ))
1177        .error(AdaptiveColor::adaptive(
1178            Color::rgb(128, 0, 0),
1179            Color::rgb(255, 96, 96),
1180        ))
1181        .info(AdaptiveColor::adaptive(
1182            Color::rgb(0, 32, 128),
1183            Color::rgb(128, 200, 255),
1184        ))
1185        .border(AdaptiveColor::adaptive(
1186            Color::rgb(0, 0, 0),
1187            Color::rgb(255, 255, 255),
1188        ))
1189        .border_focused(AdaptiveColor::adaptive(
1190            Color::rgb(0, 0, 0),
1191            Color::rgb(255, 255, 0),
1192        ))
1193        .selection_bg(AdaptiveColor::adaptive(
1194            Color::rgb(0, 0, 0),
1195            Color::rgb(255, 255, 255),
1196        ))
1197        .selection_fg(AdaptiveColor::adaptive(
1198            Color::rgb(255, 255, 255),
1199            Color::rgb(0, 0, 0),
1200        ))
1201        .scrollbar_track(AdaptiveColor::adaptive(
1202            Color::rgb(235, 235, 235),
1203            Color::rgb(0, 0, 0),
1204        ))
1205        .scrollbar_thumb(AdaptiveColor::adaptive(
1206            Color::rgb(0, 0, 0),
1207            Color::rgb(255, 255, 255),
1208        ))
1209        .build()
1210}
1211
1212fn gruvbox_dark_theme() -> Theme {
1213    ThemeBuilder::from_theme(themes::dark())
1214        .primary(Color::rgb(215, 153, 33)) // #d79921 yellow
1215        .secondary(Color::rgb(211, 134, 155)) // #d3869b purple
1216        .accent(Color::rgb(250, 189, 47)) // #fabd2f bright yellow
1217        .background(Color::rgb(40, 40, 40)) // #282828
1218        .surface(Color::rgb(50, 48, 47)) // #32302f
1219        .overlay(Color::rgb(60, 56, 54)) // #3c3836
1220        .text(Color::rgb(235, 219, 178)) // #ebdbb2
1221        .text_muted(Color::rgb(189, 174, 147)) // #bdae93
1222        .text_subtle(Color::rgb(146, 131, 116)) // #928374
1223        .success(Color::rgb(152, 151, 26)) // #98971a
1224        .warning(Color::rgb(215, 153, 33)) // #d79921
1225        .error(Color::rgb(204, 36, 29)) // #cc241d
1226        .info(Color::rgb(69, 133, 136)) // #458588
1227        .border(Color::rgb(80, 73, 69)) // #504945
1228        .border_focused(Color::rgb(250, 189, 47)) // #fabd2f
1229        .selection_bg(Color::rgb(215, 153, 33)) // #d79921
1230        .selection_fg(Color::rgb(40, 40, 40)) // #282828
1231        .scrollbar_track(Color::rgb(60, 56, 54)) // #3c3836
1232        .scrollbar_thumb(Color::rgb(146, 131, 116)) // #928374
1233        .build()
1234}
1235
1236fn one_dark_theme() -> Theme {
1237    ThemeBuilder::from_theme(themes::dark())
1238        .primary(Color::rgb(97, 175, 239)) // #61afef blue
1239        .secondary(Color::rgb(198, 120, 221)) // #c678dd purple
1240        .accent(Color::rgb(86, 182, 194)) // #56b6c2 cyan
1241        .background(Color::rgb(40, 44, 52)) // #282c34
1242        .surface(Color::rgb(49, 53, 63)) // #31353f
1243        .overlay(Color::rgb(55, 59, 69)) // #373b45
1244        .text(Color::rgb(171, 178, 191)) // #abb2bf
1245        .text_muted(Color::rgb(139, 145, 157)) // #8b919d
1246        .text_subtle(Color::rgb(99, 109, 131)) // #636d83
1247        .success(Color::rgb(152, 195, 121)) // #98c379
1248        .warning(Color::rgb(229, 192, 123)) // #e5c07b
1249        .error(Color::rgb(224, 108, 117)) // #e06c75
1250        .info(Color::rgb(86, 182, 194)) // #56b6c2
1251        .border(Color::rgb(62, 68, 81)) // #3e4451
1252        .border_focused(Color::rgb(97, 175, 239)) // #61afef
1253        .selection_bg(Color::rgb(97, 175, 239)) // #61afef
1254        .selection_fg(Color::rgb(40, 44, 52)) // #282c34
1255        .scrollbar_track(Color::rgb(49, 53, 63)) // #31353f
1256        .scrollbar_thumb(Color::rgb(99, 109, 131)) // #636d83
1257        .build()
1258}
1259
1260fn rose_pine_theme() -> Theme {
1261    ThemeBuilder::from_theme(themes::dark())
1262        .primary(Color::rgb(235, 188, 186)) // #ebbcba rose
1263        .secondary(Color::rgb(196, 167, 231)) // #c4a7e7 iris
1264        .accent(Color::rgb(49, 116, 143)) // #31748f pine
1265        .background(Color::rgb(25, 23, 36)) // #191724
1266        .surface(Color::rgb(38, 35, 53)) // #26233a
1267        .overlay(Color::rgb(57, 53, 82)) // #393552
1268        .text(Color::rgb(224, 222, 244)) // #e0def4
1269        .text_muted(Color::rgb(144, 140, 170)) // #908caa
1270        .text_subtle(Color::rgb(110, 106, 134)) // #6e6a86
1271        .success(Color::rgb(156, 207, 216)) // #9ccfd8 foam
1272        .warning(Color::rgb(246, 193, 119)) // #f6c177 gold
1273        .error(Color::rgb(235, 111, 146)) // #eb6f92 love
1274        .info(Color::rgb(49, 116, 143)) // #31748f pine
1275        .border(Color::rgb(57, 53, 82)) // #393552
1276        .border_focused(Color::rgb(235, 188, 186)) // #ebbcba
1277        .selection_bg(Color::rgb(235, 188, 186)) // #ebbcba
1278        .selection_fg(Color::rgb(25, 23, 36)) // #191724
1279        .scrollbar_track(Color::rgb(38, 35, 53)) // #26233a
1280        .scrollbar_thumb(Color::rgb(110, 106, 134)) // #6e6a86
1281        .build()
1282}
1283
1284fn everforest_theme() -> Theme {
1285    ThemeBuilder::from_theme(themes::dark())
1286        .primary(Color::rgb(167, 192, 128)) // #a7c080 green
1287        .secondary(Color::rgb(214, 153, 182)) // #d699b6 purple
1288        .accent(Color::rgb(131, 192, 146)) // #83c092 aqua
1289        .background(Color::rgb(39, 46, 34)) // #272e22 (dark bg)
1290        .surface(Color::rgb(47, 55, 42)) // #2f372a
1291        .overlay(Color::rgb(56, 64, 51)) // #384033
1292        .text(Color::rgb(211, 198, 170)) // #d3c6aa
1293        .text_muted(Color::rgb(163, 153, 132)) // #a39984
1294        .text_subtle(Color::rgb(125, 117, 100)) // #7d7564
1295        .success(Color::rgb(167, 192, 128)) // #a7c080
1296        .warning(Color::rgb(219, 188, 127)) // #dbbc7f
1297        .error(Color::rgb(230, 126, 128)) // #e67e80
1298        .info(Color::rgb(124, 195, 210)) // #7cc3d2 (light blue)
1299        .border(Color::rgb(68, 77, 60)) // #444d3c
1300        .border_focused(Color::rgb(167, 192, 128)) // #a7c080
1301        .selection_bg(Color::rgb(167, 192, 128)) // #a7c080
1302        .selection_fg(Color::rgb(39, 46, 34)) // #272e22
1303        .scrollbar_track(Color::rgb(47, 55, 42)) // #2f372a
1304        .scrollbar_thumb(Color::rgb(125, 117, 100)) // #7d7564
1305        .build()
1306}
1307
1308fn kanagawa_theme() -> Theme {
1309    ThemeBuilder::from_theme(themes::dark())
1310        .primary(Color::rgb(127, 180, 202)) // #7fb4ca wave blue
1311        .secondary(Color::rgb(149, 127, 184)) // #957fb8 oniviolet
1312        .accent(Color::rgb(126, 156, 216)) // #7e9cd8 crystal blue
1313        .background(Color::rgb(31, 31, 40)) // #1f1f28
1314        .surface(Color::rgb(42, 42, 54)) // #2a2a36
1315        .overlay(Color::rgb(54, 54, 70)) // #363646
1316        .text(Color::rgb(220, 215, 186)) // #dcd7ba
1317        .text_muted(Color::rgb(168, 162, 138)) // #a8a28a (fuji grey lighter)
1318        .text_subtle(Color::rgb(114, 113, 105)) // #727169
1319        .success(Color::rgb(152, 187, 108)) // #98bb6c spring green
1320        .warning(Color::rgb(255, 169, 98)) // #ffa962 surimiOrange
1321        .error(Color::rgb(195, 64, 67)) // #c34043 autumn red
1322        .info(Color::rgb(127, 180, 202)) // #7fb4ca
1323        .border(Color::rgb(84, 84, 109)) // #54546d sumiInk4
1324        .border_focused(Color::rgb(126, 156, 216)) // #7e9cd8
1325        .selection_bg(Color::rgb(73, 65, 107)) // #49416b waveblue2
1326        .selection_fg(Color::rgb(220, 215, 186)) // #dcd7ba
1327        .scrollbar_track(Color::rgb(42, 42, 54)) // #2a2a36
1328        .scrollbar_thumb(Color::rgb(84, 84, 109)) // #54546d
1329        .build()
1330}
1331
1332fn ayu_mirage_theme() -> Theme {
1333    ThemeBuilder::from_theme(themes::dark())
1334        .primary(Color::rgb(115, 210, 222)) // #73d2de common.accent
1335        .secondary(Color::rgb(217, 155, 243)) // #d99bf3 (purple)
1336        .accent(Color::rgb(255, 173, 102)) // #ffad66 syntax.tag
1337        .background(Color::rgb(36, 42, 54)) // #242a36 (ui.bg adjusted)
1338        .surface(Color::rgb(44, 51, 64)) // #2c3340
1339        .overlay(Color::rgb(52, 60, 74)) // #343c4a
1340        .text(Color::rgb(204, 204, 204)) // #cccac2 (common.fg)
1341        .text_muted(Color::rgb(150, 155, 160)) // #969ba0
1342        .text_subtle(Color::rgb(107, 114, 128)) // #6b7280
1343        .success(Color::rgb(135, 213, 134)) // #87d586
1344        .warning(Color::rgb(255, 213, 109)) // #ffd56d
1345        .error(Color::rgb(240, 113, 120)) // #f07178
1346        .info(Color::rgb(115, 210, 222)) // #73d2de
1347        .border(Color::rgb(60, 68, 82)) // #3c4452
1348        .border_focused(Color::rgb(115, 210, 222)) // #73d2de
1349        .selection_bg(Color::rgb(115, 210, 222)) // #73d2de
1350        .selection_fg(Color::rgb(36, 42, 54)) // #242a36
1351        .scrollbar_track(Color::rgb(44, 51, 64)) // #2c3340
1352        .scrollbar_thumb(Color::rgb(107, 114, 128)) // #6b7280
1353        .build()
1354}
1355
1356fn nightfox_theme() -> Theme {
1357    ThemeBuilder::from_theme(themes::dark())
1358        .primary(Color::rgb(129, 180, 243)) // #81b4f3 blue
1359        .secondary(Color::rgb(174, 140, 211)) // #ae8cd3 magenta
1360        .accent(Color::rgb(99, 205, 207)) // #63cdcf cyan
1361        .background(Color::rgb(18, 21, 31)) // #12151f
1362        .surface(Color::rgb(29, 33, 46)) // #1d212e
1363        .overlay(Color::rgb(41, 46, 62)) // #292e3e
1364        .text(Color::rgb(205, 207, 216)) // #cdcfd8
1365        .text_muted(Color::rgb(143, 145, 158)) // #8f919e
1366        .text_subtle(Color::rgb(106, 108, 122)) // #6a6c7a
1367        .success(Color::rgb(129, 200, 152)) // #81c898
1368        .warning(Color::rgb(218, 167, 89)) // #daa759
1369        .error(Color::rgb(201, 101, 120)) // #c96578
1370        .info(Color::rgb(99, 205, 207)) // #63cdcf
1371        .border(Color::rgb(48, 54, 71)) // #303647
1372        .border_focused(Color::rgb(129, 180, 243)) // #81b4f3
1373        .selection_bg(Color::rgb(129, 180, 243)) // #81b4f3
1374        .selection_fg(Color::rgb(18, 21, 31)) // #12151f
1375        .scrollbar_track(Color::rgb(29, 33, 46)) // #1d212e
1376        .scrollbar_thumb(Color::rgb(106, 108, 122)) // #6a6c7a
1377        .build()
1378}
1379
1380fn cyberpunk_aurora_theme() -> Theme {
1381    ThemeBuilder::from_theme(themes::dark())
1382        .primary(Color::rgb(255, 0, 128)) // #ff0080 neon pink
1383        .secondary(Color::rgb(0, 255, 255)) // #00ffff cyan
1384        .accent(Color::rgb(0, 255, 163)) // #00ffa3 neon green
1385        .background(Color::rgb(13, 2, 33)) // #0d0221 deep purple-black
1386        .surface(Color::rgb(22, 10, 48)) // #160a30
1387        .overlay(Color::rgb(33, 18, 63)) // #21123f
1388        .text(Color::rgb(224, 210, 255)) // #e0d2ff
1389        .text_muted(Color::rgb(160, 140, 200)) // #a08cc8
1390        .text_subtle(Color::rgb(120, 100, 160)) // #7864a0
1391        .success(Color::rgb(0, 255, 163)) // #00ffa3
1392        .warning(Color::rgb(255, 213, 0)) // #ffd500
1393        .error(Color::rgb(255, 51, 102)) // #ff3366
1394        .info(Color::rgb(0, 200, 255)) // #00c8ff
1395        .border(Color::rgb(60, 30, 100)) // #3c1e64
1396        .border_focused(Color::rgb(255, 0, 128)) // #ff0080
1397        .selection_bg(Color::rgb(255, 0, 128)) // #ff0080
1398        .selection_fg(Color::rgb(13, 2, 33)) // #0d0221 deep bg for contrast
1399        .scrollbar_track(Color::rgb(22, 10, 48)) // #160a30
1400        .scrollbar_thumb(Color::rgb(120, 100, 160)) // #7864a0
1401        .build()
1402}
1403
1404fn synthwave_84_theme() -> Theme {
1405    ThemeBuilder::from_theme(themes::dark())
1406        .primary(Color::rgb(255, 123, 213)) // #ff7bd5 hot pink
1407        .secondary(Color::rgb(114, 241, 223)) // #72f1df mint
1408        .accent(Color::rgb(254, 215, 102)) // #fed766 yellow
1409        .background(Color::rgb(34, 20, 54)) // #221436 deep purple
1410        .surface(Color::rgb(44, 28, 68)) // #2c1c44
1411        .overlay(Color::rgb(54, 36, 82)) // #362452
1412        .text(Color::rgb(241, 233, 255)) // #f1e9ff
1413        .text_muted(Color::rgb(180, 165, 210)) // #b4a5d2
1414        .text_subtle(Color::rgb(130, 115, 165)) // #8273a5
1415        .success(Color::rgb(114, 241, 223)) // #72f1df
1416        .warning(Color::rgb(254, 215, 102)) // #fed766
1417        .error(Color::rgb(254, 73, 99)) // #fe4963
1418        .info(Color::rgb(54, 245, 253)) // #36f5fd
1419        .border(Color::rgb(70, 45, 100)) // #462d64
1420        .border_focused(Color::rgb(255, 123, 213)) // #ff7bd5
1421        .selection_bg(Color::rgb(255, 123, 213)) // #ff7bd5
1422        .selection_fg(Color::rgb(34, 20, 54)) // #221436
1423        .scrollbar_track(Color::rgb(44, 28, 68)) // #2c1c44
1424        .scrollbar_thumb(Color::rgb(130, 115, 165)) // #8273a5
1425        .build()
1426}
1427
1428/// Colorblind-accessible theme based on Tokyo Night.
1429///
1430/// Swaps green/orange/red role colors with blue/yellow/magenta so that
1431/// all role indicators remain distinguishable for deuteranopia and
1432/// protanopia users.  Background, text, and structural colors are
1433/// identical to Tokyo Night.
1434fn colorblind_theme() -> Theme {
1435    ThemeBuilder::from_theme(tokyo_night_theme())
1436        .primary(Color::rgb(0, 114, 178))
1437        .secondary(Color::rgb(204, 121, 167))
1438        .accent(Color::rgb(230, 159, 0))
1439        .success(Color::rgb(0, 158, 115))
1440        .warning(Color::rgb(240, 228, 66))
1441        .error(Color::rgb(213, 94, 0))
1442        .info(Color::rgb(86, 180, 233))
1443        .build()
1444}
1445
1446fn apply_a11y_overrides(theme: Theme) -> Theme {
1447    ThemeBuilder::from_theme(theme)
1448        .border_focused(Color::rgb(255, 255, 0))
1449        .selection_bg(AdaptiveColor::adaptive(
1450            Color::rgb(0, 0, 0),
1451            Color::rgb(255, 255, 255),
1452        ))
1453        .selection_fg(AdaptiveColor::adaptive(
1454            Color::rgb(255, 255, 255),
1455            Color::rgb(0, 0, 0),
1456        ))
1457        .build()
1458}
1459
1460fn downgrade_adaptive_color(color: AdaptiveColor, profile: ColorProfile) -> AdaptiveColor {
1461    match color {
1462        AdaptiveColor::Fixed(value) => AdaptiveColor::fixed(value.downgrade(profile)),
1463        AdaptiveColor::Adaptive { light, dark } => {
1464            AdaptiveColor::adaptive(light.downgrade(profile), dark.downgrade(profile))
1465        }
1466    }
1467}
1468
1469fn downgrade_theme_for_profile(theme: Theme, profile: ColorProfile) -> Theme {
1470    if profile == ColorProfile::TrueColor {
1471        return theme;
1472    }
1473
1474    Theme {
1475        primary: downgrade_adaptive_color(theme.primary, profile),
1476        secondary: downgrade_adaptive_color(theme.secondary, profile),
1477        accent: downgrade_adaptive_color(theme.accent, profile),
1478        background: downgrade_adaptive_color(theme.background, profile),
1479        surface: downgrade_adaptive_color(theme.surface, profile),
1480        overlay: downgrade_adaptive_color(theme.overlay, profile),
1481        text: downgrade_adaptive_color(theme.text, profile),
1482        text_muted: downgrade_adaptive_color(theme.text_muted, profile),
1483        text_subtle: downgrade_adaptive_color(theme.text_subtle, profile),
1484        success: downgrade_adaptive_color(theme.success, profile),
1485        warning: downgrade_adaptive_color(theme.warning, profile),
1486        error: downgrade_adaptive_color(theme.error, profile),
1487        info: downgrade_adaptive_color(theme.info, profile),
1488        border: downgrade_adaptive_color(theme.border, profile),
1489        border_focused: downgrade_adaptive_color(theme.border_focused, profile),
1490        selection_bg: downgrade_adaptive_color(theme.selection_bg, profile),
1491        selection_fg: downgrade_adaptive_color(theme.selection_fg, profile),
1492        scrollbar_track: downgrade_adaptive_color(theme.scrollbar_track, profile),
1493        scrollbar_thumb: downgrade_adaptive_color(theme.scrollbar_thumb, profile),
1494    }
1495}
1496
1497/// Build the semantic stylesheet from resolved theme colors.
1498///
1499/// ## Palette → Token Derivation Strategy
1500///
1501/// All tokens derive from [`ResolvedTheme`] fields — no hardcoded colors.
1502/// The mapping is organized into semantic groups:
1503///
1504/// | Group      | Tokens                          | Palette Source                |
1505/// |------------|---------------------------------|-------------------------------|
1506/// | App chrome | APP_ROOT, PANE_BASE/FOCUSED      | text, background, surface     |
1507/// | Text       | TEXT_PRIMARY/MUTED/SUBTLE        | text hierarchy fields          |
1508/// | Status     | SUCCESS/WARNING/ERROR/INFO       | success, warning, error, info |
1509/// | Results    | ROW/ROW_ALT/ROW_SELECTED         | surface, selection_*          |
1510/// | Roles      | ROLE_USER/ASSISTANT/TOOL/SYSTEM   | blend(accent,success,0.35), info, warning, error |
1511/// | Gutters    | ROLE_GUTTER_*                    | role color + 18% bg blend     |
1512/// | Scores     | SCORE_HIGH/MID/LOW               | success, info, blend(text_subtle,bg,0.35) |
1513/// | Keys       | KBD_KEY/DESC                     | accent, text_subtle           |
1514/// | Affordance | PILL_ACTIVE, TAB_ACTIVE/INACTIVE  | secondary/accent + bg blends  |
1515/// | Detail Find| FIND_CONTAINER/QUERY/MATCH_*     | surface/overlay + accent/selection |
1516///
1517/// Role assignment: User=blend(accent,success,0.35), Assistant=info, Tool=warning, System=error.
1518/// Gutter backgrounds use a uniform 18% blend factor with `resolved.background`.
1519/// Pill/tab backgrounds use blended info tints (25% and 15% respectively).
1520fn build_stylesheet(resolved: ResolvedTheme, options: StyleOptions) -> StyleSheet {
1521    let sheet = StyleSheet::new();
1522
1523    let zebra_bg = if options.gradients_enabled() {
1524        blend(resolved.surface, resolved.background, 0.35).downgrade(options.color_profile)
1525    } else {
1526        resolved.surface
1527    };
1528
1529    // Role colors must be pairwise distinct across all presets. Some upstream
1530    // themes share primary==info or accent==info, so we derive the user color
1531    // from a blend of accent+success to guarantee visual separation from
1532    // assistant (info), tool (warning), and system (error).
1533    let role_user = blend(resolved.accent, resolved.success, 0.35);
1534    let role_assistant = resolved.info;
1535    let role_tool = resolved.warning;
1536    let role_system = resolved.error;
1537
1538    sheet.define(
1539        STYLE_APP_ROOT,
1540        Style::new()
1541            .fg(to_packed(resolved.text))
1542            .bg(to_packed(resolved.background)),
1543    );
1544    sheet.define(
1545        STYLE_PANE_BASE,
1546        Style::new()
1547            .fg(to_packed(resolved.text))
1548            .bg(to_packed(resolved.surface)),
1549    );
1550    sheet.define(
1551        STYLE_PANE_FOCUSED,
1552        Style::new()
1553            .fg(to_packed(resolved.border_focused))
1554            .bg(to_packed(resolved.surface)),
1555    );
1556    // Pane title tokens: focused uses accent+bold for immediate focus clarity,
1557    // unfocused uses muted text so the eye is drawn to the active pane.
1558    sheet.define(
1559        STYLE_PANE_TITLE_FOCUSED,
1560        Style::new()
1561            .fg(to_packed(resolved.accent))
1562            .bg(to_packed(resolved.surface))
1563            .bold(),
1564    );
1565    sheet.define(
1566        STYLE_PANE_TITLE_UNFOCUSED,
1567        Style::new()
1568            .fg(to_packed(resolved.text_muted))
1569            .bg(to_packed(resolved.surface)),
1570    );
1571    // Split handle: subtle border-colored vertical divider between panes.
1572    sheet.define(
1573        STYLE_SPLIT_HANDLE,
1574        Style::new()
1575            .fg(to_packed(resolved.border))
1576            .bg(to_packed(resolved.background)),
1577    );
1578
1579    sheet.define(
1580        STYLE_TEXT_PRIMARY,
1581        Style::new().fg(to_packed(resolved.text)),
1582    );
1583    sheet.define(
1584        STYLE_TEXT_MUTED,
1585        Style::new().fg(to_packed(resolved.text_muted)),
1586    );
1587    sheet.define(
1588        STYLE_TEXT_SUBTLE,
1589        Style::new().fg(to_packed(resolved.text_subtle)),
1590    );
1591
1592    sheet.define(
1593        STYLE_STATUS_SUCCESS,
1594        Style::new().fg(to_packed(resolved.success)).bold(),
1595    );
1596    sheet.define(
1597        STYLE_STATUS_WARNING,
1598        Style::new().fg(to_packed(resolved.warning)).bold(),
1599    );
1600    sheet.define(
1601        STYLE_STATUS_ERROR,
1602        Style::new().fg(to_packed(resolved.error)).bold(),
1603    );
1604    sheet.define(
1605        STYLE_STATUS_INFO,
1606        Style::new().fg(to_packed(resolved.info)).bold(),
1607    );
1608
1609    sheet.define(
1610        STYLE_RESULT_ROW,
1611        Style::new()
1612            .fg(to_packed(resolved.text))
1613            .bg(to_packed(resolved.surface)),
1614    );
1615    sheet.define(
1616        STYLE_RESULT_ROW_ALT,
1617        Style::new()
1618            .fg(to_packed(resolved.text))
1619            .bg(to_packed(zebra_bg)),
1620    );
1621
1622    let selected_style = if options.a11y {
1623        Style::new()
1624            .fg(to_packed(resolved.selection_fg))
1625            .bg(to_packed(resolved.selection_bg))
1626            .bold()
1627            .underline()
1628    } else {
1629        Style::new()
1630            .fg(to_packed(resolved.selection_fg))
1631            .bg(to_packed(resolved.selection_bg))
1632            .bold()
1633    };
1634    sheet.define(STYLE_RESULT_ROW_SELECTED, selected_style);
1635
1636    let role_user_style = if options.a11y {
1637        Style::new().fg(to_packed(role_user)).bold().underline()
1638    } else {
1639        Style::new().fg(to_packed(role_user)).bold()
1640    };
1641    let role_assistant_style = if options.a11y {
1642        Style::new().fg(to_packed(role_assistant)).bold().italic()
1643    } else {
1644        Style::new().fg(to_packed(role_assistant)).bold()
1645    };
1646    let role_tool_style = if options.a11y {
1647        Style::new().fg(to_packed(role_tool)).underline()
1648    } else {
1649        Style::new().fg(to_packed(role_tool))
1650    };
1651    let role_system_style = if options.a11y {
1652        Style::new().fg(to_packed(role_system)).bold().underline()
1653    } else {
1654        Style::new().fg(to_packed(role_system)).bold()
1655    };
1656
1657    sheet.define(STYLE_ROLE_USER, role_user_style);
1658    sheet.define(STYLE_ROLE_ASSISTANT, role_assistant_style);
1659    sheet.define(STYLE_ROLE_TOOL, role_tool_style);
1660    sheet.define(STYLE_ROLE_SYSTEM, role_system_style);
1661
1662    sheet.define(
1663        STYLE_ROLE_GUTTER_USER,
1664        Style::new().fg(to_packed(role_user)).bg(to_packed(blend(
1665            resolved.background,
1666            role_user,
1667            0.18,
1668        ))),
1669    );
1670    sheet.define(
1671        STYLE_ROLE_GUTTER_ASSISTANT,
1672        Style::new()
1673            .fg(to_packed(role_assistant))
1674            .bg(to_packed(blend(resolved.background, role_assistant, 0.18))),
1675    );
1676    sheet.define(
1677        STYLE_ROLE_GUTTER_TOOL,
1678        Style::new().fg(to_packed(role_tool)).bg(to_packed(blend(
1679            resolved.background,
1680            role_tool,
1681            0.18,
1682        ))),
1683    );
1684    sheet.define(
1685        STYLE_ROLE_GUTTER_SYSTEM,
1686        Style::new().fg(to_packed(role_system)).bg(to_packed(blend(
1687            resolved.background,
1688            role_system,
1689            0.18,
1690        ))),
1691    );
1692
1693    sheet.define(
1694        STYLE_SCORE_HIGH,
1695        Style::new().fg(to_packed(resolved.success)).bold(),
1696    );
1697    sheet.define(
1698        STYLE_SCORE_MID,
1699        Style::new().fg(to_packed(resolved.info)).bold(),
1700    );
1701    // Use a derived dim color for SCORE_LOW to avoid collision when info==text_subtle (e.g. Nord).
1702    let score_low_fg = blend(resolved.text_subtle, resolved.background, 0.35);
1703    sheet.define(
1704        STYLE_SCORE_LOW,
1705        Style::new().fg(to_packed(score_low_fg)).dim(),
1706    );
1707
1708    // Source provenance tokens: local is muted, remote is italic+info to
1709    // visually distinguish hosts at a glance.
1710    sheet.define(
1711        STYLE_SOURCE_LOCAL,
1712        Style::new().fg(to_packed(resolved.text_muted)),
1713    );
1714    sheet.define(
1715        STYLE_SOURCE_REMOTE,
1716        Style::new().fg(to_packed(resolved.info)).italic(),
1717    );
1718    // File location path: uses text_subtle to recede behind scores and titles.
1719    sheet.define(
1720        STYLE_LOCATION,
1721        Style::new().fg(to_packed(resolved.text_subtle)),
1722    );
1723
1724    sheet.define(
1725        STYLE_KBD_KEY,
1726        Style::new().fg(to_packed(resolved.accent)).bold(),
1727    );
1728    sheet.define(
1729        STYLE_KBD_DESC,
1730        Style::new().fg(to_packed(resolved.text_subtle)),
1731    );
1732
1733    let pill_active_bg = blend(resolved.surface, resolved.info, 0.35);
1734    sheet.define(
1735        STYLE_PILL_ACTIVE,
1736        Style::new()
1737            .fg(to_packed(resolved.accent))
1738            .bg(to_packed(pill_active_bg))
1739            .bold(),
1740    );
1741    sheet.define(
1742        STYLE_PILL_INACTIVE,
1743        Style::new()
1744            .fg(to_packed(resolved.text_muted))
1745            .bg(to_packed(blend(resolved.surface, resolved.border, 0.12)))
1746            .dim(),
1747    );
1748    sheet.define(
1749        STYLE_PILL_LABEL,
1750        Style::new()
1751            .fg(to_packed(blend(resolved.text_muted, resolved.text, 0.35)))
1752            .bg(to_packed(pill_active_bg))
1753            .bold(),
1754    );
1755
1756    sheet.define(
1757        STYLE_CRUMB_ACTIVE,
1758        Style::new().fg(to_packed(resolved.accent)).bold(),
1759    );
1760    sheet.define(
1761        STYLE_CRUMB_INACTIVE,
1762        Style::new().fg(to_packed(resolved.text_subtle)),
1763    );
1764    sheet.define(
1765        STYLE_CRUMB_SEPARATOR,
1766        Style::new().fg(to_packed(resolved.border)),
1767    );
1768
1769    sheet.define(
1770        STYLE_TAB_ACTIVE,
1771        Style::new()
1772            .fg(to_packed(resolved.accent))
1773            .bg(to_packed(blend(resolved.surface, resolved.info, 0.15)))
1774            .bold()
1775            .underline(),
1776    );
1777    sheet.define(
1778        STYLE_TAB_INACTIVE,
1779        Style::new().fg(to_packed(resolved.text_muted)).underline(),
1780    );
1781    sheet.define(
1782        STYLE_DETAIL_FIND_CONTAINER,
1783        Style::new()
1784            .fg(to_packed(resolved.text))
1785            .bg(to_packed(blend(resolved.overlay, resolved.border, 0.30))),
1786    );
1787    sheet.define(
1788        STYLE_DETAIL_FIND_QUERY,
1789        Style::new()
1790            .fg(to_packed(resolved.accent))
1791            .bold()
1792            .underline(),
1793    );
1794    sheet.define(
1795        STYLE_DETAIL_FIND_MATCH_ACTIVE,
1796        Style::new()
1797            .fg(to_packed(resolved.selection_fg))
1798            .bg(to_packed(resolved.selection_bg))
1799            .bold(),
1800    );
1801    sheet.define(
1802        STYLE_DETAIL_FIND_MATCH_INACTIVE,
1803        Style::new()
1804            .fg(to_packed(resolved.text_muted))
1805            .bg(to_packed(blend(
1806                resolved.surface,
1807                resolved.border_focused,
1808                0.28,
1809            ))),
1810    );
1811    sheet.define(
1812        STYLE_QUERY_HIGHLIGHT,
1813        Style::new()
1814            .fg(to_packed(resolved.accent))
1815            .bold()
1816            .underline(),
1817    );
1818
1819    // Search bar active: stronger accent background for "glow" effect
1820    sheet.define(
1821        STYLE_SEARCH_FOCUS,
1822        Style::new()
1823            .fg(to_packed(resolved.accent))
1824            .bg(to_packed(blend(resolved.surface, resolved.accent, 0.18)))
1825            .bold(),
1826    );
1827
1828    // Modal backdrop: dimmed background for overlay modals
1829    sheet.define(
1830        STYLE_MODAL_BACKDROP,
1831        Style::new().bg(to_packed(blend(
1832            resolved.background,
1833            Color::rgb(0, 0, 0),
1834            0.45,
1835        ))),
1836    );
1837
1838    sheet
1839}
1840
1841fn to_packed(color: Color) -> PackedRgba {
1842    let rgb = color.to_rgb();
1843    PackedRgba::rgb(rgb.r, rgb.g, rgb.b)
1844}
1845
1846fn contrast_check(pair: &'static str, fg: Color, bg: Color, minimum: f64) -> ThemeContrastCheck {
1847    let ratio = ftui::style::contrast_ratio_packed(to_packed(fg), to_packed(bg));
1848    ThemeContrastCheck {
1849        pair,
1850        ratio,
1851        minimum,
1852        passes: ratio >= minimum,
1853    }
1854}
1855
1856fn build_contrast_report(resolved: ResolvedTheme) -> ThemeContrastReport {
1857    ThemeContrastReport {
1858        checks: vec![
1859            contrast_check("text/background", resolved.text, resolved.background, 3.0),
1860            contrast_check("text/surface", resolved.text, resolved.surface, 2.5),
1861            contrast_check(
1862                "selection_fg/selection_bg",
1863                resolved.selection_fg,
1864                resolved.selection_bg,
1865                3.0,
1866            ),
1867            contrast_check(
1868                "text_muted/background",
1869                resolved.text_muted,
1870                resolved.background,
1871                3.0,
1872            ),
1873            contrast_check(
1874                "border_focused/background",
1875                resolved.border_focused,
1876                resolved.background,
1877                3.0,
1878            ),
1879        ],
1880    }
1881}
1882
1883fn blend(a: Color, b: Color, t: f32) -> Color {
1884    let t = t.clamp(0.0, 1.0);
1885    let ar = a.to_rgb();
1886    let br = b.to_rgb();
1887
1888    let blend_channel = |left: u8, right: u8| -> u8 {
1889        let mixed = left as f32 + (right as f32 - left as f32) * t;
1890        mixed.round().clamp(0.0, 255.0) as u8
1891    };
1892
1893    Color::rgb(
1894        blend_channel(ar.r, br.r),
1895        blend_channel(ar.g, br.g),
1896        blend_channel(ar.b, br.b),
1897    )
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902    use super::*;
1903    use std::time::{SystemTime, UNIX_EPOCH};
1904
1905    #[test]
1906    fn preset_parse_and_cycles_are_stable() {
1907        assert_eq!(
1908            UiThemePreset::parse("dark"),
1909            Some(UiThemePreset::TokyoNight)
1910        );
1911        assert_eq!(UiThemePreset::parse("light"), Some(UiThemePreset::Daylight));
1912        assert_eq!(
1913            UiThemePreset::parse("catppuccin"),
1914            Some(UiThemePreset::Catppuccin)
1915        );
1916        assert_eq!(
1917            UiThemePreset::parse("dracula"),
1918            Some(UiThemePreset::Dracula)
1919        );
1920        assert_eq!(UiThemePreset::parse("nord"), Some(UiThemePreset::Nord));
1921        assert_eq!(
1922            UiThemePreset::parse("high_contrast"),
1923            Some(UiThemePreset::HighContrast)
1924        );
1925        assert_eq!(
1926            UiThemePreset::parse("gruvbox"),
1927            Some(UiThemePreset::GruvboxDark)
1928        );
1929        assert_eq!(
1930            UiThemePreset::parse("rose-pine"),
1931            Some(UiThemePreset::RosePine)
1932        );
1933
1934        assert_eq!(UiThemePreset::TokyoNight.next(), UiThemePreset::Daylight);
1935        assert_eq!(
1936            UiThemePreset::Daylight.previous(),
1937            UiThemePreset::TokyoNight
1938        );
1939        assert_eq!(
1940            UiThemePreset::TokyoNight.previous(),
1941            UiThemePreset::Colorblind
1942        );
1943    }
1944
1945    #[test]
1946    fn options_from_values_honor_opt_out_and_profile_override() {
1947        let options = StyleOptions::from_env_values(EnvValues {
1948            no_color: Some("1"),
1949            cass_respect_no_color: Some("1"),
1950            cass_no_color: None,
1951            colorterm: Some("truecolor"),
1952            term: Some("xterm-256color"),
1953            cass_no_icons: Some("1"),
1954            cass_no_gradient: Some("1"),
1955            cass_a11y: Some("true"),
1956            cass_theme: Some("nord"),
1957            cass_color_profile: Some("ansi16"),
1958        });
1959
1960        assert_eq!(options.preset, UiThemePreset::Nord);
1961        assert!(options.no_color);
1962        assert!(options.no_icons);
1963        assert!(options.no_gradient);
1964        assert!(options.a11y);
1965        assert_eq!(options.color_profile, ColorProfile::Mono);
1966    }
1967
1968    #[test]
1969    fn options_profile_override_applies_when_color_enabled() {
1970        let options = StyleOptions::from_env_values(EnvValues {
1971            no_color: None,
1972            cass_respect_no_color: None,
1973            cass_no_color: None,
1974            colorterm: Some("truecolor"),
1975            term: Some("xterm-256color"),
1976            cass_no_icons: None,
1977            cass_no_gradient: None,
1978            cass_a11y: Some("0"),
1979            cass_theme: Some("dark"),
1980            cass_color_profile: Some("ansi16"),
1981        });
1982
1983        assert_eq!(options.color_profile, ColorProfile::Ansi16);
1984        assert!(!options.no_color);
1985    }
1986
1987    #[test]
1988    fn options_ignore_no_color_unless_explicitly_requested() {
1989        let options = StyleOptions::from_env_values(EnvValues {
1990            no_color: Some("1"),
1991            cass_respect_no_color: None,
1992            cass_no_color: None,
1993            colorterm: Some("truecolor"),
1994            term: Some("xterm-256color"),
1995            cass_no_icons: None,
1996            cass_no_gradient: None,
1997            cass_a11y: Some("0"),
1998            cass_theme: Some("dark"),
1999            cass_color_profile: None,
2000        });
2001
2002        assert!(!options.no_color);
2003        assert_eq!(options.color_profile, ColorProfile::TrueColor);
2004    }
2005
2006    #[test]
2007    fn cass_no_color_always_forces_monochrome() {
2008        let options = StyleOptions::from_env_values(EnvValues {
2009            no_color: None,
2010            cass_respect_no_color: None,
2011            cass_no_color: Some("1"),
2012            colorterm: Some("truecolor"),
2013            term: Some("xterm-256color"),
2014            cass_no_icons: None,
2015            cass_no_gradient: None,
2016            cass_a11y: Some("0"),
2017            cass_theme: Some("dark"),
2018            cass_color_profile: Some("truecolor"),
2019        });
2020
2021        assert!(options.no_color);
2022        assert_eq!(options.color_profile, ColorProfile::Mono);
2023    }
2024
2025    #[test]
2026    fn cass_no_color_falsy_values_do_not_force_monochrome() {
2027        for falsy in &["0", "false", "off", "no"] {
2028            let options = StyleOptions::from_env_values(EnvValues {
2029                no_color: None,
2030                cass_respect_no_color: None,
2031                cass_no_color: Some(falsy),
2032                colorterm: Some("truecolor"),
2033                term: Some("xterm-256color"),
2034                cass_no_icons: None,
2035                cass_no_gradient: None,
2036                cass_a11y: Some("0"),
2037                cass_theme: Some("dark"),
2038                cass_color_profile: None,
2039            });
2040            assert!(
2041                !options.no_color,
2042                "CASS_NO_COLOR={falsy} must not force monochrome"
2043            );
2044            assert_eq!(
2045                options.color_profile,
2046                ColorProfile::TrueColor,
2047                "CASS_NO_COLOR={falsy} should preserve detected profile"
2048            );
2049        }
2050    }
2051
2052    // -- env/capability edge-case tests (2dccg.10.4) --
2053
2054    #[test]
2055    fn no_color_without_respect_flag_preserves_full_color() {
2056        // NO_COLOR alone must NOT disable colors unless CASS_RESPECT_NO_COLOR is set.
2057        let options = StyleOptions::from_env_values(EnvValues {
2058            no_color: Some("1"),
2059            cass_respect_no_color: None,
2060            cass_no_color: None,
2061            colorterm: None,
2062            term: None,
2063            cass_no_icons: None,
2064            cass_no_gradient: None,
2065            cass_a11y: None,
2066            cass_theme: None,
2067            cass_color_profile: None,
2068        });
2069        assert!(!options.no_color, "NO_COLOR alone must be ignored");
2070        assert!(!options.no_gradient, "gradient should remain enabled");
2071    }
2072
2073    #[test]
2074    fn respect_no_color_with_falsy_value_is_not_truthy() {
2075        // CASS_RESPECT_NO_COLOR="0" should be treated as falsy.
2076        for falsy in &["0", "false", "off", "no"] {
2077            let options = StyleOptions::from_env_values(EnvValues {
2078                no_color: Some("1"),
2079                cass_respect_no_color: Some(falsy),
2080                cass_no_color: None,
2081                colorterm: Some("truecolor"),
2082                term: None,
2083                cass_no_icons: None,
2084                cass_no_gradient: None,
2085                cass_a11y: None,
2086                cass_theme: None,
2087                cass_color_profile: None,
2088            });
2089            assert!(
2090                !options.no_color,
2091                "CASS_RESPECT_NO_COLOR={falsy} must be falsy"
2092            );
2093            assert_eq!(options.color_profile, ColorProfile::TrueColor);
2094        }
2095    }
2096
2097    #[test]
2098    fn invalid_color_profile_falls_back_to_env_detection() {
2099        let options = StyleOptions::from_env_values(EnvValues {
2100            no_color: None,
2101            cass_respect_no_color: None,
2102            cass_no_color: None,
2103            colorterm: Some("truecolor"),
2104            term: Some("xterm-256color"),
2105            cass_no_icons: None,
2106            cass_no_gradient: None,
2107            cass_a11y: None,
2108            cass_theme: None,
2109            cass_color_profile: Some("garbage-value"),
2110        });
2111        // Invalid CASS_COLOR_PROFILE → fallback to COLORTERM/TERM detection.
2112        assert_eq!(options.color_profile, ColorProfile::TrueColor);
2113        assert!(!options.no_color);
2114    }
2115
2116    #[test]
2117    fn a11y_cascades_no_gradient() {
2118        // CASS_A11Y=1 should force no_gradient even without CASS_NO_GRADIENT.
2119        let options = StyleOptions::from_env_values(EnvValues {
2120            no_color: None,
2121            cass_respect_no_color: None,
2122            cass_no_color: None,
2123            colorterm: Some("truecolor"),
2124            term: None,
2125            cass_no_icons: None,
2126            cass_no_gradient: None,
2127            cass_a11y: Some("1"),
2128            cass_theme: None,
2129            cass_color_profile: None,
2130        });
2131        assert!(options.a11y);
2132        assert!(
2133            options.no_gradient,
2134            "a11y must cascade into no_gradient=true"
2135        );
2136        assert!(!options.no_color, "a11y must not cascade into no_color");
2137        assert_eq!(
2138            options.color_profile,
2139            ColorProfile::TrueColor,
2140            "a11y must not downgrade color profile"
2141        );
2142    }
2143
2144    #[test]
2145    fn no_icons_is_independent_of_color_state() {
2146        // CASS_NO_ICONS should work even with full color enabled.
2147        let with_icons_off = StyleOptions::from_env_values(EnvValues {
2148            no_color: None,
2149            cass_respect_no_color: None,
2150            cass_no_color: None,
2151            colorterm: Some("truecolor"),
2152            term: None,
2153            cass_no_icons: Some("1"),
2154            cass_no_gradient: None,
2155            cass_a11y: None,
2156            cass_theme: None,
2157            cass_color_profile: None,
2158        });
2159        assert!(with_icons_off.no_icons);
2160        assert!(!with_icons_off.no_color);
2161        assert_eq!(with_icons_off.color_profile, ColorProfile::TrueColor);
2162    }
2163
2164    #[test]
2165    fn no_icons_falsy_values_do_not_disable_icons() {
2166        for falsy in &["0", "false", "off", "no"] {
2167            let options = StyleOptions::from_env_values(EnvValues {
2168                no_color: None,
2169                cass_respect_no_color: None,
2170                cass_no_color: None,
2171                colorterm: Some("truecolor"),
2172                term: Some("xterm-256color"),
2173                cass_no_icons: Some(falsy),
2174                cass_no_gradient: None,
2175                cass_a11y: Some("0"),
2176                cass_theme: Some("dark"),
2177                cass_color_profile: None,
2178            });
2179            assert!(
2180                !options.no_icons,
2181                "CASS_NO_ICONS={falsy} should keep icons enabled"
2182            );
2183        }
2184    }
2185
2186    #[test]
2187    fn no_gradient_falsy_values_do_not_disable_gradients() {
2188        for falsy in &["0", "false", "off", "no"] {
2189            let options = StyleOptions::from_env_values(EnvValues {
2190                no_color: None,
2191                cass_respect_no_color: None,
2192                cass_no_color: None,
2193                colorterm: Some("truecolor"),
2194                term: Some("xterm-256color"),
2195                cass_no_icons: None,
2196                cass_no_gradient: Some(falsy),
2197                cass_a11y: Some("0"),
2198                cass_theme: Some("dark"),
2199                cass_color_profile: None,
2200            });
2201            assert!(
2202                !options.no_gradient,
2203                "CASS_NO_GRADIENT={falsy} should keep gradients enabled"
2204            );
2205            assert!(
2206                options.gradients_enabled(),
2207                "gradients should remain enabled when CASS_NO_GRADIENT is falsy"
2208            );
2209        }
2210    }
2211
2212    #[test]
2213    fn dark_mode_follows_preset() {
2214        // Light presets → dark_mode=false, all others → true.
2215        let presets_and_expected = [
2216            ("dark", true),
2217            ("light", false),
2218            ("nord", true),
2219            ("cat", true),
2220            ("dracula", true),
2221            ("solarized-light", false),
2222            ("solarized-dark", true),
2223            ("gruvbox", true),
2224        ];
2225        for (name, expected_dark) in presets_and_expected {
2226            let options = StyleOptions::from_env_values(EnvValues {
2227                cass_theme: Some(name),
2228                ..EnvValues::default()
2229            });
2230            assert_eq!(
2231                options.dark_mode, expected_dark,
2232                "preset {name}: expected dark_mode={expected_dark}"
2233            );
2234        }
2235    }
2236
2237    #[test]
2238    fn unknown_theme_falls_back_to_tokyo_night() {
2239        let options = StyleOptions::from_env_values(EnvValues {
2240            cass_theme: Some("nonexistent"),
2241            ..EnvValues::default()
2242        });
2243        assert_eq!(options.preset, UiThemePreset::TokyoNight);
2244        assert!(options.dark_mode);
2245    }
2246
2247    #[test]
2248    fn gradients_enabled_requires_color_support() {
2249        // Mono profile → no gradients even if no_gradient is false.
2250        let mono = StyleOptions {
2251            color_profile: ColorProfile::Mono,
2252            no_gradient: false,
2253            ..StyleOptions::default()
2254        };
2255        assert!(!mono.gradients_enabled());
2256
2257        // TrueColor with no_gradient=true → no gradients.
2258        let no_grad = StyleOptions {
2259            color_profile: ColorProfile::TrueColor,
2260            no_gradient: true,
2261            ..StyleOptions::default()
2262        };
2263        assert!(!no_grad.gradients_enabled());
2264
2265        // TrueColor with no_gradient=false → gradients enabled.
2266        let full = StyleOptions {
2267            color_profile: ColorProfile::TrueColor,
2268            no_gradient: false,
2269            ..StyleOptions::default()
2270        };
2271        assert!(full.gradients_enabled());
2272    }
2273
2274    #[test]
2275    fn env_truthy_edge_cases() {
2276        // Verify env_truthy handles edge values correctly.
2277        assert!(!env_truthy(None), "None → false");
2278        assert!(
2279            !env_truthy(Some("")),
2280            "empty string → false (treated as unset)"
2281        );
2282        assert!(env_truthy(Some("1")), "\"1\" → true");
2283        assert!(env_truthy(Some("yes")), "\"yes\" → true");
2284        assert!(env_truthy(Some("true")), "\"true\" → true... wait");
2285        // Actually "true" is in the falsy list? No — only "false" is falsy.
2286        // Re-check: falsy = "0", "false", "off", "no"
2287        assert!(!env_truthy(Some("false")), "\"false\" → false");
2288        assert!(
2289            !env_truthy(Some("FALSE")),
2290            "\"FALSE\" → false (case insensitive)"
2291        );
2292        assert!(!env_truthy(Some("  Off  ")), "trimmed \"Off\" → false");
2293        assert!(!env_truthy(Some("NO")), "\"NO\" → false");
2294        assert!(env_truthy(Some("anything")), "arbitrary string → true");
2295    }
2296
2297    #[test]
2298    fn env_precedence_full_matrix() {
2299        // Verify the full precedence chain described in the doc comment.
2300
2301        // Priority 1: CASS_NO_COLOR trumps everything.
2302        let p1 = StyleOptions::from_env_values(EnvValues {
2303            cass_no_color: Some("1"),
2304            cass_color_profile: Some("truecolor"),
2305            colorterm: Some("truecolor"),
2306            ..EnvValues::default()
2307        });
2308        assert!(p1.no_color);
2309        assert_eq!(p1.color_profile, ColorProfile::Mono);
2310
2311        // Priority 2: RESPECT_NO_COLOR + NO_COLOR beats CASS_COLOR_PROFILE.
2312        let p2 = StyleOptions::from_env_values(EnvValues {
2313            no_color: Some("1"),
2314            cass_respect_no_color: Some("1"),
2315            cass_color_profile: Some("truecolor"),
2316            ..EnvValues::default()
2317        });
2318        assert!(p2.no_color);
2319        assert_eq!(p2.color_profile, ColorProfile::Mono);
2320
2321        // Priority 3: CASS_COLOR_PROFILE overrides env detection.
2322        let p3 = StyleOptions::from_env_values(EnvValues {
2323            colorterm: Some("truecolor"),
2324            term: Some("xterm-256color"),
2325            cass_color_profile: Some("ansi16"),
2326            ..EnvValues::default()
2327        });
2328        assert!(!p3.no_color);
2329        assert_eq!(p3.color_profile, ColorProfile::Ansi16);
2330
2331        // Priority 4: Fallback to env detection.
2332        let p4 = StyleOptions::from_env_values(EnvValues {
2333            colorterm: Some("truecolor"),
2334            term: Some("xterm-256color"),
2335            ..EnvValues::default()
2336        });
2337        assert!(!p4.no_color);
2338        assert_eq!(p4.color_profile, ColorProfile::TrueColor);
2339
2340        // Bare minimum: no env vars at all → defaults.
2341        let bare = StyleOptions::from_env_values(EnvValues::default());
2342        assert!(!bare.no_color);
2343        assert_eq!(bare.preset, UiThemePreset::TokyoNight);
2344        assert!(bare.dark_mode);
2345    }
2346
2347    #[test]
2348    fn style_context_mono_produces_no_fg_bg() {
2349        // Under Mono profile, styles should still resolve (so code doesn't panic)
2350        // but colors are expected to be downgraded.
2351        let ctx = StyleContext::from_options(StyleOptions {
2352            preset: UiThemePreset::TokyoNight,
2353            dark_mode: true,
2354            color_profile: ColorProfile::Mono,
2355            no_color: true,
2356            no_icons: false,
2357            no_gradient: true,
2358            a11y: false,
2359        });
2360        // Should not panic when resolving any token.
2361        let _ = ctx.style(STYLE_TEXT_PRIMARY);
2362        let _ = ctx.style(STYLE_APP_ROOT);
2363        let _ = ctx.style(STYLE_ROLE_USER);
2364        let _ = ctx.style(STYLE_SCORE_HIGH);
2365    }
2366
2367    #[test]
2368    fn all_presets_produce_valid_style_context() {
2369        // Every preset should build a StyleContext without panicking,
2370        // for both full-color and mono profiles.
2371        for preset in UiThemePreset::all() {
2372            for &profile in &[
2373                ColorProfile::TrueColor,
2374                ColorProfile::Ansi256,
2375                ColorProfile::Ansi16,
2376                ColorProfile::Mono,
2377            ] {
2378                let dark_mode = !matches!(
2379                    preset,
2380                    UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2381                );
2382                let ctx = StyleContext::from_options(StyleOptions {
2383                    preset,
2384                    dark_mode,
2385                    color_profile: profile,
2386                    no_color: profile == ColorProfile::Mono,
2387                    no_icons: false,
2388                    no_gradient: profile == ColorProfile::Mono,
2389                    a11y: false,
2390                });
2391                // Smoke test: resolve every token without panicking.
2392                for &(_, token) in ALL_STYLE_TOKENS {
2393                    let _ = ctx.style(token);
2394                }
2395            }
2396        }
2397    }
2398
2399    #[test]
2400    fn dark_preset_matches_tokyo_night_palette() {
2401        let context = StyleContext::from_options(StyleOptions {
2402            preset: UiThemePreset::TokyoNight,
2403            dark_mode: true,
2404            color_profile: ColorProfile::TrueColor,
2405            no_color: false,
2406            no_icons: false,
2407            no_gradient: false,
2408            a11y: false,
2409        });
2410
2411        assert_eq!(context.resolved.background, Color::rgb(26, 27, 38));
2412        assert_eq!(context.resolved.surface, Color::rgb(36, 40, 59));
2413        assert_eq!(context.resolved.text, Color::rgb(192, 202, 245));
2414        assert_eq!(context.resolved.border_focused, Color::rgb(125, 145, 200));
2415    }
2416
2417    #[test]
2418    fn style_context_builds_required_semantic_styles() {
2419        let context = StyleContext::from_options(StyleOptions {
2420            preset: UiThemePreset::TokyoNight,
2421            dark_mode: true,
2422            color_profile: ColorProfile::TrueColor,
2423            no_color: false,
2424            no_icons: false,
2425            no_gradient: false,
2426            a11y: false,
2427        });
2428
2429        for key in [
2430            STYLE_APP_ROOT,
2431            STYLE_PANE_BASE,
2432            STYLE_PANE_FOCUSED,
2433            STYLE_PANE_TITLE_FOCUSED,
2434            STYLE_PANE_TITLE_UNFOCUSED,
2435            STYLE_SPLIT_HANDLE,
2436            STYLE_RESULT_ROW,
2437            STYLE_RESULT_ROW_ALT,
2438            STYLE_RESULT_ROW_SELECTED,
2439            STYLE_ROLE_USER,
2440            STYLE_ROLE_ASSISTANT,
2441            STYLE_ROLE_TOOL,
2442            STYLE_ROLE_SYSTEM,
2443            STYLE_ROLE_GUTTER_USER,
2444            STYLE_ROLE_GUTTER_ASSISTANT,
2445            STYLE_ROLE_GUTTER_TOOL,
2446            STYLE_ROLE_GUTTER_SYSTEM,
2447            STYLE_SCORE_HIGH,
2448            STYLE_SCORE_MID,
2449            STYLE_SCORE_LOW,
2450            STYLE_SOURCE_LOCAL,
2451            STYLE_SOURCE_REMOTE,
2452            STYLE_LOCATION,
2453            STYLE_KBD_KEY,
2454            STYLE_KBD_DESC,
2455            STYLE_PILL_ACTIVE,
2456            STYLE_PILL_INACTIVE,
2457            STYLE_PILL_LABEL,
2458            STYLE_CRUMB_ACTIVE,
2459            STYLE_CRUMB_INACTIVE,
2460            STYLE_CRUMB_SEPARATOR,
2461            STYLE_TAB_ACTIVE,
2462            STYLE_TAB_INACTIVE,
2463            STYLE_DETAIL_FIND_CONTAINER,
2464            STYLE_DETAIL_FIND_QUERY,
2465            STYLE_DETAIL_FIND_MATCH_ACTIVE,
2466            STYLE_DETAIL_FIND_MATCH_INACTIVE,
2467            STYLE_QUERY_HIGHLIGHT,
2468            STYLE_SEARCH_FOCUS,
2469            STYLE_MODAL_BACKDROP,
2470        ] {
2471            assert!(context.sheet.contains(key), "missing style token: {key}");
2472        }
2473    }
2474
2475    #[test]
2476    fn detail_find_token_hierarchy_is_explicit_and_theme_aware() {
2477        for preset in UiThemePreset::all() {
2478            let ctx = context_for_preset(preset);
2479            let container = ctx.style(STYLE_DETAIL_FIND_CONTAINER);
2480            let query = ctx.style(STYLE_DETAIL_FIND_QUERY);
2481            let active = ctx.style(STYLE_DETAIL_FIND_MATCH_ACTIVE);
2482            let inactive = ctx.style(STYLE_DETAIL_FIND_MATCH_INACTIVE);
2483
2484            assert!(
2485                container.bg.is_some(),
2486                "find container should provide a distinct background for preset {}",
2487                preset.name()
2488            );
2489            assert!(
2490                query == query.bold(),
2491                "find query should be emphasized (bold) for preset {}",
2492                preset.name()
2493            );
2494            assert!(
2495                active == active.bold() && active.bg.is_some(),
2496                "active match state should be high-emphasis for preset {}",
2497                preset.name()
2498            );
2499            assert!(
2500                inactive.fg.is_some(),
2501                "inactive match counter should still be legible for preset {}",
2502                preset.name()
2503            );
2504            assert_ne!(
2505                format!("{:?}", active),
2506                format!("{:?}", inactive),
2507                "active/inactive match states must be visually distinct for preset {}",
2508                preset.name()
2509            );
2510        }
2511    }
2512
2513    #[test]
2514    fn detail_find_tokens_remain_legible_in_mono_mode() {
2515        let ctx = StyleContext::from_options(StyleOptions {
2516            preset: UiThemePreset::TokyoNight,
2517            dark_mode: true,
2518            color_profile: ColorProfile::Mono,
2519            no_color: true,
2520            no_icons: false,
2521            no_gradient: true,
2522            a11y: false,
2523        });
2524
2525        for (label, token) in [
2526            ("container", STYLE_DETAIL_FIND_CONTAINER),
2527            ("query", STYLE_DETAIL_FIND_QUERY),
2528            ("match_active", STYLE_DETAIL_FIND_MATCH_ACTIVE),
2529            ("match_inactive", STYLE_DETAIL_FIND_MATCH_INACTIVE),
2530        ] {
2531            let style = ctx.style(token);
2532            assert!(
2533                style.fg.is_some() || style.bg.is_some(),
2534                "detail-find {label} token should remain visible in mono mode"
2535            );
2536        }
2537    }
2538
2539    #[test]
2540    fn mono_profile_downgrades_theme_colors() {
2541        let context = StyleContext::from_options(StyleOptions {
2542            preset: UiThemePreset::Dracula,
2543            dark_mode: true,
2544            color_profile: ColorProfile::Mono,
2545            no_color: true,
2546            no_icons: false,
2547            no_gradient: true,
2548            a11y: false,
2549        });
2550
2551        assert!(matches!(context.resolved.primary, Color::Mono(_)));
2552        assert!(matches!(context.resolved.background, Color::Mono(_)));
2553        assert!(matches!(context.resolved.text, Color::Mono(_)));
2554    }
2555
2556    #[test]
2557    fn accessibility_role_markers_prioritize_text_labels() {
2558        let markers = RoleMarkers::from_options(StyleOptions {
2559            preset: UiThemePreset::TokyoNight,
2560            dark_mode: true,
2561            color_profile: ColorProfile::Ansi256,
2562            no_color: false,
2563            no_icons: true,
2564            no_gradient: true,
2565            a11y: true,
2566        });
2567
2568        assert_eq!(markers.user, "[user]");
2569        assert_eq!(markers.assistant, "[assistant]");
2570        assert_eq!(markers.tool, "[tool]");
2571        assert_eq!(markers.system, "[system]");
2572    }
2573
2574    #[test]
2575    fn base_contrast_is_wcag_aa_or_higher_for_all_presets() {
2576        for preset in UiThemePreset::all() {
2577            let dark_mode = !matches!(
2578                preset,
2579                UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2580            );
2581            let context = StyleContext::from_options(StyleOptions {
2582                preset,
2583                dark_mode,
2584                color_profile: ColorProfile::TrueColor,
2585                no_color: false,
2586                no_icons: false,
2587                no_gradient: false,
2588                a11y: false,
2589            });
2590
2591            let root = context.style(STYLE_APP_ROOT);
2592            let fg = root.fg.expect("app.root must define foreground");
2593            let bg = root.bg.expect("app.root must define background");
2594            let ratio = ftui::style::contrast_ratio_packed(fg, bg);
2595            assert!(
2596                ratio >= 3.5,
2597                "contrast too low for {}: {ratio}",
2598                preset.name()
2599            );
2600        }
2601    }
2602
2603    #[test]
2604    fn high_contrast_preset_keeps_selection_legible() {
2605        let context = StyleContext::from_options(StyleOptions {
2606            preset: UiThemePreset::HighContrast,
2607            dark_mode: true,
2608            color_profile: ColorProfile::Ansi16,
2609            no_color: false,
2610            no_icons: false,
2611            no_gradient: true,
2612            a11y: true,
2613        });
2614
2615        let selected = context.style(STYLE_RESULT_ROW_SELECTED);
2616        let fg = selected
2617            .fg
2618            .expect("selected row style should define foreground color");
2619        let bg = selected
2620            .bg
2621            .expect("selected row style should define background color");
2622
2623        let ratio = ftui::style::contrast_ratio_packed(fg, bg);
2624        assert!(ratio >= 4.5, "selected row contrast too low: {ratio}");
2625    }
2626
2627    #[test]
2628    fn theme_config_roundtrip_preserves_fields() {
2629        let config = ThemeConfig {
2630            version: THEME_CONFIG_VERSION,
2631            base_preset: Some(UiThemePreset::Nord),
2632        };
2633
2634        let json = config
2635            .to_json_pretty()
2636            .expect("theme config should serialize");
2637        let parsed = ThemeConfig::from_json_str(&json).expect("theme config should deserialize");
2638        assert_eq!(parsed, config);
2639    }
2640
2641    #[test]
2642    fn theme_config_json_snapshot_is_stable() {
2643        let config = ThemeConfig {
2644            version: THEME_CONFIG_VERSION,
2645            base_preset: Some(UiThemePreset::Catppuccin),
2646        };
2647
2648        let json = config.to_json_pretty().expect("config should serialize");
2649        let expected = r##"{
2650  "version": 1,
2651  "base_preset": "catppuccin"
2652}"##;
2653        assert_eq!(json, expected);
2654    }
2655
2656    #[test]
2657    fn theme_config_allows_known_preset_aliases() {
2658        // Old format with "colors" key should be silently ignored (no deny_unknown_fields)
2659        let config_json = r#"{
2660  "version": 1,
2661  "base_preset": "high_contrast",
2662  "colors": {}
2663}"#;
2664
2665        let parsed =
2666            ThemeConfig::from_json_str(config_json).expect("preset alias should deserialize");
2667        assert_eq!(parsed.base_preset, Some(UiThemePreset::HighContrast));
2668    }
2669
2670    #[test]
2671    fn theme_config_backwards_compat_dark_alias() {
2672        let config_json = r#"{"version":1,"base_preset":"dark"}"#;
2673        let parsed = ThemeConfig::from_json_str(config_json).expect("dark alias should work");
2674        assert_eq!(parsed.base_preset, Some(UiThemePreset::TokyoNight));
2675    }
2676
2677    #[test]
2678    fn theme_config_backwards_compat_light_alias() {
2679        let config_json = r#"{"version":1,"base_preset":"light"}"#;
2680        let parsed = ThemeConfig::from_json_str(config_json).expect("light alias should work");
2681        assert_eq!(parsed.base_preset, Some(UiThemePreset::Daylight));
2682    }
2683
2684    #[test]
2685    fn preset_downgrades_to_ansi16_profile() {
2686        let config = ThemeConfig {
2687            version: THEME_CONFIG_VERSION,
2688            base_preset: Some(UiThemePreset::TokyoNight),
2689        };
2690
2691        let context = StyleContext::from_options_with_theme_config(
2692            StyleOptions {
2693                preset: UiThemePreset::Daylight,
2694                dark_mode: false,
2695                color_profile: ColorProfile::Ansi16,
2696                no_color: false,
2697                no_icons: false,
2698                no_gradient: false,
2699                a11y: false,
2700            },
2701            &config,
2702        );
2703
2704        assert!(matches!(context.resolved.text, Color::Ansi16(_)));
2705        assert!(matches!(context.resolved.background, Color::Ansi16(_)));
2706    }
2707
2708    #[test]
2709    fn theme_config_file_roundtrip_works() {
2710        let now = SystemTime::now()
2711            .duration_since(UNIX_EPOCH)
2712            .expect("clock should be valid")
2713            .as_nanos();
2714        let path = std::env::temp_dir().join(format!("cass-theme-config-{now}.json"));
2715
2716        let config = ThemeConfig {
2717            version: THEME_CONFIG_VERSION,
2718            base_preset: Some(UiThemePreset::Dracula),
2719        };
2720
2721        config
2722            .save_to_path(&path)
2723            .expect("theme config should save to disk");
2724        let loaded = ThemeConfig::load_from_path(&path).expect("theme config should reload");
2725        assert_eq!(loaded, config);
2726
2727        let _ = fs::remove_file(path);
2728    }
2729
2730    #[test]
2731    fn base_preset_switches_dark_mode() {
2732        let config = ThemeConfig {
2733            version: THEME_CONFIG_VERSION,
2734            base_preset: Some(UiThemePreset::Daylight),
2735        };
2736        let ctx = StyleContext::from_options_with_theme_config(
2737            StyleOptions::default(), // Dark by default
2738            &config,
2739        );
2740
2741        assert_eq!(ctx.options.preset, UiThemePreset::Daylight);
2742        assert!(!ctx.options.dark_mode, "Daylight preset → dark_mode=false");
2743    }
2744
2745    #[test]
2746    fn empty_config_does_not_change_theme() {
2747        let config = ThemeConfig {
2748            version: THEME_CONFIG_VERSION,
2749            base_preset: None,
2750        };
2751        let base_ctx = StyleContext::from_options(StyleOptions::default());
2752        let overridden_ctx =
2753            StyleContext::from_options_with_theme_config(StyleOptions::default(), &config);
2754
2755        // Same preset, same resolved text color.
2756        assert_eq!(base_ctx.resolved.text, overridden_ctx.resolved.text);
2757        assert_eq!(
2758            base_ctx.resolved.background,
2759            overridden_ctx.resolved.background
2760        );
2761    }
2762
2763    #[test]
2764    fn config_base_preset_overrides_options_preset() {
2765        // ThemeConfig.base_preset wins over StyleOptions.preset.
2766        let config = ThemeConfig {
2767            version: THEME_CONFIG_VERSION,
2768            base_preset: Some(UiThemePreset::Nord),
2769        };
2770        let ctx = StyleContext::from_options_with_theme_config(
2771            StyleOptions {
2772                preset: UiThemePreset::TokyoNight,
2773                ..StyleOptions::default()
2774            },
2775            &config,
2776        );
2777
2778        assert_eq!(
2779            ctx.options.preset,
2780            UiThemePreset::Nord,
2781            "config.base_preset should override options.preset"
2782        );
2783    }
2784
2785    /// Tokens that must always have a foreground color set (used by rendering code).
2786    const CRITICAL_FG_TOKENS: &[&str] = &[
2787        STYLE_TEXT_PRIMARY,
2788        STYLE_TEXT_MUTED,
2789        STYLE_TEXT_SUBTLE,
2790        STYLE_STATUS_SUCCESS,
2791        STYLE_STATUS_WARNING,
2792        STYLE_STATUS_ERROR,
2793        STYLE_STATUS_INFO,
2794        STYLE_ROLE_USER,
2795        STYLE_ROLE_ASSISTANT,
2796        STYLE_ROLE_TOOL,
2797        STYLE_ROLE_SYSTEM,
2798        STYLE_SCORE_HIGH,
2799        STYLE_SCORE_MID,
2800        STYLE_SCORE_LOW,
2801        STYLE_KBD_KEY,
2802        STYLE_KBD_DESC,
2803    ];
2804
2805    /// Tokens that must always have a background color set (pill/tab affordances).
2806    const CRITICAL_BG_TOKENS: &[&str] = &[
2807        STYLE_APP_ROOT,
2808        STYLE_PILL_ACTIVE,
2809        STYLE_PILL_INACTIVE,
2810        STYLE_TAB_ACTIVE,
2811        STYLE_RESULT_ROW_SELECTED,
2812    ];
2813
2814    #[test]
2815    fn critical_fg_tokens_always_have_foreground() {
2816        for preset in UiThemePreset::all() {
2817            for &profile in &[
2818                ColorProfile::TrueColor,
2819                ColorProfile::Ansi256,
2820                ColorProfile::Ansi16,
2821            ] {
2822                let dark_mode = !matches!(
2823                    preset,
2824                    UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2825                );
2826                let ctx = StyleContext::from_options(StyleOptions {
2827                    preset,
2828                    dark_mode,
2829                    color_profile: profile,
2830                    no_color: false,
2831                    no_icons: false,
2832                    no_gradient: false,
2833                    a11y: false,
2834                });
2835
2836                for &token in CRITICAL_FG_TOKENS {
2837                    let style = ctx.style(token);
2838                    assert!(
2839                        style.fg.is_some(),
2840                        "Token {token} must have fg for preset {} profile {:?}",
2841                        preset.name(),
2842                        profile
2843                    );
2844                }
2845            }
2846        }
2847    }
2848
2849    #[test]
2850    fn critical_bg_tokens_always_have_background() {
2851        for preset in UiThemePreset::all() {
2852            for &profile in &[
2853                ColorProfile::TrueColor,
2854                ColorProfile::Ansi256,
2855                ColorProfile::Ansi16,
2856            ] {
2857                let dark_mode = !matches!(
2858                    preset,
2859                    UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2860                );
2861                let ctx = StyleContext::from_options(StyleOptions {
2862                    preset,
2863                    dark_mode,
2864                    color_profile: profile,
2865                    no_color: false,
2866                    no_icons: false,
2867                    no_gradient: false,
2868                    a11y: false,
2869                });
2870
2871                for &token in CRITICAL_BG_TOKENS {
2872                    let style = ctx.style(token);
2873                    assert!(
2874                        style.bg.is_some(),
2875                        "Token {token} must have bg for preset {} profile {:?}",
2876                        preset.name(),
2877                        profile
2878                    );
2879                }
2880            }
2881        }
2882    }
2883
2884    #[test]
2885    fn a11y_mode_adds_emphasis_to_roles() {
2886        // With a11y enabled, role tokens should have bold or underline for emphasis.
2887        for preset in UiThemePreset::all() {
2888            let dark_mode = !matches!(
2889                preset,
2890                UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2891            );
2892            let ctx = StyleContext::from_options(StyleOptions {
2893                preset,
2894                dark_mode,
2895                color_profile: ColorProfile::TrueColor,
2896                no_color: false,
2897                no_icons: false,
2898                no_gradient: true,
2899                a11y: true,
2900            });
2901
2902            let user = ctx.style(STYLE_ROLE_USER);
2903            let assistant = ctx.style(STYLE_ROLE_ASSISTANT);
2904            // At minimum, role tokens should still resolve with fg.
2905            assert!(
2906                user.fg.is_some(),
2907                "ROLE_USER must have fg in a11y mode for {}",
2908                preset.name()
2909            );
2910            assert!(
2911                assistant.fg.is_some(),
2912                "ROLE_ASSISTANT must have fg in a11y mode for {}",
2913                preset.name()
2914            );
2915        }
2916    }
2917
2918    #[test]
2919    fn gutter_tokens_derive_from_role_tokens() {
2920        // Gutter bg should be a blend of the role fg toward background.
2921        // This test verifies they are related (gutter fg == role fg).
2922        for preset in UiThemePreset::all() {
2923            let ctx = context_for_preset(preset);
2924            let role_user = ctx.style(STYLE_ROLE_USER);
2925            let gutter_user = ctx.style(STYLE_ROLE_GUTTER_USER);
2926
2927            // Gutter fg must equal role fg (they share the same foreground).
2928            assert_eq!(
2929                role_user.fg,
2930                gutter_user.fg,
2931                "GUTTER_USER.fg should match ROLE_USER.fg for preset {}",
2932                preset.name()
2933            );
2934            // Gutter must have a bg (the role+bg blend).
2935            assert!(
2936                gutter_user.bg.is_some(),
2937                "GUTTER_USER must have bg for preset {}",
2938                preset.name()
2939            );
2940        }
2941    }
2942
2943    // -- decorative policy tests (2dccg.10.6) ---
2944
2945    #[test]
2946    fn deco_full_wide_fancy_uses_rounded() {
2947        use crate::ui::app::LayoutBreakpoint as LB;
2948        use ftui::render::budget::DegradationLevel as DL;
2949
2950        let policy = DecorativePolicy::resolve(StyleOptions::default(), DL::Full, LB::Wide, true);
2951        assert_eq!(policy.border_tier, BorderTier::Rounded);
2952        assert!(policy.show_icons);
2953        assert!(policy.use_styling);
2954        assert!(policy.render_content);
2955    }
2956
2957    #[test]
2958    fn deco_full_narrow_downgrades_to_square() {
2959        use crate::ui::app::LayoutBreakpoint as LB;
2960        use ftui::render::budget::DegradationLevel as DL;
2961
2962        let policy = DecorativePolicy::resolve(StyleOptions::default(), DL::Full, LB::Narrow, true);
2963        assert_eq!(
2964            policy.border_tier,
2965            BorderTier::Square,
2966            "Narrow breakpoint should force Square even with fancy_borders=true"
2967        );
2968        assert!(policy.show_icons);
2969    }
2970
2971    #[test]
2972    fn deco_fancy_off_uses_square() {
2973        use crate::ui::app::LayoutBreakpoint as LB;
2974        use ftui::render::budget::DegradationLevel as DL;
2975
2976        let policy = DecorativePolicy::resolve(StyleOptions::default(), DL::Full, LB::Wide, false);
2977        assert_eq!(policy.border_tier, BorderTier::Square);
2978    }
2979
2980    #[test]
2981    fn deco_simple_borders_forces_square() {
2982        use crate::ui::app::LayoutBreakpoint as LB;
2983        use ftui::render::budget::DegradationLevel as DL;
2984
2985        let policy =
2986            DecorativePolicy::resolve(StyleOptions::default(), DL::SimpleBorders, LB::Wide, true);
2987        assert_eq!(policy.border_tier, BorderTier::Square);
2988        assert!(
2989            policy.use_styling,
2990            "SimpleBorders should still allow styling"
2991        );
2992    }
2993
2994    #[test]
2995    fn deco_no_styling_drops_color() {
2996        use crate::ui::app::LayoutBreakpoint as LB;
2997        use ftui::render::budget::DegradationLevel as DL;
2998
2999        let policy =
3000            DecorativePolicy::resolve(StyleOptions::default(), DL::NoStyling, LB::Wide, true);
3001        assert_eq!(policy.border_tier, BorderTier::Square);
3002        assert!(!policy.use_styling, "NoStyling should drop color");
3003        assert!(policy.show_icons, "NoStyling should still show icons");
3004    }
3005
3006    #[test]
3007    fn deco_essential_only_strips_everything() {
3008        use crate::ui::app::LayoutBreakpoint as LB;
3009        use ftui::render::budget::DegradationLevel as DL;
3010
3011        let policy =
3012            DecorativePolicy::resolve(StyleOptions::default(), DL::EssentialOnly, LB::Wide, true);
3013        assert_eq!(policy.border_tier, BorderTier::None);
3014        assert!(!policy.show_icons);
3015        assert!(!policy.use_styling);
3016        assert!(
3017            policy.render_content,
3018            "EssentialOnly should still render content"
3019        );
3020    }
3021
3022    #[test]
3023    fn deco_skeleton_drops_content() {
3024        use crate::ui::app::LayoutBreakpoint as LB;
3025        use ftui::render::budget::DegradationLevel as DL;
3026
3027        let policy =
3028            DecorativePolicy::resolve(StyleOptions::default(), DL::Skeleton, LB::Wide, true);
3029        assert!(!policy.render_content, "Skeleton should not render content");
3030    }
3031
3032    #[test]
3033    fn deco_no_color_drops_styling() {
3034        use crate::ui::app::LayoutBreakpoint as LB;
3035        use ftui::render::budget::DegradationLevel as DL;
3036
3037        let opts = StyleOptions {
3038            no_color: true,
3039            color_profile: ColorProfile::Mono,
3040            no_gradient: true,
3041            ..StyleOptions::default()
3042        };
3043        let policy = DecorativePolicy::resolve(opts, DL::Full, LB::Wide, true);
3044        assert!(!policy.use_styling, "NO_COLOR should drop styling");
3045        assert!(!policy.use_gradients, "NO_COLOR should drop gradients");
3046    }
3047
3048    #[test]
3049    fn deco_no_icons_suppresses_icons() {
3050        use crate::ui::app::LayoutBreakpoint as LB;
3051        use ftui::render::budget::DegradationLevel as DL;
3052
3053        let opts = StyleOptions {
3054            no_icons: true,
3055            ..StyleOptions::default()
3056        };
3057        let policy = DecorativePolicy::resolve(opts, DL::Full, LB::Wide, true);
3058        assert!(!policy.show_icons, "CASS_NO_ICONS should suppress icons");
3059    }
3060
3061    #[test]
3062    fn deco_monotonic_degradation() {
3063        use crate::ui::app::LayoutBreakpoint as LB;
3064        use ftui::render::budget::DegradationLevel as DL;
3065
3066        let levels = [
3067            DL::Full,
3068            DL::SimpleBorders,
3069            DL::NoStyling,
3070            DL::EssentialOnly,
3071            DL::Skeleton,
3072            DL::SkipFrame,
3073        ];
3074        let opts = StyleOptions::default();
3075        let mut prev: Option<DecorativePolicy> = None;
3076
3077        for &level in &levels {
3078            let policy = DecorativePolicy::resolve(opts, level, LB::Wide, true);
3079
3080            if let Some(p) = prev {
3081                // Border tier should be >= (weaker or equal).
3082                assert!(
3083                    policy.border_tier >= p.border_tier,
3084                    "Border tier should degrade monotonically: {:?} at {:?}",
3085                    policy.border_tier,
3086                    level
3087                );
3088                // Capabilities should only decrease.
3089                if !p.show_icons {
3090                    assert!(
3091                        !policy.show_icons,
3092                        "show_icons should not re-enable at {:?}",
3093                        level
3094                    );
3095                }
3096                if !p.use_styling {
3097                    assert!(
3098                        !policy.use_styling,
3099                        "use_styling should not re-enable at {:?}",
3100                        level
3101                    );
3102                }
3103                if !p.render_content {
3104                    assert!(
3105                        !policy.render_content,
3106                        "render_content should not re-enable at {:?}",
3107                        level
3108                    );
3109                }
3110            }
3111            prev = Some(policy);
3112        }
3113    }
3114
3115    // -- pane chrome & focus tokens (2dccg.9.1) --------------------------------
3116
3117    #[test]
3118    fn pane_title_focused_has_bold_accent() {
3119        for preset in UiThemePreset::all() {
3120            let ctx = context_for_preset(preset);
3121            let focused = ctx.style(STYLE_PANE_TITLE_FOCUSED);
3122            assert!(
3123                focused.fg.is_some(),
3124                "{preset:?}: focused title should have fg"
3125            );
3126            assert!(
3127                focused
3128                    .attrs
3129                    .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3130                "{preset:?}: focused title should be bold"
3131            );
3132        }
3133    }
3134
3135    #[test]
3136    fn pane_title_unfocused_is_muted_not_bold() {
3137        for preset in UiThemePreset::all() {
3138            let ctx = context_for_preset(preset);
3139            let unfocused = ctx.style(STYLE_PANE_TITLE_UNFOCUSED);
3140            assert!(
3141                unfocused.fg.is_some(),
3142                "{preset:?}: unfocused title should have fg"
3143            );
3144            assert!(
3145                !unfocused
3146                    .attrs
3147                    .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3148                "{preset:?}: unfocused title should NOT be bold"
3149            );
3150        }
3151    }
3152
3153    #[test]
3154    fn pane_title_focused_differs_from_unfocused() {
3155        for preset in UiThemePreset::all() {
3156            let ctx = context_for_preset(preset);
3157            let focused = ctx.style(STYLE_PANE_TITLE_FOCUSED);
3158            let unfocused = ctx.style(STYLE_PANE_TITLE_UNFOCUSED);
3159            assert_ne!(
3160                focused.fg, unfocused.fg,
3161                "{preset:?}: focused and unfocused title fg should differ"
3162            );
3163        }
3164    }
3165
3166    #[test]
3167    fn split_handle_has_fg_and_bg() {
3168        for preset in UiThemePreset::all() {
3169            let ctx = context_for_preset(preset);
3170            let handle = ctx.style(STYLE_SPLIT_HANDLE);
3171            assert!(
3172                handle.fg.is_some(),
3173                "{preset:?}: split handle should have fg"
3174            );
3175            assert!(
3176                handle.bg.is_some(),
3177                "{preset:?}: split handle should have bg"
3178            );
3179        }
3180    }
3181
3182    #[test]
3183    fn split_handle_fg_differs_from_own_bg() {
3184        for preset in UiThemePreset::all() {
3185            let ctx = context_for_preset(preset);
3186            let handle = ctx.style(STYLE_SPLIT_HANDLE);
3187            // The handle character must be visible on its own background.
3188            assert_ne!(
3189                handle.fg, handle.bg,
3190                "{preset:?}: split handle fg should differ from its bg"
3191            );
3192        }
3193    }
3194
3195    // -- score/source/location hierarchy (2dccg.9.3) ---------------------------
3196
3197    #[test]
3198    fn source_local_differs_from_source_remote() {
3199        for preset in UiThemePreset::all() {
3200            let ctx = context_for_preset(preset);
3201            let local = ctx.style(STYLE_SOURCE_LOCAL);
3202            let remote = ctx.style(STYLE_SOURCE_REMOTE);
3203            assert_ne!(
3204                local.fg, remote.fg,
3205                "{preset:?}: local and remote source fg should differ"
3206            );
3207        }
3208    }
3209
3210    #[test]
3211    fn source_remote_is_italic() {
3212        for preset in UiThemePreset::all() {
3213            let ctx = context_for_preset(preset);
3214            let remote = ctx.style(STYLE_SOURCE_REMOTE);
3215            assert!(
3216                remote
3217                    .attrs
3218                    .is_some_and(|a| a.contains(ftui::StyleFlags::ITALIC)),
3219                "{preset:?}: remote source should be italic"
3220            );
3221        }
3222    }
3223
3224    #[test]
3225    fn location_style_has_fg() {
3226        for preset in UiThemePreset::all() {
3227            let ctx = context_for_preset(preset);
3228            let loc = ctx.style(STYLE_LOCATION);
3229            assert!(
3230                loc.fg.is_some(),
3231                "{preset:?}: location style should have fg"
3232            );
3233        }
3234    }
3235
3236    #[test]
3237    fn result_scanning_hierarchy_is_ordered() {
3238        // Verify the visual hierarchy: score colors > source badge > location > snippet
3239        // by checking that higher-priority tokens have bolder emphasis.
3240        for preset in UiThemePreset::all() {
3241            let ctx = context_for_preset(preset);
3242            let score_high = ctx.style(STYLE_SCORE_HIGH);
3243            let source_local = ctx.style(STYLE_SOURCE_LOCAL);
3244            let location = ctx.style(STYLE_LOCATION);
3245
3246            // Score high should be bold (strongest visual signal).
3247            assert!(
3248                score_high
3249                    .attrs
3250                    .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3251                "{preset:?}: score high should be bold"
3252            );
3253            // Source local and location should NOT be bold (they recede).
3254            assert!(
3255                !source_local
3256                    .attrs
3257                    .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3258                "{preset:?}: source local should not be bold"
3259            );
3260            assert!(
3261                !location
3262                    .attrs
3263                    .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3264                "{preset:?}: location should not be bold"
3265            );
3266        }
3267    }
3268
3269    #[test]
3270    fn capability_matrix_profiles_resolve_expected_color_profiles() {
3271        use crate::ui::app::LayoutBreakpoint as LB;
3272        use ftui::core::terminal_capabilities::TerminalProfile;
3273        use ftui::render::budget::DegradationLevel as DL;
3274
3275        let fixtures = [
3276            (TerminalProfile::Xterm256Color, "xterm-256color"),
3277            (TerminalProfile::Screen, "screen"),
3278            (TerminalProfile::Dumb, "dumb"),
3279            (TerminalProfile::WindowsConsole, "windows-console"),
3280            (TerminalProfile::Kitty, "kitty"),
3281        ];
3282
3283        for (profile, term) in fixtures {
3284            let caps = TerminalCapabilities::from_profile(profile);
3285            let diag = style_policy_diagnostic(
3286                caps,
3287                CapabilityMatrixInputs {
3288                    term: Some(term),
3289                    ..CapabilityMatrixInputs::default()
3290                },
3291                DL::Full,
3292                LB::Wide,
3293                true,
3294            );
3295
3296            let expected_profile = if caps.true_color {
3297                "truecolor"
3298            } else if caps.colors_256 {
3299                "ansi256"
3300            } else {
3301                "ansi16"
3302            };
3303
3304            assert_eq!(
3305                diag.terminal_profile,
3306                profile.as_str(),
3307                "terminal profile id should be preserved in diagnostics"
3308            );
3309            assert_eq!(
3310                diag.resolved_color_profile, expected_profile,
3311                "profile {profile} should map to expected color profile"
3312            );
3313            assert_eq!(diag.term.as_deref(), Some(term));
3314            assert_eq!(
3315                diag.capability_unicode_box_drawing, caps.unicode_box_drawing,
3316                "unicode capability should be reported verbatim for {profile}"
3317            );
3318        }
3319    }
3320
3321    #[test]
3322    fn capability_matrix_no_color_precedence_matches_policy_contract() {
3323        use crate::ui::app::LayoutBreakpoint as LB;
3324        use ftui::core::terminal_capabilities::TerminalProfile;
3325        use ftui::render::budget::DegradationLevel as DL;
3326
3327        let caps = TerminalCapabilities::from_profile(TerminalProfile::Kitty);
3328
3329        let no_color_only = style_policy_diagnostic(
3330            caps,
3331            CapabilityMatrixInputs {
3332                term: Some("xterm-kitty"),
3333                no_color: true,
3334                cass_respect_no_color: false,
3335                ..CapabilityMatrixInputs::default()
3336            },
3337            DL::Full,
3338            LB::Wide,
3339            true,
3340        );
3341        assert!(
3342            !no_color_only.resolved_no_color,
3343            "NO_COLOR alone must not force monochrome"
3344        );
3345        assert_ne!(
3346            no_color_only.resolved_color_profile, "mono",
3347            "NO_COLOR alone should keep color enabled"
3348        );
3349
3350        let respect_no_color = style_policy_diagnostic(
3351            caps,
3352            CapabilityMatrixInputs {
3353                term: Some("xterm-kitty"),
3354                no_color: true,
3355                cass_respect_no_color: true,
3356                ..CapabilityMatrixInputs::default()
3357            },
3358            DL::Full,
3359            LB::Wide,
3360            true,
3361        );
3362        assert!(respect_no_color.resolved_no_color);
3363        assert_eq!(respect_no_color.resolved_color_profile, "mono");
3364        assert!(
3365            !respect_no_color.policy_use_styling,
3366            "monochrome mode should disable styling"
3367        );
3368        assert!(
3369            !respect_no_color.policy_use_gradients,
3370            "monochrome mode should disable gradients"
3371        );
3372
3373        let cass_no_color = style_policy_diagnostic(
3374            caps,
3375            CapabilityMatrixInputs {
3376                term: Some("xterm-kitty"),
3377                cass_no_color: true,
3378                cass_color_profile: Some("truecolor"),
3379                ..CapabilityMatrixInputs::default()
3380            },
3381            DL::Full,
3382            LB::Wide,
3383            true,
3384        );
3385        assert!(cass_no_color.resolved_no_color);
3386        assert_eq!(
3387            cass_no_color.resolved_color_profile, "mono",
3388            "CASS_NO_COLOR must override explicit profile requests"
3389        );
3390    }
3391
3392    #[test]
3393    fn capability_matrix_diagnostic_payload_is_machine_readable_json() {
3394        use crate::ui::app::LayoutBreakpoint as LB;
3395        use ftui::core::terminal_capabilities::TerminalProfile;
3396        use ftui::render::budget::DegradationLevel as DL;
3397
3398        let caps = TerminalCapabilities::from_profile(TerminalProfile::Xterm256Color);
3399        let diag = style_policy_diagnostic(
3400            caps,
3401            CapabilityMatrixInputs {
3402                term: Some("xterm-256color"),
3403                colorterm: Some("truecolor"),
3404                ..CapabilityMatrixInputs::default()
3405            },
3406            DL::SimpleBorders,
3407            LB::Medium,
3408            true,
3409        );
3410
3411        let json = match serde_json::to_value(&diag) {
3412            Ok(value) => value,
3413            Err(error) => panic!("diagnostic payload should serialize: {error}"),
3414        };
3415        let object = match json.as_object() {
3416            Some(map) => map,
3417            None => panic!("diagnostic payload must serialize to a JSON object"),
3418        };
3419
3420        for required in [
3421            "terminal_profile",
3422            "degradation",
3423            "breakpoint",
3424            "resolved_color_profile",
3425            "policy_border_tier",
3426            "policy_use_styling",
3427            "policy_use_gradients",
3428            "policy_render_content",
3429            "capability_unicode_box_drawing",
3430            "env_no_color",
3431            "env_cass_respect_no_color",
3432            "env_cass_no_color",
3433        ] {
3434            assert!(
3435                object.contains_key(required),
3436                "diagnostic payload missing required key: {required}"
3437            );
3438        }
3439    }
3440
3441    #[test]
3442    fn capability_matrix_degradation_transitions_are_monotonic() {
3443        use crate::ui::app::LayoutBreakpoint as LB;
3444        use ftui::core::terminal_capabilities::TerminalProfile;
3445        use ftui::render::budget::DegradationLevel as DL;
3446
3447        fn border_rank(tier: &str) -> u8 {
3448            match tier {
3449                "rounded" => 0,
3450                "square" => 1,
3451                "none" => 2,
3452                other => panic!("unexpected border tier: {other}"),
3453            }
3454        }
3455
3456        let caps = TerminalCapabilities::from_profile(TerminalProfile::Kitty);
3457        let levels = [
3458            DL::Full,
3459            DL::SimpleBorders,
3460            DL::NoStyling,
3461            DL::EssentialOnly,
3462        ];
3463        let mut prev: Option<StylePolicyDiagnostic> = None;
3464
3465        for level in levels {
3466            let diag = style_policy_diagnostic(
3467                caps,
3468                CapabilityMatrixInputs {
3469                    term: Some("xterm-kitty"),
3470                    ..CapabilityMatrixInputs::default()
3471                },
3472                level,
3473                LB::Wide,
3474                true,
3475            );
3476
3477            if let Some(last) = &prev {
3478                assert!(
3479                    border_rank(diag.policy_border_tier) >= border_rank(last.policy_border_tier),
3480                    "border tier should only weaken across degradation levels"
3481                );
3482                if !last.policy_show_icons {
3483                    assert!(!diag.policy_show_icons, "icons must not re-enable");
3484                }
3485                if !last.policy_use_styling {
3486                    assert!(
3487                        !diag.policy_use_styling,
3488                        "styling must not re-enable after being stripped"
3489                    );
3490                }
3491                if !last.policy_use_gradients {
3492                    assert!(
3493                        !diag.policy_use_gradients,
3494                        "gradients must not re-enable after being stripped"
3495                    );
3496                }
3497                if !last.policy_render_content {
3498                    assert!(
3499                        !diag.policy_render_content,
3500                        "content rendering must not re-enable after being stripped"
3501                    );
3502                }
3503            }
3504            prev = Some(diag);
3505        }
3506    }
3507
3508    // -- agent/role coherence tests (2dccg.10.2) ---
3509
3510    #[test]
3511    fn agent_accent_style_is_bold_for_all_agents() {
3512        let ctx = context_for_preset(UiThemePreset::TokyoNight);
3513        let agents = [
3514            "claude_code",
3515            "codex",
3516            "cline",
3517            "gemini",
3518            "amp",
3519            "aider",
3520            "cursor",
3521            "chatgpt",
3522            "opencode",
3523            "pi_agent",
3524            "unknown_agent",
3525        ];
3526        for agent in agents {
3527            let style = ctx.agent_accent_style(agent);
3528            assert!(
3529                style.fg.is_some(),
3530                "agent_accent_style({agent}) must have fg"
3531            );
3532            assert!(
3533                style.has_attr(ftui::StyleFlags::BOLD),
3534                "agent_accent_style({agent}) must be bold"
3535            );
3536        }
3537    }
3538
3539    #[test]
3540    fn agent_accent_style_adds_badge_bg_in_color_modes() {
3541        for preset in UiThemePreset::all() {
3542            let ctx = context_for_preset(preset);
3543            let style = ctx.agent_accent_style("codex");
3544            let fg = style.fg.expect("agent accent style should define fg");
3545            let bg = style.bg.expect("agent accent style should define bg tint");
3546            assert!(
3547                style.has_attr(ftui::StyleFlags::BOLD),
3548                "agent accent style should remain bold for preset {}",
3549                preset.name()
3550            );
3551            assert_ne!(
3552                Some(bg),
3553                Some(to_packed(ctx.resolved.surface)),
3554                "badge bg should differ from plain surface background for preset {}",
3555                preset.name()
3556            );
3557            let ratio = ftui::style::contrast_ratio_packed(fg, bg);
3558            assert!(
3559                ratio >= 3.0,
3560                "agent accent badge contrast too low ({ratio:.2}) for preset {}",
3561                preset.name()
3562            );
3563        }
3564    }
3565
3566    #[test]
3567    fn agent_accent_style_uses_fg_only_in_no_color_and_a11y_modes() {
3568        let no_color_ctx = StyleContext::from_options(StyleOptions {
3569            preset: UiThemePreset::TokyoNight,
3570            dark_mode: true,
3571            color_profile: ColorProfile::Mono,
3572            no_color: true,
3573            no_icons: false,
3574            no_gradient: true,
3575            a11y: false,
3576        });
3577        let no_color_style = no_color_ctx.agent_accent_style("codex");
3578        assert!(
3579            no_color_style.bg.is_none(),
3580            "no-color mode should avoid accent background tint"
3581        );
3582
3583        let a11y_ctx = StyleContext::from_options(StyleOptions {
3584            preset: UiThemePreset::TokyoNight,
3585            dark_mode: true,
3586            color_profile: ColorProfile::TrueColor,
3587            no_color: false,
3588            no_icons: false,
3589            no_gradient: true,
3590            a11y: true,
3591        });
3592        let a11y_style = a11y_ctx.agent_accent_style("codex");
3593        assert!(
3594            a11y_style.bg.is_none(),
3595            "a11y mode should avoid accent background tint"
3596        );
3597    }
3598
3599    #[test]
3600    fn result_row_style_for_agent_tints_background_when_color_enabled() {
3601        let ctx = context_for_preset(UiThemePreset::TokyoNight);
3602        let base = ctx.style(STYLE_RESULT_ROW);
3603        let tinted = ctx.result_row_style_for_agent(base, "codex");
3604        assert!(base.bg.is_some(), "base row style should have a background");
3605        assert!(
3606            tinted.bg.is_some(),
3607            "tinted row style should retain a background"
3608        );
3609        assert_eq!(
3610            tinted.fg, base.fg,
3611            "row tinting should preserve existing foreground color"
3612        );
3613        assert_ne!(
3614            tinted.bg, base.bg,
3615            "row tinting should shift background toward agent accent"
3616        );
3617    }
3618
3619    #[test]
3620    fn result_row_style_for_agent_preserves_base_style_in_no_color_or_a11y() {
3621        let base = Style::new()
3622            .fg(to_packed(Color::rgb(230, 230, 230)))
3623            .bg(to_packed(Color::rgb(32, 36, 48)));
3624
3625        let no_color_ctx = StyleContext::from_options(StyleOptions {
3626            preset: UiThemePreset::TokyoNight,
3627            dark_mode: true,
3628            color_profile: ColorProfile::Mono,
3629            no_color: true,
3630            no_icons: false,
3631            no_gradient: true,
3632            a11y: false,
3633        });
3634        assert_eq!(
3635            no_color_ctx.result_row_style_for_agent(base, "codex"),
3636            base,
3637            "no-color mode should not tint row backgrounds"
3638        );
3639
3640        let a11y_ctx = StyleContext::from_options(StyleOptions {
3641            preset: UiThemePreset::TokyoNight,
3642            dark_mode: true,
3643            color_profile: ColorProfile::TrueColor,
3644            no_color: false,
3645            no_icons: false,
3646            no_gradient: true,
3647            a11y: true,
3648        });
3649        assert_eq!(
3650            a11y_ctx.result_row_style_for_agent(base, "codex"),
3651            base,
3652            "a11y mode should not tint row backgrounds"
3653        );
3654    }
3655
3656    #[test]
3657    fn result_row_tints_are_pairwise_distinct_for_representative_agents() {
3658        let agents = ["claude_code", "codex", "cursor", "gemini", "aider"];
3659        for preset in UiThemePreset::all() {
3660            let ctx = context_for_preset(preset);
3661            let base = ctx.style(STYLE_RESULT_ROW);
3662            let mut tinted_bgs: Vec<(&str, ftui::PackedRgba)> = Vec::new();
3663
3664            for agent in agents {
3665                let tinted = ctx.result_row_style_for_agent(base, agent);
3666                let tinted_bg = tinted
3667                    .bg
3668                    .expect("color mode result-row tint should define background");
3669                tinted_bgs.push((agent, tinted_bg));
3670            }
3671            let unique_count = tinted_bgs
3672                .iter()
3673                .map(|(_, bg)| *bg)
3674                .collect::<std::collections::HashSet<_>>()
3675                .len();
3676            let min_buckets = if matches!(preset, UiThemePreset::HighContrast) {
3677                2
3678            } else {
3679                4
3680            };
3681            assert!(
3682                unique_count >= min_buckets,
3683                "expected at least {min_buckets} distinct tint buckets (got {unique_count}) for preset {}",
3684                preset.name()
3685            );
3686        }
3687    }
3688
3689    #[test]
3690    fn result_row_tints_preserve_text_legibility_threshold() {
3691        let agents = ["claude_code", "codex", "cursor", "gemini", "aider"];
3692        for preset in UiThemePreset::all() {
3693            let ctx = context_for_preset(preset);
3694            for base_token in [STYLE_RESULT_ROW, STYLE_RESULT_ROW_ALT] {
3695                let base = ctx.style(base_token);
3696                for agent in agents {
3697                    let tinted = ctx.result_row_style_for_agent(base, agent);
3698                    let fg = tinted
3699                        .fg
3700                        .expect("result-row style should always define foreground");
3701                    let bg = tinted
3702                        .bg
3703                        .expect("result-row style should always define background");
3704                    let ratio = ftui::style::contrast_ratio_packed(fg, bg);
3705                    assert!(
3706                        ratio >= 2.5,
3707                        "text contrast {:.2} below threshold for preset {} token {} agent {}",
3708                        ratio,
3709                        preset.name(),
3710                        base_token,
3711                        agent
3712                    );
3713                }
3714            }
3715        }
3716    }
3717
3718    #[test]
3719    fn selected_row_affordance_remains_distinct_from_agent_tints() {
3720        let agents = ["claude_code", "codex", "cursor", "gemini", "aider"];
3721        for preset in UiThemePreset::all() {
3722            let ctx = context_for_preset(preset);
3723            let selected = ctx.style(STYLE_RESULT_ROW_SELECTED);
3724            let selected_bg = selected
3725                .bg
3726                .expect("selected-row style should define background");
3727
3728            for base_token in [STYLE_RESULT_ROW, STYLE_RESULT_ROW_ALT] {
3729                let base = ctx.style(base_token);
3730                let base_bg = base.bg.expect("base row style should define background");
3731                let base_separation = ftui::style::contrast_ratio_packed(selected_bg, base_bg);
3732                for agent in agents {
3733                    let tinted = ctx.result_row_style_for_agent(base, agent);
3734                    let tinted_bg = tinted.bg.expect("result-row tint should define background");
3735                    assert_ne!(
3736                        selected_bg,
3737                        tinted_bg,
3738                        "selected background should differ from tinted row background for preset {} token {} agent {}",
3739                        preset.name(),
3740                        base_token,
3741                        agent
3742                    );
3743
3744                    let separation = ftui::style::contrast_ratio_packed(selected_bg, tinted_bg);
3745                    assert!(
3746                        separation + 0.03 >= base_separation,
3747                        "selected/tinted separation {:.3} regressed too far below base {:.3} for preset {} token {} agent {}",
3748                        separation,
3749                        base_separation,
3750                        preset.name(),
3751                        base_token,
3752                        agent
3753                    );
3754                }
3755            }
3756        }
3757    }
3758
3759    #[test]
3760    fn role_markers_provide_text_disambiguation_in_a11y() {
3761        let markers = RoleMarkers::from_options(StyleOptions {
3762            a11y: true,
3763            ..StyleOptions::default()
3764        });
3765        // In a11y mode, markers provide text-based role disambiguation.
3766        assert!(
3767            !markers.user.is_empty(),
3768            "a11y user marker must be non-empty"
3769        );
3770        assert!(
3771            !markers.assistant.is_empty(),
3772            "a11y assistant marker must be non-empty"
3773        );
3774        assert_ne!(markers.user, markers.assistant, "user != assistant markers");
3775        assert_ne!(markers.user, markers.tool, "user != tool markers");
3776        assert_ne!(markers.assistant, markers.tool, "assistant != tool markers");
3777    }
3778
3779    #[test]
3780    fn role_markers_empty_when_no_icons() {
3781        let markers = RoleMarkers::from_options(StyleOptions {
3782            no_icons: true,
3783            a11y: false,
3784            ..StyleOptions::default()
3785        });
3786        assert!(
3787            markers.user.is_empty(),
3788            "no_icons should suppress role markers"
3789        );
3790    }
3791
3792    // -- pill & tab style token tests (k25j6, 2kz6t) -------------------------
3793
3794    fn context_for_preset(preset: UiThemePreset) -> StyleContext {
3795        let dark_mode = !matches!(
3796            preset,
3797            UiThemePreset::Daylight | UiThemePreset::SolarizedLight
3798        );
3799        StyleContext::from_options(StyleOptions {
3800            preset,
3801            dark_mode,
3802            color_profile: ColorProfile::TrueColor,
3803            no_color: false,
3804            no_icons: false,
3805            no_gradient: false,
3806            a11y: false,
3807        })
3808    }
3809
3810    #[test]
3811    fn pill_active_has_background_for_all_presets() {
3812        for preset in UiThemePreset::all() {
3813            let ctx = context_for_preset(preset);
3814            let style = ctx.style(STYLE_PILL_ACTIVE);
3815            assert!(
3816                style.bg.is_some(),
3817                "STYLE_PILL_ACTIVE must have bg for preset {}",
3818                preset.name()
3819            );
3820        }
3821    }
3822
3823    #[test]
3824    fn tab_active_has_background_for_all_presets() {
3825        for preset in UiThemePreset::all() {
3826            let ctx = context_for_preset(preset);
3827            let style = ctx.style(STYLE_TAB_ACTIVE);
3828            assert!(
3829                style.bg.is_some(),
3830                "STYLE_TAB_ACTIVE must have bg for preset {}",
3831                preset.name()
3832            );
3833        }
3834    }
3835
3836    #[test]
3837    fn tab_inactive_has_no_background() {
3838        for preset in UiThemePreset::all() {
3839            let ctx = context_for_preset(preset);
3840            let style = ctx.style(STYLE_TAB_INACTIVE);
3841            assert!(
3842                style.bg.is_none(),
3843                "STYLE_TAB_INACTIVE should have no bg for preset {}",
3844                preset.name()
3845            );
3846        }
3847    }
3848
3849    #[test]
3850    fn tab_active_differs_from_status_info() {
3851        let ctx = context_for_preset(UiThemePreset::TokyoNight);
3852        let tab = ctx.style(STYLE_TAB_ACTIVE);
3853        let info = ctx.style(STYLE_STATUS_INFO);
3854        assert_ne!(
3855            tab, info,
3856            "STYLE_TAB_ACTIVE must differ from STYLE_STATUS_INFO"
3857        );
3858    }
3859
3860    #[test]
3861    fn pill_active_differs_from_text_primary() {
3862        let ctx = context_for_preset(UiThemePreset::TokyoNight);
3863        let pill = ctx.style(STYLE_PILL_ACTIVE);
3864        let text = ctx.style(STYLE_TEXT_PRIMARY);
3865        assert_ne!(
3866            pill, text,
3867            "STYLE_PILL_ACTIVE must differ from STYLE_TEXT_PRIMARY"
3868        );
3869    }
3870
3871    #[test]
3872    fn tab_and_pill_styles_unique_across_presets() {
3873        let mut tab_styles = std::collections::HashSet::new();
3874        let mut pill_styles = std::collections::HashSet::new();
3875        for preset in UiThemePreset::all() {
3876            let ctx = context_for_preset(preset);
3877            let tab = ctx.style(STYLE_TAB_ACTIVE);
3878            let pill = ctx.style(STYLE_PILL_ACTIVE);
3879            tab_styles.insert(format!("{:?}", tab));
3880            pill_styles.insert(format!("{:?}", pill));
3881        }
3882        assert!(
3883            tab_styles.len() >= 3,
3884            "STYLE_TAB_ACTIVE should produce at least 3 distinct styles across presets, got {}",
3885            tab_styles.len()
3886        );
3887        assert!(
3888            pill_styles.len() >= 3,
3889            "STYLE_PILL_ACTIVE should produce at least 3 distinct styles across presets, got {}",
3890            pill_styles.len()
3891        );
3892    }
3893
3894    // -- Pill hierarchy tests (2dccg.8.3) ----------------------------------------
3895
3896    #[test]
3897    fn pill_inactive_differs_from_pill_active() {
3898        for preset in UiThemePreset::all() {
3899            let ctx = context_for_preset(preset);
3900            let active = ctx.style(STYLE_PILL_ACTIVE);
3901            let inactive = ctx.style(STYLE_PILL_INACTIVE);
3902            assert_ne!(
3903                active,
3904                inactive,
3905                "STYLE_PILL_INACTIVE must differ from STYLE_PILL_ACTIVE for preset {}",
3906                preset.name()
3907            );
3908        }
3909    }
3910
3911    #[test]
3912    fn pill_inactive_is_not_bold() {
3913        for preset in UiThemePreset::all() {
3914            let ctx = context_for_preset(preset);
3915            let inactive = ctx.style(STYLE_PILL_INACTIVE);
3916            let is_bold = inactive
3917                .attrs
3918                .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD));
3919            assert!(
3920                !is_bold,
3921                "STYLE_PILL_INACTIVE should not be bold for preset {}",
3922                preset.name()
3923            );
3924        }
3925    }
3926
3927    #[test]
3928    fn pill_active_is_bold() {
3929        for preset in UiThemePreset::all() {
3930            let ctx = context_for_preset(preset);
3931            let active = ctx.style(STYLE_PILL_ACTIVE);
3932            let is_bold = active
3933                .attrs
3934                .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD));
3935            assert!(
3936                is_bold,
3937                "STYLE_PILL_ACTIVE should be bold for preset {}",
3938                preset.name()
3939            );
3940        }
3941    }
3942
3943    #[test]
3944    fn pill_inactive_has_background_for_all_presets() {
3945        for preset in UiThemePreset::all() {
3946            let ctx = context_for_preset(preset);
3947            let inactive = ctx.style(STYLE_PILL_INACTIVE);
3948            assert!(
3949                inactive.bg.is_some(),
3950                "STYLE_PILL_INACTIVE must have bg for preset {}",
3951                preset.name()
3952            );
3953        }
3954    }
3955
3956    #[test]
3957    fn pill_inactive_is_dim() {
3958        for preset in UiThemePreset::all() {
3959            let ctx = context_for_preset(preset);
3960            let inactive = ctx.style(STYLE_PILL_INACTIVE);
3961            let is_dim = inactive
3962                .attrs
3963                .is_some_and(|a| a.contains(ftui::StyleFlags::DIM));
3964            assert!(
3965                is_dim,
3966                "STYLE_PILL_INACTIVE should be dim for preset {}",
3967                preset.name()
3968            );
3969        }
3970    }
3971
3972    #[test]
3973    fn pill_label_has_foreground_and_bold() {
3974        for preset in UiThemePreset::all() {
3975            let ctx = context_for_preset(preset);
3976            let label = ctx.style(STYLE_PILL_LABEL);
3977            assert!(
3978                label.fg.is_some(),
3979                "STYLE_PILL_LABEL must have fg for preset {}",
3980                preset.name()
3981            );
3982            let is_bold = label
3983                .attrs
3984                .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD));
3985            assert!(
3986                is_bold,
3987                "STYLE_PILL_LABEL should be bold for preset {}",
3988                preset.name()
3989            );
3990        }
3991    }
3992
3993    #[test]
3994    fn pill_hierarchy_is_visually_ordered() {
3995        // Active pills should be the most prominent (fg differs from inactive/label)
3996        for preset in UiThemePreset::all() {
3997            let ctx = context_for_preset(preset);
3998            let active = ctx.style(STYLE_PILL_ACTIVE);
3999            let inactive = ctx.style(STYLE_PILL_INACTIVE);
4000            let label = ctx.style(STYLE_PILL_LABEL);
4001            // All three should be distinct
4002            assert_ne!(
4003                active.fg,
4004                inactive.fg,
4005                "pill active fg must differ from inactive fg for preset {}",
4006                preset.name()
4007            );
4008            assert_ne!(
4009                active.fg,
4010                label.fg,
4011                "pill active fg must differ from label fg for preset {}",
4012                preset.name()
4013            );
4014        }
4015    }
4016
4017    // -- Breadcrumb hierarchy tests (2dccg.8.2) ---------------------------------
4018
4019    #[test]
4020    fn crumb_active_differs_from_inactive() {
4021        for preset in UiThemePreset::all() {
4022            let ctx = context_for_preset(preset);
4023            let active = ctx.style(STYLE_CRUMB_ACTIVE);
4024            let inactive = ctx.style(STYLE_CRUMB_INACTIVE);
4025            assert_ne!(
4026                active,
4027                inactive,
4028                "CRUMB_ACTIVE must differ from CRUMB_INACTIVE for preset {}",
4029                preset.name()
4030            );
4031        }
4032    }
4033
4034    #[test]
4035    fn crumb_active_is_bold() {
4036        for preset in UiThemePreset::all() {
4037            let ctx = context_for_preset(preset);
4038            let active = ctx.style(STYLE_CRUMB_ACTIVE);
4039            assert!(
4040                active.has_attr(ftui::StyleFlags::BOLD),
4041                "CRUMB_ACTIVE should be bold for preset {}",
4042                preset.name()
4043            );
4044        }
4045    }
4046
4047    #[test]
4048    fn crumb_separator_has_fg() {
4049        for preset in UiThemePreset::all() {
4050            let ctx = context_for_preset(preset);
4051            let sep = ctx.style(STYLE_CRUMB_SEPARATOR);
4052            assert!(
4053                sep.fg.is_some(),
4054                "CRUMB_SEPARATOR must have fg for preset {}",
4055                preset.name()
4056            );
4057        }
4058    }
4059
4060    #[test]
4061    fn crumb_separator_differs_from_active() {
4062        for preset in UiThemePreset::all() {
4063            let ctx = context_for_preset(preset);
4064            let active = ctx.style(STYLE_CRUMB_ACTIVE);
4065            let sep = ctx.style(STYLE_CRUMB_SEPARATOR);
4066            assert_ne!(
4067                active.fg,
4068                sep.fg,
4069                "CRUMB_SEPARATOR fg must differ from CRUMB_ACTIVE fg for preset {}",
4070                preset.name()
4071            );
4072        }
4073    }
4074
4075    // -- MarkdownTheme integration tests (kr88h) --------------------------------
4076
4077    #[test]
4078    fn markdown_theme_h1_uses_primary_color() {
4079        let ctx = context_for_preset(UiThemePreset::TokyoNight);
4080        let md = ctx.markdown_theme();
4081        let expected_fg = to_packed(ctx.resolved.primary);
4082        assert_eq!(
4083            md.h1.fg,
4084            Some(expected_fg),
4085            "h1 fg should match resolved.primary"
4086        );
4087    }
4088
4089    #[test]
4090    fn markdown_theme_code_inline_has_background() {
4091        for preset in UiThemePreset::all() {
4092            let ctx = context_for_preset(preset);
4093            let md = ctx.markdown_theme();
4094            assert!(
4095                md.code_inline.bg.is_some(),
4096                "code_inline must have bg for preset {}",
4097                preset.name()
4098            );
4099        }
4100    }
4101
4102    #[test]
4103    fn markdown_theme_code_block_has_background() {
4104        for preset in UiThemePreset::all() {
4105            let ctx = context_for_preset(preset);
4106            let md = ctx.markdown_theme();
4107            assert!(
4108                md.code_block.bg.is_some(),
4109                "code_block must have bg for preset {}",
4110                preset.name()
4111            );
4112        }
4113    }
4114
4115    #[test]
4116    fn markdown_theme_link_is_underlined() {
4117        let ctx = context_for_preset(UiThemePreset::TokyoNight);
4118        let md = ctx.markdown_theme();
4119        assert!(
4120            md.link.has_attr(ftui::StyleFlags::UNDERLINE),
4121            "link style should include underline"
4122        );
4123    }
4124
4125    #[test]
4126    fn markdown_theme_table_has_themed_border() {
4127        for preset in UiThemePreset::all() {
4128            let ctx = context_for_preset(preset);
4129            let md = ctx.markdown_theme();
4130            assert!(
4131                md.table_theme.border.fg.is_some(),
4132                "table border must have fg for preset {}",
4133                preset.name()
4134            );
4135            assert!(
4136                md.table_theme.header.fg.is_some(),
4137                "table header must have fg for preset {}",
4138                preset.name()
4139            );
4140        }
4141    }
4142
4143    #[test]
4144    fn syntax_highlight_theme_matches_dark_mode() {
4145        let dark_ctx = context_for_preset(UiThemePreset::TokyoNight);
4146        let light_ctx = context_for_preset(UiThemePreset::Daylight);
4147        let dark_hl = dark_ctx.syntax_highlight_theme();
4148        let light_hl = light_ctx.syntax_highlight_theme();
4149        // Dark and light highlight themes should differ (keyword color at minimum).
4150        assert_ne!(
4151            format!("{:?}", dark_hl.keyword.fg),
4152            format!("{:?}", light_hl.keyword.fg),
4153            "dark and light syntax themes should differ"
4154        );
4155    }
4156
4157    #[test]
4158    fn markdown_theme_differs_across_presets() {
4159        let mut themes = std::collections::HashSet::new();
4160        for preset in UiThemePreset::all() {
4161            let ctx = context_for_preset(preset);
4162            let md = ctx.markdown_theme();
4163            themes.insert(format!("{:?}", md.h1.fg));
4164        }
4165        assert!(
4166            themes.len() >= 3,
4167            "markdown h1 should differ across presets, got {} distinct",
4168            themes.len()
4169        );
4170    }
4171
4172    #[test]
4173    fn markdown_theme_not_default() {
4174        let ctx = context_for_preset(UiThemePreset::TokyoNight);
4175        let themed = ctx.markdown_theme();
4176        let default = MarkdownTheme::default();
4177        assert_ne!(
4178            format!("{:?}", themed.h1),
4179            format!("{:?}", default.h1),
4180            "themed markdown h1 should differ from default"
4181        );
4182    }
4183
4184    // -- dead-style-token audit (2dccg.1.3) -----------------------------------
4185
4186    /// All semantic style token constant names defined in this module.
4187    /// This list MUST be kept in sync with the `pub const STYLE_*` declarations
4188    /// at the top of the file. Adding a new constant without adding it here will
4189    /// cause `style_token_registry_is_complete` to fail; adding it here without
4190    /// using it in rendering code will cause `no_dead_style_tokens` to fail.
4191    const ALL_STYLE_TOKENS: &[(&str, &str)] = &[
4192        ("STYLE_APP_ROOT", STYLE_APP_ROOT),
4193        ("STYLE_PANE_BASE", STYLE_PANE_BASE),
4194        ("STYLE_PANE_FOCUSED", STYLE_PANE_FOCUSED),
4195        ("STYLE_PANE_TITLE_FOCUSED", STYLE_PANE_TITLE_FOCUSED),
4196        ("STYLE_PANE_TITLE_UNFOCUSED", STYLE_PANE_TITLE_UNFOCUSED),
4197        ("STYLE_SPLIT_HANDLE", STYLE_SPLIT_HANDLE),
4198        ("STYLE_TEXT_PRIMARY", STYLE_TEXT_PRIMARY),
4199        ("STYLE_TEXT_MUTED", STYLE_TEXT_MUTED),
4200        ("STYLE_TEXT_SUBTLE", STYLE_TEXT_SUBTLE),
4201        ("STYLE_STATUS_SUCCESS", STYLE_STATUS_SUCCESS),
4202        ("STYLE_STATUS_WARNING", STYLE_STATUS_WARNING),
4203        ("STYLE_STATUS_ERROR", STYLE_STATUS_ERROR),
4204        ("STYLE_STATUS_INFO", STYLE_STATUS_INFO),
4205        ("STYLE_RESULT_ROW", STYLE_RESULT_ROW),
4206        ("STYLE_RESULT_ROW_ALT", STYLE_RESULT_ROW_ALT),
4207        ("STYLE_RESULT_ROW_SELECTED", STYLE_RESULT_ROW_SELECTED),
4208        ("STYLE_ROLE_USER", STYLE_ROLE_USER),
4209        ("STYLE_ROLE_ASSISTANT", STYLE_ROLE_ASSISTANT),
4210        ("STYLE_ROLE_TOOL", STYLE_ROLE_TOOL),
4211        ("STYLE_ROLE_SYSTEM", STYLE_ROLE_SYSTEM),
4212        ("STYLE_ROLE_GUTTER_USER", STYLE_ROLE_GUTTER_USER),
4213        ("STYLE_ROLE_GUTTER_ASSISTANT", STYLE_ROLE_GUTTER_ASSISTANT),
4214        ("STYLE_ROLE_GUTTER_TOOL", STYLE_ROLE_GUTTER_TOOL),
4215        ("STYLE_ROLE_GUTTER_SYSTEM", STYLE_ROLE_GUTTER_SYSTEM),
4216        ("STYLE_SCORE_HIGH", STYLE_SCORE_HIGH),
4217        ("STYLE_SCORE_MID", STYLE_SCORE_MID),
4218        ("STYLE_SCORE_LOW", STYLE_SCORE_LOW),
4219        ("STYLE_SOURCE_LOCAL", STYLE_SOURCE_LOCAL),
4220        ("STYLE_SOURCE_REMOTE", STYLE_SOURCE_REMOTE),
4221        ("STYLE_LOCATION", STYLE_LOCATION),
4222        ("STYLE_PILL_ACTIVE", STYLE_PILL_ACTIVE),
4223        ("STYLE_PILL_INACTIVE", STYLE_PILL_INACTIVE),
4224        ("STYLE_PILL_LABEL", STYLE_PILL_LABEL),
4225        ("STYLE_CRUMB_ACTIVE", STYLE_CRUMB_ACTIVE),
4226        ("STYLE_CRUMB_INACTIVE", STYLE_CRUMB_INACTIVE),
4227        ("STYLE_CRUMB_SEPARATOR", STYLE_CRUMB_SEPARATOR),
4228        ("STYLE_TAB_ACTIVE", STYLE_TAB_ACTIVE),
4229        ("STYLE_TAB_INACTIVE", STYLE_TAB_INACTIVE),
4230        ("STYLE_DETAIL_FIND_CONTAINER", STYLE_DETAIL_FIND_CONTAINER),
4231        ("STYLE_DETAIL_FIND_QUERY", STYLE_DETAIL_FIND_QUERY),
4232        (
4233            "STYLE_DETAIL_FIND_MATCH_ACTIVE",
4234            STYLE_DETAIL_FIND_MATCH_ACTIVE,
4235        ),
4236        (
4237            "STYLE_DETAIL_FIND_MATCH_INACTIVE",
4238            STYLE_DETAIL_FIND_MATCH_INACTIVE,
4239        ),
4240        ("STYLE_QUERY_HIGHLIGHT", STYLE_QUERY_HIGHLIGHT),
4241        ("STYLE_KBD_KEY", STYLE_KBD_KEY),
4242        ("STYLE_KBD_DESC", STYLE_KBD_DESC),
4243        ("STYLE_SEARCH_FOCUS", STYLE_SEARCH_FOCUS),
4244        ("STYLE_MODAL_BACKDROP", STYLE_MODAL_BACKDROP),
4245    ];
4246
4247    /// Tokens that are consumed indirectly (e.g. via helper methods like
4248    /// `score_style()` or `agent_accent_style()`) and may not appear as
4249    /// literal `style_system::STYLE_*` references in rendering code.
4250    /// Each entry requires a justification comment.
4251    const INDIRECT_USE_WHITELIST: &[&str] = &[
4252        // score_style() dispatches to these based on numeric thresholds
4253        "STYLE_SCORE_HIGH",
4254        "STYLE_SCORE_MID",
4255        "STYLE_SCORE_LOW",
4256        // Planned to be wired by implementation bead 2dccg.4.2 (detail find bar
4257        // rendering). This spec bead defines the semantic contract and tests.
4258        "STYLE_DETAIL_FIND_CONTAINER",
4259        "STYLE_DETAIL_FIND_QUERY",
4260        "STYLE_DETAIL_FIND_MATCH_ACTIVE",
4261        "STYLE_DETAIL_FIND_MATCH_INACTIVE",
4262        "STYLE_QUERY_HIGHLIGHT",
4263        // build_pills_row() applies label style per-span within pill construction
4264        "STYLE_PILL_LABEL",
4265    ];
4266
4267    #[test]
4268    fn style_token_registry_is_complete() {
4269        // Verify ALL_STYLE_TOKENS matches the actual pub const declarations.
4270        // Read the source file and extract all `pub const STYLE_*` names.
4271        let source = std::fs::read_to_string(
4272            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/ui/style_system.rs"),
4273        )
4274        .expect("should be able to read style_system.rs");
4275
4276        let mut defined_in_source: Vec<String> = Vec::new();
4277        for line in source.lines() {
4278            let trimmed = line.trim();
4279            if trimmed.starts_with("pub const STYLE_")
4280                && trimmed.contains(": &str")
4281                && let Some(name) = trimmed
4282                    .strip_prefix("pub const ")
4283                    .and_then(|s| s.split(':').next())
4284            {
4285                defined_in_source.push(name.trim().to_string());
4286            }
4287        }
4288
4289        let registry_names: Vec<&str> = ALL_STYLE_TOKENS.iter().map(|(name, _)| *name).collect();
4290
4291        // Check nothing is missing from the registry
4292        for src_name in &defined_in_source {
4293            assert!(
4294                registry_names.contains(&src_name.as_str()),
4295                "Style token `{src_name}` is defined in source but missing from \
4296                 ALL_STYLE_TOKENS registry. Add it to the test registry."
4297            );
4298        }
4299
4300        // Check nothing in registry is absent from source
4301        for reg_name in &registry_names {
4302            assert!(
4303                defined_in_source.iter().any(|s| s == reg_name),
4304                "ALL_STYLE_TOKENS contains `{reg_name}` but it is not defined \
4305                 as `pub const` in source. Remove it from the test registry."
4306            );
4307        }
4308    }
4309
4310    #[test]
4311    fn no_dead_style_tokens() {
4312        // Read all files under src/ui/ that consume style tokens in rendering.
4313        let ui_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/ui");
4314        let mut rendering_source = String::new();
4315
4316        for entry in std::fs::read_dir(&ui_dir).expect("should read src/ui/") {
4317            let entry = entry.expect("dir entry");
4318            let path = entry.path();
4319            if path.extension().is_some_and(|e| e == "rs")
4320                && path.file_name().is_some_and(|n| n != "style_system.rs")
4321            {
4322                rendering_source.push_str(
4323                    &std::fs::read_to_string(&path)
4324                        .unwrap_or_else(|_| panic!("should read {}", path.display())),
4325                );
4326            }
4327        }
4328
4329        // Also include style_system.rs itself for internal references
4330        // (e.g. score_style, markdown_theme, agent_accent_style call sites)
4331        let self_source = std::fs::read_to_string(ui_dir.join("style_system.rs"))
4332            .expect("should read style_system.rs");
4333
4334        let mut dead_tokens: Vec<&str> = Vec::new();
4335
4336        for (const_name, _token_value) in ALL_STYLE_TOKENS {
4337            if INDIRECT_USE_WHITELIST.contains(const_name) {
4338                continue;
4339            }
4340
4341            // Check if the constant name appears in rendering code.
4342            // We search for the constant name (e.g. "STYLE_PILL_ACTIVE") as
4343            // it would appear in `style_system::STYLE_PILL_ACTIVE` or
4344            // `STYLE_PILL_ACTIVE` references.
4345            let in_rendering = rendering_source.contains(const_name);
4346            let in_self_methods = self_source.lines().any(|line| {
4347                line.contains(const_name)
4348                    && !line.trim().starts_with("pub const ")
4349                    && !line.trim().starts_with("//")
4350                    && !line.contains("ALL_STYLE_TOKENS")
4351                    && !line.contains("INDIRECT_USE_WHITELIST")
4352            });
4353
4354            if !in_rendering && !in_self_methods {
4355                dead_tokens.push(const_name);
4356            }
4357        }
4358
4359        assert!(
4360            dead_tokens.is_empty(),
4361            "Dead style tokens found (defined but never used in rendering code):\n  \
4362             {}\n\n\
4363             Fix: Either wire these tokens into rendering code in src/ui/app.rs,\n\
4364             or add them to INDIRECT_USE_WHITELIST with a justification comment\n\
4365             if they are consumed indirectly (e.g. via helper methods).",
4366            dead_tokens.join("\n  ")
4367        );
4368    }
4369
4370    #[test]
4371    fn all_tokens_resolve_to_non_default_style() {
4372        // Every token should produce a meaningfully-styled Style (at minimum
4373        // an fg color) for every preset, ensuring no token silently falls back
4374        // to the stylesheet's default empty style.
4375        for preset in UiThemePreset::all() {
4376            let ctx = context_for_preset(preset);
4377            for (const_name, token_value) in ALL_STYLE_TOKENS {
4378                let style = ctx.style(token_value);
4379                assert!(
4380                    style.fg.is_some() || style.bg.is_some(),
4381                    "Token {const_name} resolves to a style with no fg or bg \
4382                     for preset {} — it may be unwired in build_stylesheet()",
4383                    preset.name()
4384                );
4385            }
4386        }
4387    }
4388
4389    // -- palette correctness & semantic validation (2dccg.10.1) ----------------
4390
4391    #[test]
4392    fn role_tokens_are_pairwise_distinct_per_preset() {
4393        let role_tokens = [
4394            ("user", STYLE_ROLE_USER),
4395            ("assistant", STYLE_ROLE_ASSISTANT),
4396            ("tool", STYLE_ROLE_TOOL),
4397            ("system", STYLE_ROLE_SYSTEM),
4398        ];
4399        for preset in UiThemePreset::all() {
4400            let ctx = context_for_preset(preset);
4401            for i in 0..role_tokens.len() {
4402                for j in (i + 1)..role_tokens.len() {
4403                    let (name_a, token_a) = role_tokens[i];
4404                    let (name_b, token_b) = role_tokens[j];
4405                    let style_a = ctx.style(token_a);
4406                    let style_b = ctx.style(token_b);
4407                    assert_ne!(
4408                        style_a.fg,
4409                        style_b.fg,
4410                        "Role {name_a} and {name_b} must have distinct fg colors \
4411                         for preset {} to remain visually distinguishable",
4412                        preset.name()
4413                    );
4414                }
4415            }
4416        }
4417    }
4418
4419    #[test]
4420    fn role_gutter_tokens_are_pairwise_distinct_per_preset() {
4421        let gutter_tokens = [
4422            ("user", STYLE_ROLE_GUTTER_USER),
4423            ("assistant", STYLE_ROLE_GUTTER_ASSISTANT),
4424            ("tool", STYLE_ROLE_GUTTER_TOOL),
4425            ("system", STYLE_ROLE_GUTTER_SYSTEM),
4426        ];
4427        for preset in UiThemePreset::all() {
4428            let ctx = context_for_preset(preset);
4429            for i in 0..gutter_tokens.len() {
4430                for j in (i + 1)..gutter_tokens.len() {
4431                    let (name_a, token_a) = gutter_tokens[i];
4432                    let (name_b, token_b) = gutter_tokens[j];
4433                    let style_a = ctx.style(token_a);
4434                    let style_b = ctx.style(token_b);
4435                    assert_ne!(
4436                        style_a.fg,
4437                        style_b.fg,
4438                        "Gutter {name_a} and {name_b} must have distinct fg colors \
4439                         for preset {} to remain scannable",
4440                        preset.name()
4441                    );
4442                }
4443            }
4444        }
4445    }
4446
4447    #[test]
4448    fn status_tokens_are_pairwise_distinct_per_preset() {
4449        let status_tokens = [
4450            ("success", STYLE_STATUS_SUCCESS),
4451            ("warning", STYLE_STATUS_WARNING),
4452            ("error", STYLE_STATUS_ERROR),
4453            ("info", STYLE_STATUS_INFO),
4454        ];
4455        for preset in UiThemePreset::all() {
4456            let ctx = context_for_preset(preset);
4457            for i in 0..status_tokens.len() {
4458                for j in (i + 1)..status_tokens.len() {
4459                    let (name_a, token_a) = status_tokens[i];
4460                    let (name_b, token_b) = status_tokens[j];
4461                    let style_a = ctx.style(token_a);
4462                    let style_b = ctx.style(token_b);
4463                    assert_ne!(
4464                        style_a.fg,
4465                        style_b.fg,
4466                        "Status {name_a} and {name_b} must have distinct fg colors \
4467                         for preset {}",
4468                        preset.name()
4469                    );
4470                }
4471            }
4472        }
4473    }
4474
4475    #[test]
4476    fn text_hierarchy_is_ordered_per_preset() {
4477        // text_primary should be "brighter" (more opaque/distinct from bg) than
4478        // text_muted, which should differ from text_subtle.
4479        for preset in UiThemePreset::all() {
4480            let ctx = context_for_preset(preset);
4481            let primary = ctx.style(STYLE_TEXT_PRIMARY);
4482            let muted = ctx.style(STYLE_TEXT_MUTED);
4483            let subtle = ctx.style(STYLE_TEXT_SUBTLE);
4484
4485            assert_ne!(
4486                primary.fg,
4487                muted.fg,
4488                "TEXT_PRIMARY and TEXT_MUTED must differ for preset {}",
4489                preset.name()
4490            );
4491            assert_ne!(
4492                muted.fg,
4493                subtle.fg,
4494                "TEXT_MUTED and TEXT_SUBTLE must differ for preset {}",
4495                preset.name()
4496            );
4497            assert_ne!(
4498                primary.fg,
4499                subtle.fg,
4500                "TEXT_PRIMARY and TEXT_SUBTLE must differ for preset {}",
4501                preset.name()
4502            );
4503        }
4504    }
4505
4506    #[test]
4507    fn score_tokens_form_visual_hierarchy() {
4508        for preset in UiThemePreset::all() {
4509            let ctx = context_for_preset(preset);
4510            let high = ctx.style(STYLE_SCORE_HIGH);
4511            let mid = ctx.style(STYLE_SCORE_MID);
4512            let low = ctx.style(STYLE_SCORE_LOW);
4513
4514            assert_ne!(
4515                high.fg,
4516                mid.fg,
4517                "SCORE_HIGH and SCORE_MID must differ for preset {}",
4518                preset.name()
4519            );
4520            assert_ne!(
4521                mid.fg,
4522                low.fg,
4523                "SCORE_MID and SCORE_LOW must differ for preset {}",
4524                preset.name()
4525            );
4526            // High should have bold for emphasis
4527            assert!(
4528                high.has_attr(ftui::StyleFlags::BOLD),
4529                "SCORE_HIGH should be bold for preset {}",
4530                preset.name()
4531            );
4532        }
4533    }
4534
4535    #[test]
4536    fn default_presets_pass_contrast_report() {
4537        // All built-in presets should pass the contrast report (they use
4538        // curated color palettes). Only custom overrides might fail.
4539        for preset in UiThemePreset::all() {
4540            let ctx = context_for_preset(preset);
4541            let report = ctx.contrast_report();
4542            assert!(
4543                !report.has_failures(),
4544                "Preset {} fails contrast checks: {:?}",
4545                preset.name(),
4546                report.failing_pairs().into_iter().collect::<Vec<_>>()
4547            );
4548        }
4549    }
4550
4551    #[test]
4552    fn palette_propagation_is_deterministic() {
4553        // Building the same preset twice should produce identical styles.
4554        for preset in UiThemePreset::all() {
4555            let ctx1 = context_for_preset(preset);
4556            let ctx2 = context_for_preset(preset);
4557            for (_const_name, token_value) in ALL_STYLE_TOKENS {
4558                let s1 = ctx1.style(token_value);
4559                let s2 = ctx2.style(token_value);
4560                assert_eq!(
4561                    format!("{s1:?}"),
4562                    format!("{s2:?}"),
4563                    "Token {_const_name} is not deterministic for preset {}",
4564                    preset.name()
4565                );
4566            }
4567        }
4568    }
4569
4570    // =====================================================================
4571    // 2dccg.11.1 — Rendering-facing style invariants with structured logging
4572    // =====================================================================
4573
4574    #[test]
4575    fn rendering_token_affordance_matrix_with_logging() {
4576        use super::super::test_log::{Category, TestLogger};
4577
4578        let log = TestLogger::new("11.1.affordance_matrix");
4579
4580        for preset in UiThemePreset::all() {
4581            let ctx = context_for_preset(preset);
4582            log.step_start(Category::Style, format!(r#""preset:{:?}""#, preset));
4583
4584            // Pill active must have background affordance
4585            let pill_active = ctx.style(STYLE_PILL_ACTIVE);
4586            if pill_active.bg.is_some() {
4587                log.pass(
4588                    Category::Style,
4589                    format!(r#""pill_active bg present for {:?}""#, preset),
4590                );
4591            } else {
4592                log.fail(
4593                    Category::Style,
4594                    format!(
4595                        r#"{{"msg":"pill_active bg missing","preset":"{:?}"}}"#,
4596                        preset
4597                    ),
4598                );
4599                panic!("STYLE_PILL_ACTIVE must have bg for {:?}", preset);
4600            }
4601
4602            // Tab active must have background
4603            let tab_active = ctx.style(STYLE_TAB_ACTIVE);
4604            if tab_active.bg.is_some() {
4605                log.pass(
4606                    Category::Style,
4607                    format!(r#""tab_active bg present for {:?}""#, preset),
4608                );
4609            } else {
4610                log.fail(
4611                    Category::Style,
4612                    format!(
4613                        r#"{{"msg":"tab_active bg missing","preset":"{:?}"}}"#,
4614                        preset
4615                    ),
4616                );
4617                panic!("STYLE_TAB_ACTIVE must have bg for {:?}", preset);
4618            }
4619
4620            // Tab inactive must differ from active
4621            let tab_inactive = ctx.style(STYLE_TAB_INACTIVE);
4622            if tab_active.fg != tab_inactive.fg || tab_active.bg != tab_inactive.bg {
4623                log.pass(
4624                    Category::Style,
4625                    format!(r#""tab active/inactive distinct for {:?}""#, preset),
4626                );
4627            } else {
4628                log.fail(
4629                    Category::Style,
4630                    format!(
4631                        r#"{{"msg":"tab active/inactive identical","preset":"{:?}"}}"#,
4632                        preset
4633                    ),
4634                );
4635                panic!("TAB_ACTIVE and TAB_INACTIVE must differ for {:?}", preset);
4636            }
4637
4638            // Score hierarchy
4639            let high = ctx.style(STYLE_SCORE_HIGH);
4640            let mid = ctx.style(STYLE_SCORE_MID);
4641            let low = ctx.style(STYLE_SCORE_LOW);
4642            if high.fg != mid.fg && mid.fg != low.fg {
4643                log.pass(
4644                    Category::Style,
4645                    format!(r#""score hierarchy preserved for {:?}""#, preset),
4646                );
4647            } else {
4648                log.fail(
4649                    Category::Style,
4650                    format!(
4651                        r#"{{"msg":"score hierarchy broken","preset":"{:?}"}}"#,
4652                        preset
4653                    ),
4654                );
4655                panic!(
4656                    "Score HIGH/MID/LOW must be pairwise distinct for {:?}",
4657                    preset
4658                );
4659            }
4660
4661            // Role gutters pairwise distinct
4662            let user = ctx.style(STYLE_ROLE_GUTTER_USER);
4663            let asst = ctx.style(STYLE_ROLE_GUTTER_ASSISTANT);
4664            let tool = ctx.style(STYLE_ROLE_GUTTER_TOOL);
4665            let sys = ctx.style(STYLE_ROLE_GUTTER_SYSTEM);
4666            let roles = [
4667                ("user", user.fg),
4668                ("assistant", asst.fg),
4669                ("tool", tool.fg),
4670                ("system", sys.fg),
4671            ];
4672            let mut distinct = true;
4673            for i in 0..roles.len() {
4674                for j in (i + 1)..roles.len() {
4675                    if roles[i].1 == roles[j].1 {
4676                        distinct = false;
4677                    }
4678                }
4679            }
4680            if distinct {
4681                log.pass(
4682                    Category::Style,
4683                    format!(r#""role gutters pairwise distinct for {:?}""#, preset),
4684                );
4685            } else {
4686                log.fail(
4687                    Category::Style,
4688                    format!(
4689                        r#"{{"msg":"role gutters not pairwise distinct","preset":"{:?}"}}"#,
4690                        preset
4691                    ),
4692                );
4693                panic!("Role gutters must be pairwise distinct for {:?}", preset);
4694            }
4695
4696            log.step_end(Category::Style, format!(r#""preset:{:?} done""#, preset));
4697        }
4698
4699        let (pass, fail, _) = log.summary();
4700        assert!(
4701            fail == 0,
4702            "rendering affordance matrix: {pass} pass, {fail} fail"
4703        );
4704    }
4705
4706    #[test]
4707    fn markdown_theme_preset_coherence_with_logging() {
4708        use super::super::test_log::{Category, TestLogger};
4709
4710        let log = TestLogger::new("11.1.markdown_coherence");
4711
4712        for preset in UiThemePreset::all() {
4713            let ctx = context_for_preset(preset);
4714            let md_theme = ctx.markdown_theme();
4715
4716            // Markdown theme should not be all-default
4717            let default_md = MarkdownTheme::default();
4718            if format!("{:?}", md_theme) != format!("{:?}", default_md) {
4719                log.pass(
4720                    Category::Theme,
4721                    format!(r#""markdown_theme non-default for {:?}""#, preset),
4722                );
4723            } else {
4724                log.fail(
4725                    Category::Theme,
4726                    format!(
4727                        r#"{{"msg":"markdown_theme is default","preset":"{:?}"}}"#,
4728                        preset
4729                    ),
4730                );
4731                panic!("markdown_theme() must be non-default for {:?}", preset);
4732            }
4733
4734            // Code inline should have background
4735            if md_theme.code_inline.bg.is_some() {
4736                log.pass(
4737                    Category::Theme,
4738                    format!(r#""code_inline has bg for {:?}""#, preset),
4739                );
4740            } else {
4741                log.fail(
4742                    Category::Theme,
4743                    format!(
4744                        r#"{{"msg":"code_inline bg missing","preset":"{:?}"}}"#,
4745                        preset
4746                    ),
4747                );
4748                panic!("code_inline must have bg for {:?}", preset);
4749            }
4750        }
4751
4752        let (pass, fail, _) = log.summary();
4753        assert!(fail == 0, "markdown coherence: {pass} pass, {fail} fail");
4754    }
4755
4756    #[test]
4757    fn degradation_affordance_preservation_with_logging() {
4758        use super::super::test_log::{Category, TestLogger};
4759        use crate::ui::app::LayoutBreakpoint as LB;
4760        use ftui::render::budget::DegradationLevel;
4761
4762        let log = TestLogger::new("11.1.degradation_affordance");
4763        let opts = StyleOptions {
4764            color_profile: ColorProfile::TrueColor,
4765            ..StyleOptions::default()
4766        };
4767
4768        // At Full degradation, DecorativePolicy should allow all decorations
4769        let full_policy = DecorativePolicy::resolve(opts, DegradationLevel::Full, LB::Wide, true);
4770        if full_policy.use_gradients && full_policy.show_icons {
4771            log.pass(
4772                Category::Degradation,
4773                r#""Full allows gradients+icons""#.to_string(),
4774            );
4775        } else {
4776            log.fail(
4777                Category::Degradation,
4778                format!(
4779                    r#"{{"msg":"Full degradation restricts decorations","gradients":{},"icons":{}}}"#,
4780                    full_policy.use_gradients, full_policy.show_icons
4781                ),
4782            );
4783        }
4784
4785        // At EssentialOnly, decorations should be restricted
4786        let essential_policy =
4787            DecorativePolicy::resolve(opts, DegradationLevel::EssentialOnly, LB::Wide, true);
4788        if !essential_policy.use_gradients {
4789            log.pass(
4790                Category::Degradation,
4791                r#""EssentialOnly restricts gradients""#.to_string(),
4792            );
4793        } else {
4794            log.fail(
4795                Category::Degradation,
4796                r#""EssentialOnly should restrict gradients""#.to_string(),
4797            );
4798        }
4799
4800        let (pass, fail, _) = log.summary();
4801        assert!(
4802            fail == 0,
4803            "degradation affordance: {pass} pass, {fail} fail"
4804        );
4805    }
4806}