Skip to main content

fd_core/
theme.rs

1//! Cross-platform theme contract.
2//!
3//! Single source of truth for visual constants across all FD platforms
4//! (web, VS Code, Tauri desktop, iOS, Android). Each platform consumes
5//! these values through its native theming system.
6
7use serde::{Deserialize, Serialize};
8
9/// Platform-agnostic theme contract.
10///
11/// Defines all visual constants that must be consistent across platforms.
12/// The Rust renderer (`render2d.rs`) derives `CanvasTheme` from this.
13/// JavaScript hosts consume it via `get_theme_json()` WASM API.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct ThemeContract {
16    // ── Canvas ──────────────────────────────────────────────────────
17    /// Canvas background color (the drawing surface)
18    pub canvas_bg: String,
19    /// Grid overlay color (dots/lines)
20    pub grid_color: String,
21
22    // ── Selection & Interaction ─────────────────────────────────────
23    /// Selection highlight accent (handle dots, selection box)
24    pub selection_accent: String,
25    /// Smart guide line color
26    pub smart_guide_color: String,
27
28    // ── Panels & UI Chrome ─────────────────────────────────────────
29    /// Panel background (layers, properties, toolbar)
30    pub panel_bg: String,
31    /// Panel border / separator color
32    pub panel_border: String,
33
34    // ── Text ────────────────────────────────────────────────────────
35    /// Primary text color (labels, headings)
36    pub text_primary: String,
37    /// Secondary text color (captions, hints)
38    pub text_secondary: String,
39
40    // ── Accent ──────────────────────────────────────────────────────
41    /// Primary accent color (buttons, links, active states)
42    pub accent: String,
43
44    // ── Placeholders ────────────────────────────────────────────────
45    /// Generic node placeholder border
46    pub placeholder_border: String,
47    /// Generic node placeholder background
48    pub placeholder_bg: String,
49    /// Generic node placeholder text
50    pub placeholder_text: String,
51
52    // ── Typography ──────────────────────────────────────────────────
53    /// Default font family stack
54    pub font_family: String,
55    /// Base font size in px
56    pub font_size_base: f32,
57    /// Default border radius in px
58    pub border_radius: f32,
59}
60
61impl ThemeContract {
62    /// Light theme — Apple HIG-inspired warm white.
63    pub fn light() -> Self {
64        Self {
65            canvas_bg: "#F5F5F7".into(),
66            grid_color: "rgba(0, 0, 0, 0.05)".into(),
67            selection_accent: "#007AFF".into(),
68            smart_guide_color: "#FF3B30".into(),
69            panel_bg: "rgba(255, 255, 255, 0.8)".into(),
70            panel_border: "rgba(0, 0, 0, 0.06)".into(),
71            text_primary: "#1D1D1F".into(),
72            text_secondary: "#86868B".into(),
73            accent: "#007AFF".into(),
74            placeholder_border: "#86868B".into(),
75            placeholder_bg: "rgba(142, 142, 147, 0.06)".into(),
76            placeholder_text: "#86868B".into(),
77            font_family: "Inter, SF Pro, system-ui, sans-serif".into(),
78            font_size_base: 13.0,
79            border_radius: 8.0,
80        }
81    }
82
83    /// Dark theme — macOS Catppuccin Mocha-inspired.
84    pub fn dark() -> Self {
85        Self {
86            canvas_bg: "#1C1C1E".into(),
87            grid_color: "rgba(255, 255, 255, 0.04)".into(),
88            selection_accent: "#0A84FF".into(),
89            smart_guide_color: "#FF453A".into(),
90            panel_bg: "rgba(44, 44, 46, 0.8)".into(),
91            panel_border: "rgba(255, 255, 255, 0.08)".into(),
92            text_primary: "#F5F5F7".into(),
93            text_secondary: "#98989D".into(),
94            accent: "#0A84FF".into(),
95            placeholder_border: "#636366".into(),
96            placeholder_bg: "rgba(99, 99, 102, 0.08)".into(),
97            placeholder_text: "#98989D".into(),
98            font_family: "Inter, SF Pro, system-ui, sans-serif".into(),
99            font_size_base: 13.0,
100            border_radius: 8.0,
101        }
102    }
103
104    /// Serialize to JSON for JavaScript consumption.
105    pub fn to_json(&self) -> String {
106        serde_json::to_string(self).unwrap_or_else(|_| "{}".into())
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn theme_light_fields_non_empty() {
116        let t = ThemeContract::light();
117        assert!(!t.canvas_bg.is_empty());
118        assert!(!t.grid_color.is_empty());
119        assert!(!t.selection_accent.is_empty());
120        assert!(!t.panel_bg.is_empty());
121        assert!(!t.text_primary.is_empty());
122        assert!(!t.accent.is_empty());
123        assert!(!t.font_family.is_empty());
124        assert!(t.font_size_base > 0.0);
125        assert!(t.border_radius >= 0.0);
126    }
127
128    #[test]
129    fn theme_dark_fields_non_empty() {
130        let t = ThemeContract::dark();
131        assert!(!t.canvas_bg.is_empty());
132        assert!(!t.grid_color.is_empty());
133        assert!(!t.selection_accent.is_empty());
134        assert!(!t.panel_bg.is_empty());
135        assert!(!t.text_primary.is_empty());
136        assert!(!t.accent.is_empty());
137    }
138
139    #[test]
140    fn theme_light_dark_differ() {
141        let l = ThemeContract::light();
142        let d = ThemeContract::dark();
143        assert_ne!(l.canvas_bg, d.canvas_bg, "light and dark bg should differ");
144        assert_ne!(
145            l.text_primary, d.text_primary,
146            "light and dark text should differ"
147        );
148    }
149
150    #[test]
151    fn theme_to_json_roundtrip() {
152        let original = ThemeContract::light();
153        let json = original.to_json();
154        let parsed: ThemeContract = serde_json::from_str(&json).unwrap();
155        assert_eq!(original, parsed);
156    }
157
158    #[test]
159    fn canvas_theme_from_contract() {
160        let contract = ThemeContract::light();
161        // Verify the contract's placeholder fields match CanvasTheme expectations
162        assert_eq!(contract.placeholder_border, "#86868B");
163        assert_eq!(contract.placeholder_bg, "rgba(142, 142, 147, 0.06)");
164        assert_eq!(contract.placeholder_text, "#86868B");
165    }
166}