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}