Skip to main content

gwk_theme/
lib.rs

1//! The SIGNAL design tokens — data only, no rendering, no dependencies beyond
2//! serialization.
3//!
4//! One source of truth consumed three ways: the site's CSS custom properties
5//! (checked by `tools/check-theme-sync.sh` — token `name` maps to
6//! `--kebab-case`), the TUI theme, and the generated TypeScript contract.
7//!
8//! These values are the ratified SIGNAL contract. A change here is a design
9//! decision, never drift.
10//!
11//! NOTE: the `Token { name: …, value: …, role: … }` lines below are parsed by
12//! `tools/check-theme-sync.sh` — keep one token per line.
13
14/// One named color token.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
16pub struct Token {
17    /// snake_case token name (CSS uses the kebab-case form).
18    pub name: &'static str,
19    /// `#RRGGBB` hex color.
20    pub value: &'static str,
21    /// What the token is FOR — the contract is the role, not the hex.
22    pub role: &'static str,
23}
24
25/// The SIGNAL palette.
26#[rustfmt::skip]
27pub const SIGNAL: &[Token] = &[
28    Token { name: "bg", value: "#070B10", role: "canvas background" },
29    Token { name: "surface", value: "#121A24", role: "raised surface" },
30    Token { name: "surface_2", value: "#1F2B3A", role: "second elevation" },
31    Token { name: "hue", value: "#6BDBFF", role: "accent" },
32    Token { name: "hue_dim", value: "#3FA8CC", role: "accent, dimmed" },
33    Token { name: "hue_bright", value: "#9AE8FF", role: "accent, bright" },
34    Token { name: "fg", value: "#E4EDF5", role: "foreground text" },
35    Token { name: "faint", value: "#526274", role: "faint structure (decorative/hairline only — never text or essential UI)" },
36    Token { name: "muted", value: "#9AA5AF", role: "muted text" },
37    Token { name: "warn", value: "#F2C14E", role: "warning" },
38    Token { name: "fail", value: "#FF6E6E", role: "failure" },
39    Token { name: "ok", value: "#6EE7A8", role: "success" },
40];
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45
46    #[test]
47    fn twelve_unique_snake_named_hex_tokens() {
48        assert_eq!(SIGNAL.len(), 12);
49        let mut names: Vec<&str> = SIGNAL.iter().map(|t| t.name).collect();
50        names.sort_unstable();
51        names.dedup();
52        assert_eq!(names.len(), 12, "duplicate token name");
53        for token in SIGNAL {
54            assert!(
55                token
56                    .name
57                    .chars()
58                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
59                "token name not snake_case: {}",
60                token.name
61            );
62            assert!(
63                token.value.len() == 7
64                    && token.value.starts_with('#')
65                    && token.value[1..].chars().all(|c| c.is_ascii_hexdigit()),
66                "token value not #RRGGBB: {}",
67                token.value
68            );
69            assert!(!token.role.is_empty());
70        }
71    }
72
73    #[test]
74    fn faint_role_forbids_text_and_essential_ui() {
75        let faint = SIGNAL
76            .iter()
77            .find(|token| token.name == "faint")
78            .expect("faint token");
79        assert_eq!(
80            faint.role,
81            "faint structure (decorative/hairline only — never text or essential UI)"
82        );
83    }
84
85    #[test]
86    fn token_serializes_as_plain_data() {
87        let json = serde_json::to_value(SIGNAL[0]).expect("serialize");
88        assert_eq!(
89            json,
90            serde_json::json!({ "name": "bg", "value": "#070B10", "role": "canvas background" })
91        );
92    }
93}