Skip to main content

tatara_ui/
palette.rs

1//! Nord palette — canonical hex values from <https://www.nordtheme.com>,
2//! matched to pleme-io's starship + irodori conventions.
3
4/// A 24-bit RGB color — the wire form for all tatara-ui palette values.
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
6pub struct Rgb(pub u8, pub u8, pub u8);
7
8impl Rgb {
9    pub const fn from_hex(rgb: u32) -> Self {
10        Self(
11            ((rgb >> 16) & 0xff) as u8,
12            ((rgb >> 8) & 0xff) as u8,
13            (rgb & 0xff) as u8,
14        )
15    }
16
17    /// `owo_colors::Rgb` conversion — drops us into the 24-bit ANSI ecosystem.
18    pub fn owo(self) -> owo_colors::Rgb {
19        owo_colors::Rgb(self.0, self.1, self.2)
20    }
21
22    pub fn as_hex(self) -> String {
23        format!("#{:02X}{:02X}{:02X}", self.0, self.1, self.2)
24    }
25}
26
27/// The 16-color Nord palette. Named by the upstream conventions.
28pub struct NordPalette {
29    // Polar Night (darks → dims)
30    pub nord0: Rgb,
31    pub nord1: Rgb,
32    pub nord2: Rgb,
33    pub nord3: Rgb,
34    // Snow Storm (neutrals, foreground)
35    pub nord4: Rgb,
36    pub nord5: Rgb,
37    pub nord6: Rgb,
38    // Frost (cool accents — primary + info + progress)
39    pub nord7: Rgb,
40    pub nord8: Rgb,
41    pub nord9: Rgb,
42    pub nord10: Rgb,
43    // Aurora (warm accents — semantic roles)
44    pub nord11: Rgb,
45    pub nord12: Rgb,
46    pub nord13: Rgb,
47    pub nord14: Rgb,
48    pub nord15: Rgb,
49}
50
51pub const NORD: NordPalette = NordPalette {
52    nord0: Rgb::from_hex(0x2E3440),
53    nord1: Rgb::from_hex(0x3B4252),
54    nord2: Rgb::from_hex(0x434C5E),
55    nord3: Rgb::from_hex(0x4C566A),
56    nord4: Rgb::from_hex(0xD8DEE9),
57    nord5: Rgb::from_hex(0xE5E9F0),
58    nord6: Rgb::from_hex(0xECEFF4),
59    nord7: Rgb::from_hex(0x8FBCBB),
60    nord8: Rgb::from_hex(0x88C0D0),
61    nord9: Rgb::from_hex(0x81A1C1),
62    nord10: Rgb::from_hex(0x5E81AC),
63    nord11: Rgb::from_hex(0xBF616A),
64    nord12: Rgb::from_hex(0xD08770),
65    nord13: Rgb::from_hex(0xEBCB8B),
66    nord14: Rgb::from_hex(0xA3BE8C),
67    nord15: Rgb::from_hex(0xB48EAD),
68};
69
70/// Seven semantic roles — the public vocabulary tooling renders against.
71/// Matches pangea-core/theme.rb's CLI role vocabulary.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
73pub enum Role {
74    Primary,
75    Accent,
76    Info,
77    Success,
78    Warn,
79    Error,
80    Dim,
81}
82
83/// Default Nord → Role mapping. Every tatara-ui consumer starts with this
84/// and may override via `ThemeSpec::semantic`.
85#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86pub struct RoleMap {
87    pub primary: Rgb,
88    pub accent: Rgb,
89    pub info: Rgb,
90    pub success: Rgb,
91    pub warn: Rgb,
92    pub error: Rgb,
93    pub dim: Rgb,
94}
95
96impl Default for RoleMap {
97    fn default() -> Self {
98        Self {
99            primary: NORD.nord8,  // frost cyan
100            accent: NORD.nord15,  // aurora purple
101            info: NORD.nord9,     // frost blue
102            success: NORD.nord14, // aurora green
103            warn: NORD.nord13,    // aurora yellow
104            error: NORD.nord11,   // aurora red
105            dim: NORD.nord3,      // polar night lightest
106        }
107    }
108}
109
110impl RoleMap {
111    pub fn color_of(&self, role: Role) -> Rgb {
112        match role {
113            Role::Primary => self.primary,
114            Role::Accent => self.accent,
115            Role::Info => self.info,
116            Role::Success => self.success,
117            Role::Warn => self.warn,
118            Role::Error => self.error,
119            Role::Dim => self.dim,
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn nord_canonical_hex_values() {
130        assert_eq!(NORD.nord8.as_hex(), "#88C0D0"); // cyan / primary
131        assert_eq!(NORD.nord11.as_hex(), "#BF616A"); // red / error
132        assert_eq!(NORD.nord14.as_hex(), "#A3BE8C"); // green / success
133        assert_eq!(NORD.nord13.as_hex(), "#EBCB8B"); // yellow / warn
134    }
135
136    #[test]
137    fn default_rolemap_binds_semantic_to_nord() {
138        let rm = RoleMap::default();
139        assert_eq!(rm.success.as_hex(), "#A3BE8C");
140        assert_eq!(rm.error.as_hex(), "#BF616A");
141        assert_eq!(rm.warn.as_hex(), "#EBCB8B");
142    }
143
144    #[test]
145    fn rgb_owo_roundtrip_preserves_bytes() {
146        let c = NORD.nord8;
147        let o = c.owo();
148        assert_eq!((o.0, o.1, o.2), (c.0, c.1, c.2));
149    }
150}