Skip to main content

logicaffeine_web/ui/
theme_state.rs

1//! Theme state management with localStorage persistence.
2//!
3//! Provides a reactive theme system with 7 nature-inspired themes:
4//! - Sunrise: Warm dawn colors (coral, gold, amber)
5//! - Violet: Twilight mystical (lavender, magenta, soft pink)
6//! - Ocean: Deep sea calm (turquoise, aqua, seafoam)
7//! - Mountain: Clean tech (cyan, indigo) - default
8//! - Rose: Warm pink/rose tones
9//! - Forest: Deep green earth tones
10//! - Ember: Red/orange fire tones
11//!
12//! # Usage
13//!
14//! ```no_run
15//! # use dioxus::prelude::*;
16//! use logicaffeine_web::ui::theme_state::{ThemeState, Theme};
17//!
18//! # fn Example() -> Element {
19//! // Provide at app root
20//! use_context_provider(ThemeState::new);
21//!
22//! // Use in components
23//! let mut theme_state = use_context::<ThemeState>();
24//! let current = theme_state.current();
25//! theme_state.set_theme(Theme::Ocean);
26//! # rsx! {}
27//! # }
28//! ```
29
30use dioxus::prelude::*;
31use serde::{Deserialize, Serialize};
32#[cfg(target_arch = "wasm32")]
33use gloo_storage::{LocalStorage, Storage};
34
35const THEME_STORAGE_KEY: &str = "logicaffeine-theme";
36
37/// Available theme variants.
38#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
39pub enum Theme {
40    /// Warm dawn - coral, gold, amber
41    Sunrise,
42    /// Soft dusk - muted lavender, rose, warm gray
43    Violet,
44    /// Deep sea - cyan, teal, seafoam
45    Ocean,
46    /// Midnight tech - cyan accent, minimal, cutting-edge (default)
47    #[default]
48    Mountain,
49    /// Warm pink - rose, blush, coral pink
50    Rose,
51    /// Deep green - forest, emerald, moss
52    Forest,
53    /// Fire tones - red, orange, amber
54    Ember,
55}
56
57impl Theme {
58    /// Returns the theme name for display.
59    pub fn name(&self) -> &'static str {
60        match self {
61            Theme::Sunrise => "Sunrise",
62            Theme::Violet => "Violet",
63            Theme::Ocean => "Ocean",
64            Theme::Mountain => "Mountain",
65            Theme::Rose => "Rose",
66            Theme::Forest => "Forest",
67            Theme::Ember => "Ember",
68        }
69    }
70
71    /// Returns the data-theme attribute value.
72    pub fn data_attr(&self) -> &'static str {
73        match self {
74            Theme::Sunrise => "sunrise",
75            Theme::Violet => "violet",
76            Theme::Ocean => "ocean",
77            Theme::Mountain => "mountain",
78            Theme::Rose => "rose",
79            Theme::Forest => "forest",
80            Theme::Ember => "ember",
81        }
82    }
83
84    /// Returns an iterator over all themes.
85    pub fn all() -> impl Iterator<Item = Theme> {
86        [
87            Theme::Mountain,
88            Theme::Sunrise,
89            Theme::Violet,
90            Theme::Ocean,
91            Theme::Rose,
92            Theme::Forest,
93            Theme::Ember,
94        ].into_iter()
95    }
96
97    /// Returns the CSS variables for this theme.
98    pub fn css_variables(&self) -> &'static str {
99        match self {
100            // Sunrise: Warm, energetic dawn
101            Theme::Sunrise => r#"
102                --bg-gradient-start: #0c0a09;
103                --bg-gradient-mid: #1c1410;
104                --bg-gradient-end: #0c0a09;
105                --accent-primary: #f97316;
106                --accent-secondary: #fbbf24;
107                --accent-tertiary: #fef3c7;
108                --accent-primary-rgb: 249, 115, 22;
109                --accent-secondary-rgb: 251, 191, 36;
110                --accent-border: rgba(249, 115, 22, 0.3);
111                --accent-glow: rgba(249, 115, 22, 0.2);
112            "#,
113            // Violet: Soft, sophisticated dusk (less intense purple)
114            Theme::Violet => r#"
115                --bg-gradient-start: #0f0d13;
116                --bg-gradient-mid: #1a1520;
117                --bg-gradient-end: #0f0d13;
118                --accent-primary: #a78bfa;
119                --accent-secondary: #f0abfc;
120                --accent-tertiary: #e9d5ff;
121                --accent-primary-rgb: 167, 139, 250;
122                --accent-secondary-rgb: 240, 171, 252;
123                --accent-border: rgba(167, 139, 250, 0.3);
124                --accent-glow: rgba(167, 139, 250, 0.2);
125            "#,
126            // Ocean: Cool, calm depths
127            Theme::Ocean => r#"
128                --bg-gradient-start: #0a0f14;
129                --bg-gradient-mid: #0c1820;
130                --bg-gradient-end: #0a0f14;
131                --accent-primary: #22d3ee;
132                --accent-secondary: #2dd4bf;
133                --accent-tertiary: #a5f3fc;
134                --accent-primary-rgb: 34, 211, 238;
135                --accent-secondary-rgb: 45, 212, 191;
136                --accent-border: rgba(34, 211, 238, 0.3);
137                --accent-glow: rgba(34, 211, 238, 0.2);
138            "#,
139            // Mountain (default): Clean, cutting-edge tech
140            Theme::Mountain => r#"
141                --bg-gradient-start: #09090b;
142                --bg-gradient-mid: #0c0c10;
143                --bg-gradient-end: #09090b;
144                --accent-primary: #00d4ff;
145                --accent-secondary: #818cf8;
146                --accent-tertiary: #f0f0f0;
147                --accent-primary-rgb: 0, 212, 255;
148                --accent-secondary-rgb: 129, 140, 248;
149                --accent-border: rgba(0, 212, 255, 0.3);
150                --accent-glow: rgba(0, 212, 255, 0.2);
151            "#,
152            // Rose: Warm pink/rose tones
153            Theme::Rose => r#"
154                --bg-gradient-start: #0d0909;
155                --bg-gradient-mid: #1a1015;
156                --bg-gradient-end: #0d0909;
157                --accent-primary: #f472b6;
158                --accent-secondary: #fb7185;
159                --accent-tertiary: #fce7f3;
160                --accent-primary-rgb: 244, 114, 182;
161                --accent-secondary-rgb: 251, 113, 133;
162                --accent-border: rgba(244, 114, 182, 0.3);
163                --accent-glow: rgba(244, 114, 182, 0.2);
164            "#,
165            // Forest: Deep green earth tones
166            Theme::Forest => r#"
167                --bg-gradient-start: #090d09;
168                --bg-gradient-mid: #0f1a12;
169                --bg-gradient-end: #090d09;
170                --accent-primary: #4ade80;
171                --accent-secondary: #86efac;
172                --accent-tertiary: #dcfce7;
173                --accent-primary-rgb: 74, 222, 128;
174                --accent-secondary-rgb: 134, 239, 172;
175                --accent-border: rgba(74, 222, 128, 0.3);
176                --accent-glow: rgba(74, 222, 128, 0.2);
177            "#,
178            // Ember: Red/orange fire tones
179            Theme::Ember => r#"
180                --bg-gradient-start: #0d0808;
181                --bg-gradient-mid: #1a0f0c;
182                --bg-gradient-end: #0d0808;
183                --accent-primary: #ef4444;
184                --accent-secondary: #f97316;
185                --accent-tertiary: #fef2f2;
186                --accent-primary-rgb: 239, 68, 68;
187                --accent-secondary-rgb: 249, 115, 22;
188                --accent-border: rgba(239, 68, 68, 0.3);
189                --accent-glow: rgba(239, 68, 68, 0.2);
190            "#,
191        }
192    }
193}
194
195/// Global theme state with localStorage persistence.
196#[derive(Clone, Copy)]
197pub struct ThemeState {
198    current: Signal<Theme>,
199}
200
201impl ThemeState {
202    /// Creates a new ThemeState, loading from localStorage if available.
203    /// Server renders always use the default theme; the client repaints with the
204    /// stored choice as soon as it takes over.
205    pub fn new() -> Self {
206        #[cfg(target_arch = "wasm32")]
207        let initial = LocalStorage::get::<Theme>(THEME_STORAGE_KEY).ok().unwrap_or_default();
208        #[cfg(not(target_arch = "wasm32"))]
209        let initial = Theme::default();
210
211        Self {
212            current: Signal::new(initial),
213        }
214    }
215
216    /// Returns the current theme.
217    pub fn current(&self) -> Theme {
218        *self.current.read()
219    }
220
221    /// Sets the theme and persists to localStorage.
222    pub fn set_theme(&mut self, theme: Theme) {
223        self.current.set(theme);
224        #[cfg(target_arch = "wasm32")]
225        let _ = LocalStorage::set(THEME_STORAGE_KEY, theme);
226    }
227
228    /// Cycles to the next theme.
229    pub fn cycle_theme(&mut self) {
230        let next = match self.current() {
231            Theme::Mountain => Theme::Sunrise,
232            Theme::Sunrise => Theme::Violet,
233            Theme::Violet => Theme::Ocean,
234            Theme::Ocean => Theme::Rose,
235            Theme::Rose => Theme::Forest,
236            Theme::Forest => Theme::Ember,
237            Theme::Ember => Theme::Mountain,
238        };
239        self.set_theme(next);
240    }
241}
242
243impl Default for ThemeState {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249/// Returns the full theme CSS including variables and background.
250pub fn theme_css(theme: Theme) -> String {
251    let vars = theme.css_variables();
252    format!(
253        r#":root {{
254            {vars}
255        }}
256
257        html, body {{
258            background: linear-gradient(
259                135deg,
260                var(--bg-gradient-start) 0%,
261                var(--bg-gradient-mid) 50%,
262                var(--bg-gradient-end) 100%
263            );
264        }}
265
266        /* Theme-aware accent colors */
267        .accent-primary {{
268            color: var(--accent-primary);
269        }}
270
271        .accent-secondary {{
272            color: var(--accent-secondary);
273        }}
274
275        .bg-accent-primary {{
276            background-color: var(--accent-primary);
277        }}
278
279        .bg-accent-secondary {{
280            background-color: var(--accent-secondary);
281        }}
282
283        .border-accent {{
284            border-color: var(--accent-primary);
285        }}
286
287        /* Override color-accent-blue/purple with theme colors */
288        .main-nav-link.active::after {{
289            background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)) !important;
290        }}
291
292        .btn-primary,
293        .main-nav-btn.primary {{
294            background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important;
295            box-shadow: 0 12px 30px rgba(var(--accent-primary-rgb), 0.18) !important;
296        }}
297
298        .btn-primary:hover,
299        .main-nav-btn.primary:hover {{
300            box-shadow: 0 16px 40px rgba(var(--accent-primary-rgb), 0.25) !important;
301        }}
302
303        /* Gradient buttons with theme colors */
304        .btn-gradient {{
305            background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
306        }}
307
308        .btn-gradient:hover {{
309            box-shadow: 0 8px 24px rgba(var(--accent-primary-rgb), 0.3);
310        }}
311
312        /* Theme-aware link colors */
313        a.accent-link {{
314            color: var(--accent-primary);
315            transition: color 0.2s ease;
316        }}
317
318        a.accent-link:hover {{
319            color: var(--accent-secondary);
320        }}
321
322        /* Progress bars */
323        .progress-accent,
324        .progress-fill {{
325            background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
326        }}
327
328        /* Active nav items */
329        .mobile-nav-item.active,
330        .mobile-nav-link.active {{
331            background: rgba(var(--accent-primary-rgb), 0.12) !important;
332            color: var(--accent-primary) !important;
333        }}
334
335        .learn-sidebar-module.active {{
336            background: rgba(var(--accent-primary-rgb), 0.15) !important;
337            color: var(--accent-primary) !important;
338            border-left-color: var(--accent-primary) !important;
339        }}
340
341        /* Focus rings */
342        .focus-accent:focus,
343        input:focus,
344        textarea:focus {{
345            box-shadow: 0 0 0 3px rgba(var(--accent-primary-rgb), 0.3);
346            border-color: var(--accent-primary);
347        }}
348
349        /* Badge/pill accents */
350        .badge .dot {{
351            background: var(--accent-primary) !important;
352            box-shadow: 0 0 0 6px rgba(var(--accent-primary-rgb), 0.12) !important;
353        }}
354
355        /* Card hover accents */
356        .card:hover {{
357            border-color: rgba(var(--accent-secondary-rgb), 0.28) !important;
358        }}
359
360        .card:hover::before {{
361            background: linear-gradient(135deg, rgba(var(--accent-primary-rgb), 0.12), rgba(var(--accent-secondary-rgb), 0.12)) !important;
362        }}
363
364        /* Icon box accents */
365        .icon-box {{
366            background: rgba(var(--accent-primary-rgb), 0.15) !important;
367        }}
368
369        /* Step numbers */
370        .step-num {{
371            background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important;
372        }}
373
374        /* Code/logic accents */
375        .code.logic {{
376            color: var(--accent-secondary) !important;
377        }}
378        "#
379    )
380}