| 1 | //! Closed color vocabulary for status-bar chrome. |
| 2 | //! |
| 3 | //! Five visual families: surface, neutral text, action, live/outcome, and |
| 4 | //! attention. Semantic roles stay explicit even when they share a hue; |
| 5 | //! modes and outcomes are named by their labels, not extra rainbow lanes. |
| 6 | //! Attention preserves each theme's permission, warning and danger shades. |
| 7 | //! |
| 8 | //! Contract: `docs/design/STATUS_BAR_COLOR_GRAMMAR.md`. |
| 9 | |
| 10 | use ratatui::style::{Color, Style}; |
| 11 | |
| 12 | use super::themes::UiTheme; |
| 13 | |
| 14 | /// The five visual families. Surface is the canvas, not a foreground ink. |
| 15 | #[cfg_attr(not(test), allow(dead_code))] |
| 16 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 17 | pub enum SemanticFamily { |
| 18 | Surface, |
| 19 | Neutral, |
| 20 | Action, |
| 21 | Live, |
| 22 | Attention, |
| 23 | } |
| 24 | |
| 25 | impl SemanticFamily { |
| 26 | #[cfg(test)] |
| 27 | pub const ALL: [Self; 5] = [ |
| 28 | Self::Surface, |
| 29 | Self::Neutral, |
| 30 | Self::Action, |
| 31 | Self::Live, |
| 32 | Self::Attention, |
| 33 | ]; |
| 34 | } |
| 35 | |
| 36 | /// Named status-bar inks. Each variant is an existing `UiTheme` slot, not a |
| 37 | /// new theme. Adding a variant requires assigning one of the five families. |
| 38 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 39 | pub enum ChromeInk { |
| 40 | Outcome, |
| 41 | PermissionAsk, |
| 42 | PermissionAutoReview, |
| 43 | PermissionFullAccess, |
| 44 | Waiting, |
| 45 | Attention, |
| 46 | Active, |
| 47 | PolicyAct, |
| 48 | PolicyPlan, |
| 49 | PolicyOperate, |
| 50 | Identity, |
| 51 | Info, |
| 52 | MetadataValue, |
| 53 | Metadata, |
| 54 | MetadataHint, |
| 55 | MetadataDim, |
| 56 | Failure, |
| 57 | } |
| 58 | |
| 59 | impl ChromeInk { |
| 60 | #[cfg(test)] |
| 61 | pub const ALL: [Self; 17] = [ |
| 62 | Self::Outcome, |
| 63 | Self::PermissionAsk, |
| 64 | Self::PermissionAutoReview, |
| 65 | Self::PermissionFullAccess, |
| 66 | Self::Waiting, |
| 67 | Self::Attention, |
| 68 | Self::Active, |
| 69 | Self::PolicyAct, |
| 70 | Self::PolicyPlan, |
| 71 | Self::PolicyOperate, |
| 72 | Self::Identity, |
| 73 | Self::Info, |
| 74 | Self::MetadataValue, |
| 75 | Self::Metadata, |
| 76 | Self::MetadataHint, |
| 77 | Self::MetadataDim, |
| 78 | Self::Failure, |
| 79 | ]; |
| 80 | |
| 81 | #[must_use] |
| 82 | #[cfg_attr(not(test), allow(dead_code))] |
| 83 | pub const fn family(self) -> SemanticFamily { |
| 84 | match self { |
| 85 | Self::Outcome | Self::Active => SemanticFamily::Live, |
| 86 | Self::PermissionAsk |
| 87 | | Self::PermissionAutoReview |
| 88 | | Self::PermissionFullAccess |
| 89 | | Self::Waiting |
| 90 | | Self::Attention |
| 91 | | Self::Failure => SemanticFamily::Attention, |
| 92 | Self::PolicyAct |
| 93 | | Self::PolicyPlan |
| 94 | | Self::PolicyOperate |
| 95 | | Self::Identity |
| 96 | | Self::Info => SemanticFamily::Action, |
| 97 | Self::MetadataValue | Self::Metadata | Self::MetadataHint | Self::MetadataDim => { |
| 98 | SemanticFamily::Neutral |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | /// Resolve through the live theme. Ordinary navigation and mode share |
| 104 | /// its action hue; active and completed work share its live hue. Safety |
| 105 | /// retains the existing permission and warning/error distinctions. |
| 106 | #[must_use] |
| 107 | pub fn color(self, theme: &UiTheme) -> Color { |
| 108 | match self { |
| 109 | Self::Outcome => theme.status_working, |
| 110 | Self::PermissionAsk => theme.permission_ask, |
| 111 | Self::PermissionAutoReview => theme.permission_auto_review, |
| 112 | Self::PermissionFullAccess => theme.permission_full_access, |
| 113 | Self::Waiting => theme.accent_action, |
| 114 | Self::Attention => theme.warning, |
| 115 | Self::Active => theme.status_working, |
| 116 | Self::PolicyAct => theme.accent_primary, |
| 117 | Self::PolicyPlan => theme.accent_primary, |
| 118 | Self::PolicyOperate => theme.accent_primary, |
| 119 | Self::Identity => theme.accent_primary, |
| 120 | Self::Info => theme.accent_primary, |
| 121 | Self::MetadataValue => theme.text_soft, |
| 122 | Self::Metadata => theme.text_muted, |
| 123 | Self::MetadataHint => theme.text_hint, |
| 124 | Self::MetadataDim => theme.text_dim, |
| 125 | Self::Failure => theme.error_fg, |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | #[must_use] |
| 131 | pub fn chrome_style(theme: &UiTheme, ink: ChromeInk) -> Style { |
| 132 | Style::default().fg(ink.color(theme)) |
| 133 | } |
| 134 | |
| 135 | #[cfg(test)] |
| 136 | mod tests { |
| 137 | use super::*; |
| 138 | use crate::themes::SELECTABLE_THEMES; |
| 139 | |
| 140 | #[test] |
| 141 | fn chrome_has_five_visual_families_with_explicit_safety_roles() { |
| 142 | assert_eq!(SemanticFamily::ALL.len(), 5); |
| 143 | for ink in ChromeInk::ALL { |
| 144 | assert!(SemanticFamily::ALL.contains(&ink.family())); |
| 145 | } |
| 146 | assert_eq!(ChromeInk::Failure.family(), SemanticFamily::Attention); |
| 147 | assert_eq!( |
| 148 | ChromeInk::PermissionFullAccess.family(), |
| 149 | SemanticFamily::Attention |
| 150 | ); |
| 151 | assert_eq!(ChromeInk::PolicyOperate.family(), SemanticFamily::Action); |
| 152 | assert_eq!(ChromeInk::Outcome.family(), SemanticFamily::Live); |
| 153 | } |
| 154 | |
| 155 | #[test] |
| 156 | fn every_selectable_theme_limits_ordinary_chrome_to_action_and_live() { |
| 157 | for id in SELECTABLE_THEMES { |
| 158 | let theme = id.ui_theme(); |
| 159 | // Changing a mode or finishing work must not add a third hue. |
| 160 | for ink in [ |
| 161 | ChromeInk::Identity, |
| 162 | ChromeInk::Info, |
| 163 | ChromeInk::PolicyAct, |
| 164 | ChromeInk::PolicyPlan, |
| 165 | ChromeInk::PolicyOperate, |
| 166 | ] { |
| 167 | assert_eq!( |
| 168 | ink.color(&theme), |
| 169 | theme.accent_primary, |
| 170 | "{} {ink:?}", |
| 171 | id.name() |
| 172 | ); |
| 173 | } |
| 174 | for ink in [ChromeInk::Active, ChromeInk::Outcome] { |
| 175 | assert_eq!( |
| 176 | ink.color(&theme), |
| 177 | theme.status_working, |
| 178 | "{} {ink:?}", |
| 179 | id.name() |
| 180 | ); |
| 181 | } |
| 182 | assert_eq!(ChromeInk::Metadata.color(&theme), theme.text_muted); |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | #[test] |
| 187 | fn every_selectable_theme_preserves_permission_and_failure_meaning() { |
| 188 | for id in SELECTABLE_THEMES { |
| 189 | let theme = id.ui_theme(); |
| 190 | let permissions = [ |
| 191 | ChromeInk::PermissionAsk.color(&theme), |
| 192 | ChromeInk::PermissionAutoReview.color(&theme), |
| 193 | ChromeInk::PermissionFullAccess.color(&theme), |
| 194 | ]; |
| 195 | assert_eq!( |
| 196 | permissions, |
| 197 | [ |
| 198 | theme.permission_ask, |
| 199 | theme.permission_auto_review, |
| 200 | theme.permission_full_access |
| 201 | ], |
| 202 | "{} permission authority", |
| 203 | id.name() |
| 204 | ); |
| 205 | assert_ne!( |
| 206 | permissions[0], |
| 207 | permissions[1], |
| 208 | "{} Ask/Auto-Review", |
| 209 | id.name() |
| 210 | ); |
| 211 | assert_ne!( |
| 212 | permissions[1], |
| 213 | permissions[2], |
| 214 | "{} Auto-Review/Full Access", |
| 215 | id.name() |
| 216 | ); |
| 217 | assert_ne!( |
| 218 | permissions[0], |
| 219 | permissions[2], |
| 220 | "{} Ask/Full Access", |
| 221 | id.name() |
| 222 | ); |
| 223 | assert_eq!(ChromeInk::Attention.color(&theme), theme.warning); |
| 224 | assert_eq!(ChromeInk::Failure.color(&theme), theme.error_fg); |
| 225 | assert_eq!( |
| 226 | chrome_style(&theme, ChromeInk::Failure).fg, |
| 227 | Some(theme.error_fg) |
| 228 | ); |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 |