Skip to main content

rustio_core/admin/
design.rs

1//! Admin design customisation.
2//!
3//! RustIO's admin is **framework-owned** — projects don't edit
4//! templates or stylesheets. What they *can* change is the thin layer
5//! of visual identity: logo initial, project display name, primary /
6//! accent colours, density. Those values live in `rustio.design.json`
7//! at the project root and are loaded once per process via
8//! [`Design::global`].
9//!
10//! The config is **visual only**. It cannot alter page structure,
11//! routing, form semantics, or any admin behaviour. Any value outside
12//! the accepted range (e.g. a colour string containing `;` or `}`) is
13//! silently replaced with the safe default at render time so a bad
14//! config can't break the admin.
15
16use std::sync::OnceLock;
17
18use serde::{Deserialize, Serialize};
19
20/// Visual-only admin design config.
21///
22/// Defaults produce a calm, slate-and-indigo look that works out of
23/// the box. Projects override fields individually; unspecified fields
24/// fall back to the default.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(default, deny_unknown_fields)]
27pub struct Design {
28    /// Name shown in the sidebar and page titles, e.g. "Workflowdesk".
29    pub project_name: String,
30    /// Single character rendered in the square logo mark. Projects
31    /// using longer glyphs should pick a wide character, e.g. "◈".
32    pub logo_initial: String,
33    /// CSS colour used for the primary action button and the sidebar
34    /// logo-mark background. Accept hex (`#0f172a`), rgb(), hsl(), or
35    /// named colours. Values containing `;`, `{`, `}`, `<`, or `\`
36    /// are rejected and replaced with the default at render time.
37    pub primary_color: String,
38    /// CSS colour used for focus rings and hyperlinks.
39    pub accent_color: String,
40    /// Row density for tables and forms.
41    pub density: Density,
42}
43
44/// Row-density modes for tables, cards, and forms.
45#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum Density {
48    /// Default — relaxed spacing, 14px cell vertical padding.
49    #[default]
50    Comfortable,
51    /// Tighter spacing. 10px cell vertical padding. Surfaces more
52    /// data per screen; sacrifices some readability.
53    Compact,
54}
55
56impl Default for Design {
57    fn default() -> Self {
58        Self {
59            project_name: "RustIO".to_string(),
60            logo_initial: "R".to_string(),
61            // Blue-600 (#2563eb) — the 0.10.1 visual refresh moved the
62            // default off indigo-600 (#4f46e5) onto a slightly cooler
63            // blue to match the Sora/Source-Sans card design. The
64            // accompanying admin.css uses the same value as
65            // `--admin-accent`. Projects with `rustio.design.json`
66            // pinning a colour continue to override.
67            primary_color: "#2563eb".to_string(),
68            accent_color: "#2563eb".to_string(),
69            density: Density::Comfortable,
70        }
71    }
72}
73
74impl Design {
75    /// Load from `rustio.design.json` in the current working
76    /// directory, or return defaults if the file is missing /
77    /// unreadable / malformed.
78    ///
79    /// Silently falls back on any error. Logging the parse failure is
80    /// a project concern — we never want a bad design config to block
81    /// the admin from rendering.
82    pub fn load() -> Self {
83        let path = std::path::Path::new("rustio.design.json");
84        if let Ok(bytes) = std::fs::read(path) {
85            if let Ok(parsed) = serde_json::from_slice::<Design>(&bytes) {
86                return parsed;
87            }
88        }
89        Self::default()
90    }
91
92    /// Process-global instance. Lazily loaded on first access. Not
93    /// reloadable at runtime — restart the server to pick up a new
94    /// config. This matches the static-asset posture of the rest of
95    /// the admin.
96    pub fn global() -> &'static Self {
97        static INSTANCE: OnceLock<Design> = OnceLock::new();
98        INSTANCE.get_or_init(Design::load)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn default_values_are_reasonable() {
108        let d = Design::default();
109        assert_eq!(d.project_name, "RustIO");
110        assert_eq!(d.logo_initial, "R");
111        assert!(d.primary_color.starts_with('#'));
112        assert!(matches!(d.density, Density::Comfortable));
113    }
114
115    #[test]
116    fn density_serializes_as_snake_case() {
117        let d = Density::Comfortable;
118        let s = serde_json::to_string(&d).unwrap();
119        assert_eq!(s, "\"comfortable\"");
120        let d2 = Density::Compact;
121        let s2 = serde_json::to_string(&d2).unwrap();
122        assert_eq!(s2, "\"compact\"");
123    }
124
125    #[test]
126    fn parse_rejects_unknown_fields() {
127        let json = r#"{"project_name":"X","surprise":"yes"}"#;
128        let parsed = serde_json::from_str::<Design>(json);
129        assert!(
130            parsed.is_err(),
131            "deny_unknown_fields must reject `surprise`"
132        );
133    }
134
135    #[test]
136    fn parse_accepts_partial_config() {
137        // Using r##"..."## because the JSON contains `"#` which would
138        // otherwise terminate a single-hash raw string.
139        let json = r##"{"project_name":"Workflowdesk","primary_color":"#1e40af"}"##;
140        let d: Design = serde_json::from_str(json).unwrap();
141        assert_eq!(d.project_name, "Workflowdesk");
142        assert_eq!(d.primary_color, "#1e40af");
143        // Missing fields fall back to defaults.
144        assert_eq!(d.logo_initial, "R");
145        assert_eq!(d.accent_color, "#2563eb");
146    }
147
148    #[test]
149    fn default_palette_is_blue_as_of_0_10_1() {
150        // 0.10.1 visual refresh: indigo-600 → blue-600 to match the
151        // Sora/Source-Sans card design (see admin.css `--admin-accent`).
152        let d = Design::default();
153        assert_eq!(d.primary_color, "#2563eb");
154        assert_eq!(d.accent_color, "#2563eb");
155    }
156}