Skip to main content

jugar_probar/web/
css_builder.rs

1//! Type-Safe CSS Generation (Zero-JavaScript Policy)
2//!
3//! Generates valid CSS programmatically with responsive design support.
4
5use crate::result::ProbarResult;
6use serde::{Deserialize, Serialize};
7
8/// Generated CSS output
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GeneratedCss {
11    /// CSS content
12    pub content: String,
13    /// CSS rules in the stylesheet
14    pub rules: Vec<CssRule>,
15    /// CSS variables defined
16    pub variables: Vec<(String, String)>,
17}
18
19/// A CSS rule with selector and declarations
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct CssRule {
22    /// CSS selector
23    pub selector: String,
24    /// Property-value pairs
25    pub declarations: Vec<(String, String)>,
26}
27
28impl CssRule {
29    /// Create a new CSS rule
30    #[must_use]
31    pub fn new(selector: &str) -> Self {
32        Self {
33            selector: selector.to_string(),
34            declarations: Vec::new(),
35        }
36    }
37
38    /// Add a declaration
39    #[must_use]
40    pub fn declaration(mut self, property: &str, value: &str) -> Self {
41        self.declarations
42            .push((property.to_string(), value.to_string()));
43        self
44    }
45
46    /// Render rule to CSS string
47    #[must_use]
48    pub fn render(&self) -> String {
49        if self.declarations.is_empty() {
50            return String::new();
51        }
52
53        let decls = self
54            .declarations
55            .iter()
56            .map(|(prop, val)| format!("    {prop}: {val};"))
57            .collect::<Vec<_>>()
58            .join("\n");
59
60        format!("{} {{\n{}\n}}", self.selector, decls)
61    }
62}
63
64/// Type-safe CSS builder
65#[derive(Debug, Clone, Default)]
66pub struct CssBuilder {
67    variables: Vec<(String, String)>,
68    rules: Vec<CssRule>,
69}
70
71impl CssBuilder {
72    /// Create a new CSS builder
73    #[must_use]
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Add a CSS variable
79    #[must_use]
80    pub fn variable(mut self, name: &str, value: &str) -> Self {
81        self.variables.push((name.to_string(), value.to_string()));
82        self
83    }
84
85    /// Add a CSS rule
86    #[must_use]
87    pub fn rule(mut self, rule: CssRule) -> Self {
88        self.rules.push(rule);
89        self
90    }
91
92    /// Add reset styles (minimal normalize)
93    #[must_use]
94    pub fn reset(mut self) -> Self {
95        self.rules.push(
96            CssRule::new("*, *::before, *::after")
97                .declaration("box-sizing", "border-box")
98                .declaration("margin", "0")
99                .declaration("padding", "0"),
100        );
101        self
102    }
103
104    /// Add responsive canvas styling
105    #[must_use]
106    pub fn responsive_canvas(mut self, id: &str) -> Self {
107        self.rules.push(
108            CssRule::new(&format!("#{id}"))
109                .declaration("width", "100vw")
110                .declaration("height", "100vh")
111                .declaration("display", "block")
112                .declaration("touch-action", "none"),
113        );
114        self
115    }
116
117    /// Add fullscreen body styling
118    #[must_use]
119    pub fn fullscreen_body(mut self) -> Self {
120        self.rules.push(
121            CssRule::new("html, body")
122                .declaration("width", "100%")
123                .declaration("height", "100%")
124                .declaration("margin", "0")
125                .declaration("padding", "0")
126                .declaration("overflow", "hidden"),
127        );
128        self
129    }
130
131    /// Add a media query rule
132    #[must_use]
133    pub fn media_query(mut self, query: &str, rule: CssRule) -> Self {
134        // Store as a special rule with media query prefix
135        let media_rule = CssRule {
136            selector: format!("@media {query} {{ {} }}", rule.selector),
137            declarations: rule.declarations,
138        };
139        self.rules.push(media_rule);
140        self
141    }
142
143    /// Add dark mode support
144    #[must_use]
145    #[allow(clippy::literal_string_with_formatting_args)]
146    pub fn dark_mode(mut self, background: &str, foreground: &str) -> Self {
147        self.rules.push(
148            CssRule::new("@media (prefers-color-scheme: dark) { :root }")
149                .declaration("--bg-color", background)
150                .declaration("--fg-color", foreground),
151        );
152        self
153    }
154
155    /// Build the CSS stylesheet
156    ///
157    /// # Errors
158    ///
159    /// Currently always succeeds, but returns Result for future validation
160    pub fn build(self) -> ProbarResult<GeneratedCss> {
161        let mut content = String::new();
162
163        // Generate CSS variables in :root
164        if !self.variables.is_empty() {
165            content.push_str(":root {\n");
166            for (name, value) in &self.variables {
167                content.push_str(&format!("    --{name}: {value};\n"));
168            }
169            content.push_str("}\n\n");
170        }
171
172        // Generate rules
173        let rule_strings: Vec<String> = self.rules.iter().map(CssRule::render).collect();
174        content.push_str(&rule_strings.join("\n\n"));
175
176        Ok(GeneratedCss {
177            content,
178            rules: self.rules,
179            variables: self.variables,
180        })
181    }
182}
183
184/// Pre-built CSS presets
185#[allow(dead_code)]
186pub mod presets {
187    use super::{CssBuilder, CssRule};
188
189    /// WASM application preset - fullscreen canvas with no scrollbars
190    #[must_use]
191    pub fn wasm_app(canvas_id: &str) -> CssBuilder {
192        CssBuilder::new()
193            .reset()
194            .fullscreen_body()
195            .responsive_canvas(canvas_id)
196    }
197
198    /// Calculator preset
199    #[must_use]
200    pub fn calculator() -> CssBuilder {
201        CssBuilder::new()
202            .reset()
203            .variable("primary-color", "#4a90d9")
204            .variable("secondary-color", "#2c3e50")
205            .variable("bg-color", "#1a1a2e")
206            .rule(
207                CssRule::new("body")
208                    .declaration("font-family", "system-ui, sans-serif")
209                    .declaration("background", "var(--bg-color)")
210                    .declaration("color", "#fff")
211                    .declaration("display", "flex")
212                    .declaration("justify-content", "center")
213                    .declaration("align-items", "center")
214                    .declaration("min-height", "100vh"),
215            )
216    }
217
218    /// Game preset with loading screen support
219    #[must_use]
220    pub fn game(canvas_id: &str) -> CssBuilder {
221        CssBuilder::new()
222            .reset()
223            .fullscreen_body()
224            .responsive_canvas(canvas_id)
225            .rule(
226                CssRule::new(".loading")
227                    .declaration("position", "fixed")
228                    .declaration("inset", "0")
229                    .declaration("display", "flex")
230                    .declaration("justify-content", "center")
231                    .declaration("align-items", "center")
232                    .declaration("background", "#000")
233                    .declaration("color", "#fff")
234                    .declaration("font-size", "1.5rem"),
235            )
236    }
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used, clippy::expect_used, clippy::wildcard_imports)]
241mod tests {
242    use super::*;
243
244    // =========================================================================
245    // H₀-CSS-01: CssBuilder creation
246    // =========================================================================
247
248    #[test]
249    fn h0_css_01_builder_new() {
250        let builder = CssBuilder::new();
251        assert!(builder.variables.is_empty());
252        assert!(builder.rules.is_empty());
253    }
254
255    #[test]
256    fn h0_css_02_builder_variable() {
257        let css = CssBuilder::new()
258            .variable("primary", "#ff0000")
259            .build()
260            .unwrap();
261
262        assert!(css.content.contains(":root {"));
263        assert!(css.content.contains("--primary: #ff0000;"));
264    }
265
266    // =========================================================================
267    // H₀-CSS-03: CSS rule generation
268    // =========================================================================
269
270    #[test]
271    fn h0_css_03_rule_render() {
272        let rule = CssRule::new("body")
273            .declaration("margin", "0")
274            .declaration("padding", "0");
275
276        let rendered = rule.render();
277        assert!(rendered.contains("body {"));
278        assert!(rendered.contains("margin: 0;"));
279        assert!(rendered.contains("padding: 0;"));
280    }
281
282    #[test]
283    fn h0_css_04_rule_empty_declarations() {
284        let rule = CssRule::new("div");
285        let rendered = rule.render();
286        assert!(rendered.is_empty());
287    }
288
289    #[test]
290    fn h0_css_05_builder_rule() {
291        let css = CssBuilder::new()
292            .rule(CssRule::new(".test").declaration("color", "red"))
293            .build()
294            .unwrap();
295
296        assert!(css.content.contains(".test {"));
297        assert!(css.content.contains("color: red;"));
298    }
299
300    // =========================================================================
301    // H₀-CSS-06: Preset methods
302    // =========================================================================
303
304    #[test]
305    fn h0_css_06_reset() {
306        let css = CssBuilder::new().reset().build().unwrap();
307
308        assert!(css.content.contains("box-sizing: border-box;"));
309        assert!(css.content.contains("margin: 0;"));
310        assert!(css.content.contains("padding: 0;"));
311    }
312
313    #[test]
314    fn h0_css_07_responsive_canvas() {
315        let css = CssBuilder::new().responsive_canvas("game").build().unwrap();
316
317        assert!(css.content.contains("#game {"));
318        assert!(css.content.contains("width: 100vw;"));
319        assert!(css.content.contains("height: 100vh;"));
320        assert!(css.content.contains("touch-action: none;"));
321    }
322
323    #[test]
324    fn h0_css_08_fullscreen_body() {
325        let css = CssBuilder::new().fullscreen_body().build().unwrap();
326
327        assert!(css.content.contains("html, body {"));
328        assert!(css.content.contains("overflow: hidden;"));
329    }
330
331    #[test]
332    fn h0_css_09_dark_mode() {
333        let css = CssBuilder::new().dark_mode("#000", "#fff").build().unwrap();
334
335        assert!(css.content.contains("prefers-color-scheme: dark"));
336        assert!(css.content.contains("--bg-color: #000;"));
337        assert!(css.content.contains("--fg-color: #fff;"));
338    }
339
340    // =========================================================================
341    // H₀-CSS-10: Preset modules
342    // =========================================================================
343
344    #[test]
345    fn h0_css_10_preset_wasm_app() {
346        let css = presets::wasm_app("app").build().unwrap();
347
348        assert!(css.content.contains("#app {"));
349        assert!(css.content.contains("100vw"));
350    }
351
352    #[test]
353    fn h0_css_11_preset_calculator() {
354        let css = presets::calculator().build().unwrap();
355
356        assert!(css.content.contains("--primary-color"));
357        assert!(css.content.contains("system-ui"));
358    }
359
360    #[test]
361    fn h0_css_12_preset_game() {
362        let css = presets::game("canvas").build().unwrap();
363
364        assert!(css.content.contains("#canvas"));
365        assert!(css.content.contains(".loading"));
366    }
367
368    // =========================================================================
369    // H₀-CSS-13: Generated output structure
370    // =========================================================================
371
372    #[test]
373    fn h0_css_13_generated_css_fields() {
374        let css = CssBuilder::new()
375            .variable("x", "1")
376            .rule(CssRule::new("a").declaration("b", "c"))
377            .build()
378            .unwrap();
379
380        assert_eq!(css.variables.len(), 1);
381        assert_eq!(css.rules.len(), 1);
382        assert!(!css.content.is_empty());
383    }
384
385    #[test]
386    fn h0_css_14_chained_methods() {
387        let css = CssBuilder::new()
388            .reset()
389            .fullscreen_body()
390            .responsive_canvas("c")
391            .variable("color", "blue")
392            .build()
393            .unwrap();
394
395        // Should have reset + fullscreen_body + responsive_canvas = 3 rules
396        assert_eq!(css.rules.len(), 3);
397        assert_eq!(css.variables.len(), 1);
398    }
399}