Skip to main content

logicaffeine_web/ui/
app.rs

1//! Root application component and global styles.
2//!
3//! This module defines the top-level Dioxus component that bootstraps the
4//! application, sets up global context providers, and injects CSS styles.
5//!
6//! # Context Providers
7//!
8//! The [`App`] component provides these global contexts:
9//! - [`LicenseState`] - License key validation and plan tiers
10//! - [`RegistryAuthState`] - GitHub authentication for package registry
11//!
12//! # Global Styles
13//!
14//! CSS custom properties (design tokens) are injected at the root level,
15//! making them available throughout the component tree via `var(--name)`.
16//!
17//! # Routing
18//!
19//! The app uses Dioxus Router with routes defined in [`crate::ui::router::Route`].
20
21use dioxus::prelude::*;
22use crate::ui::router::Route;
23use crate::ui::state::{LicenseState, RegistryAuthState};
24use crate::ui::theme;
25use crate::ui::theme_state::{ThemeState, theme_css};
26
27/// Global CSS including design tokens, reset styles, and common component styles.
28const GLOBAL_STYLE: &str = r#"
29:root {
30    /* Primary colors */
31    --color-primary-blue: #667eea;
32    --color-primary-purple: #764ba2;
33    --color-accent-blue: #60a5fa;
34    --color-accent-purple: #a78bfa;
35
36    /* Semantic colors */
37    --color-success: #4ade80;
38    --color-warning: #f59e0b;
39    --color-error: #e06c75;
40    --color-info: #60a5fa;
41
42    /* Text colors - MUCH lighter for readability */
43    --text-primary: #f5f5f5;
44    --text-secondary: #d0d0d0;
45    --text-tertiary: #b8b8b8;
46    --text-muted: #c0c0c0;
47    --text-placeholder: #a0a0a0;
48
49    /* Font sizes - minimum 14px for readability */
50    --font-display-xl: 66px;
51    --font-display-lg: 50px;
52    --font-display-md: 34px;
53    --font-heading-lg: 26px;
54    --font-heading-md: 22px;
55    --font-heading-sm: 20px;
56    --font-body-lg: 18px;
57    --font-body-md: 16px;
58    --font-body-sm: 15px;
59    --font-caption-lg: 15px;
60    --font-caption-md: 14px;
61    --font-caption-sm: 14px;
62
63    /* Font families */
64    --font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
65    --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
66
67    /* Spacing */
68    --spacing-xs: 4px;
69    --spacing-sm: 8px;
70    --spacing-md: 12px;
71    --spacing-lg: 16px;
72    --spacing-xl: 24px;
73    --spacing-xxl: 32px;
74
75    /* Border radius */
76    --radius-sm: 4px;
77    --radius-md: 8px;
78    --radius-lg: 12px;
79    --radius-xl: 16px;
80    --radius-full: 9999px;
81}
82
83* {
84    margin: 0;
85    padding: 0;
86    box-sizing: border-box;
87}
88
89html, body {
90    height: 100%;
91    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
92    color: var(--text-primary);
93    font-family: var(--font-sans);
94    font-size: var(--font-body-lg);
95    overflow-x: hidden;
96}
97
98#main {
99    min-height: 100vh;
100}
101
102a {
103    color: inherit;
104    text-decoration: none;
105}
106
107.chat-area {
108    flex: 1;
109    overflow-y: auto;
110    padding: 30px;
111    display: flex;
112    flex-direction: column;
113    gap: var(--spacing-lg);
114}
115
116.message {
117    max-width: 75%;
118    padding: 14px 20px;
119    border-radius: var(--radius-xl);
120    line-height: 1.6;
121    animation: fadeIn 0.3s ease;
122}
123
124@keyframes fadeIn {
125    from { opacity: 0; transform: translateY(10px); }
126    to { opacity: 1; transform: translateY(0); }
127}
128
129.message.user {
130    align-self: flex-end;
131    background: linear-gradient(135deg, var(--color-primary-blue) 0%, var(--color-primary-purple) 100%);
132    color: white;
133    border-bottom-right-radius: var(--radius-sm);
134    box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
135}
136
137.message.system {
138    align-self: flex-start;
139    background: rgba(255, 255, 255, 0.08);
140    border: 1px solid rgba(255, 255, 255, 0.15);
141    border-bottom-left-radius: var(--radius-sm);
142    font-family: var(--font-mono);
143    font-size: var(--font-heading-sm);
144    color: var(--accent-primary);
145    text-shadow: 0 0 20px var(--accent-glow);
146}
147
148.message.error {
149    align-self: flex-start;
150    background: linear-gradient(135deg, #ff6b6b 0%, #c92a2a 100%);
151    color: white;
152    border-bottom-left-radius: var(--radius-sm);
153    font-style: italic;
154    box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3);
155}
156
157.input-area {
158    background: rgba(0, 0, 0, 0.4);
159    backdrop-filter: blur(10px);
160    padding: 20px 30px;
161    border-top: 1px solid rgba(255, 255, 255, 0.1);
162}
163
164.input-row {
165    display: flex;
166    gap: var(--spacing-md);
167    align-items: center;
168}
169
170.input-row input {
171    flex: 1;
172    background: rgba(255, 255, 255, 0.08);
173    border: 1px solid rgba(255, 255, 255, 0.2);
174    border-radius: var(--radius-lg);
175    padding: 14px 20px;
176    font-size: var(--font-body-lg);
177    color: white;
178    outline: none;
179    transition: all 0.2s ease;
180}
181
182.input-row input::placeholder {
183    color: var(--text-placeholder);
184}
185
186.input-row input:focus {
187    border-color: var(--color-primary-blue);
188    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
189}
190
191.input-row button {
192    background: linear-gradient(135deg, var(--color-primary-blue) 0%, var(--color-primary-purple) 100%);
193    border: none;
194    border-radius: var(--radius-lg);
195    padding: 14px 28px;
196    font-size: var(--font-body-lg);
197    font-weight: 600;
198    color: white;
199    cursor: pointer;
200    transition: all 0.2s ease;
201    box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
202}
203
204.input-row button:hover {
205    transform: translateY(-2px);
206    box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
207}
208
209.input-row button:active {
210    transform: translateY(0);
211}
212
213/* Interactive reveal section */
214.reveal-section {
215    margin-top: var(--spacing-lg);
216    padding-top: var(--spacing-lg);
217    border-top: 1px solid rgba(255,255,255,0.06);
218}
219
220.reveal-buttons {
221    display: flex;
222    gap: var(--spacing-md);
223    flex-wrap: wrap;
224    margin-bottom: var(--spacing-lg);
225}
226
227.reveal-btn {
228    padding: 10px 16px;
229    border-radius: var(--radius-md);
230    border: 1px solid rgba(255,255,255,0.15);
231    background: rgba(255,255,255,0.05);
232    color: var(--text-secondary);
233    font-size: var(--font-body-sm);
234    font-weight: 500;
235    cursor: pointer;
236    transition: all 0.2s ease;
237    display: inline-flex;
238    align-items: center;
239    gap: 6px;
240}
241
242.reveal-btn:hover {
243    background: rgba(255,255,255,0.10);
244    color: var(--text-primary);
245}
246
247.reveal-btn.active {
248    background: linear-gradient(135deg, rgba(var(--accent-primary-rgb),0.2), rgba(var(--accent-secondary-rgb),0.2));
249    border-color: rgba(var(--accent-secondary-rgb),0.4);
250    color: var(--text-primary);
251}
252
253.revealed-content {
254    padding: var(--spacing-lg);
255    background: rgba(255,255,255,0.03);
256    border: 1px solid rgba(255,255,255,0.08);
257    border-radius: var(--radius-lg);
258    margin-top: var(--spacing-md);
259    animation: fadeIn 0.2s ease;
260}
261
262.revealed-label {
263    font-size: var(--font-caption-sm);
264    font-weight: 600;
265    text-transform: uppercase;
266    letter-spacing: 0.5px;
267    color: var(--text-tertiary);
268    margin-bottom: var(--spacing-sm);
269}
270
271.revealed-logic {
272    font-family: var(--font-mono);
273    font-size: var(--font-heading-sm);
274    color: var(--accent-primary);
275    padding: var(--spacing-md);
276    background: rgba(var(--accent-primary-rgb), 0.08);
277    border-radius: var(--radius-md);
278    margin: var(--spacing-md) 0;
279}
280
281/* Socratic hint box */
282.socratic-hint-box {
283    margin-top: var(--spacing-lg);
284    padding: var(--spacing-lg);
285    background: linear-gradient(135deg, rgba(var(--accent-secondary-rgb),0.08), rgba(var(--accent-primary-rgb),0.08));
286    border: 1px solid rgba(var(--accent-secondary-rgb),0.2);
287    border-radius: var(--radius-lg);
288    border-left: 4px solid var(--accent-secondary);
289}
290
291.hint-header {
292    display: flex;
293    align-items: center;
294    gap: var(--spacing-sm);
295    margin-bottom: var(--spacing-sm);
296    font-size: var(--font-caption-md);
297    font-weight: 600;
298    color: var(--accent-secondary);
299}
300
301.hint-text {
302    color: var(--text-secondary);
303    line-height: 1.6;
304}
305
306/* Multiple choice options */
307.multiple-choice-options {
308    display: flex;
309    flex-direction: column;
310    gap: var(--spacing-sm);
311    margin: var(--spacing-lg) 0;
312}
313
314.multiple-choice-options .reveal-btn {
315    width: 100%;
316    text-align: left;
317    padding: var(--spacing-md) var(--spacing-lg);
318    font-family: var(--font-mono);
319}
320
321.multiple-choice-options .reveal-btn.correct {
322    background: rgba(74, 222, 128, 0.15);
323    border-color: var(--color-success);
324}
325
326.multiple-choice-options .reveal-btn.incorrect {
327    background: rgba(248, 113, 113, 0.15);
328    border-color: var(--color-error);
329}
330
331/* Progress indicator - shows exercise completion within a module */
332.exercise-progress {
333    display: flex;
334    align-items: center;
335    gap: var(--spacing-sm);
336    font-size: var(--font-caption-md);
337    color: var(--text-tertiary);
338    margin-bottom: var(--spacing-md);
339}
340
341.progress-bar {
342    flex: 1;
343    height: 4px;
344    background: rgba(255,255,255,0.1);
345    border-radius: 2px;
346    overflow: hidden;
347}
348
349.progress-fill {
350    height: 100%;
351    background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
352    border-radius: 2px;
353    transition: width 0.3s ease;
354}
355
356.practice-score {
357    font-weight: 600;
358    color: var(--color-success);
359}
360
361/* Exercise mode badges */
362.exercise-mode-badge {
363    display: inline-flex;
364    align-items: center;
365    gap: 4px;
366    padding: 4px 10px;
367    border-radius: var(--radius-full);
368    font-size: var(--font-caption-sm);
369    font-weight: 600;
370    text-transform: uppercase;
371    letter-spacing: 0.5px;
372    margin-right: var(--spacing-md);
373}
374
375.exercise-mode-badge.test {
376    background: rgba(251, 191, 36, 0.15);
377    color: #fbbf24;
378}
379
380.exercise-mode-badge.practice {
381    background: rgba(74, 222, 128, 0.15);
382    color: var(--color-success);
383}
384"#;
385
386/// Root application component.
387///
388/// Sets up global context providers for license, registry authentication, and theme,
389/// triggers license revalidation on mount, and renders the router.
390///
391/// # Context Providers
392///
393/// - [`LicenseState`] - Manages subscription validation
394/// - [`RegistryAuthState`] - Manages GitHub OAuth for package registry
395/// - [`ThemeState`] - Manages theme selection with localStorage persistence
396pub fn App() -> Element {
397    let license_state = use_context_provider(LicenseState::new);
398    let _registry_auth = use_context_provider(RegistryAuthState::new);
399    let theme_state = use_context_provider(ThemeState::new);
400
401    use_effect(move || {
402        let mut license_state = license_state.clone();
403        spawn(async move {
404            if license_state.has_license() && license_state.needs_revalidation() {
405                license_state.validate().await;
406            }
407        });
408    });
409
410    // Generate dynamic theme CSS
411    let current_theme = theme_state.current();
412    let dynamic_theme_css = theme_css(current_theme);
413
414    rsx! {
415        style { "{GLOBAL_STYLE}" }
416        style { "{dynamic_theme_css}" }
417        div {
418            "data-theme": "{current_theme.data_attr()}",
419            id: "app-root",
420            // No explicit SuspenseBoundary: lazy route chunks suspend against
421            // the implicit root boundary (the wasm-split harness shape). The
422            // CSR takeover that swaps out the prerendered copy is DOM-driven —
423            // see the MutationObserver in index.html.
424            Router::<Route> {}
425        }
426    }
427}