violit 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
violit/app.py ADDED
@@ -0,0 +1,1984 @@
1
+ """
2
+ Violit - A Streamlit-like framework with reactive components
3
+ Refactored with modular widget mixins
4
+ """
5
+
6
+ import uuid
7
+ import sys
8
+ import argparse
9
+ import threading
10
+ import time
11
+ import json
12
+ import warnings
13
+ import secrets
14
+ import hmac
15
+ import hashlib
16
+ from typing import Any, Callable, Dict, List, Optional, Set, Union
17
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
18
+ from fastapi.responses import HTMLResponse
19
+ import inspect
20
+ import uvicorn
21
+ import webview
22
+ import pandas as pd
23
+ import os
24
+ import subprocess
25
+ from pathlib import Path
26
+ import plotly.graph_objects as go
27
+ import plotly.io as pio
28
+
29
+ from .context import session_ctx, rendering_ctx, fragment_ctx, app_instance_ref, layout_ctx
30
+ from .theme import Theme
31
+ from .component import Component
32
+ from .engine import LiteEngine, WsEngine
33
+ from .state import State, get_session_store
34
+ from .broadcast import Broadcaster
35
+
36
+ # Import all widget mixins
37
+ from .widgets import (
38
+ TextWidgetsMixin,
39
+ InputWidgetsMixin,
40
+ DataWidgetsMixin,
41
+ ChartWidgetsMixin,
42
+ MediaWidgetsMixin,
43
+ LayoutWidgetsMixin,
44
+ StatusWidgetsMixin,
45
+ FormWidgetsMixin,
46
+ ChatWidgetsMixin,
47
+ CardWidgetsMixin,
48
+ ListWidgetsMixin,
49
+ )
50
+
51
+ class FileWatcher:
52
+ """Simple file watcher to detect changes"""
53
+ def __init__(self, debug_mode=False):
54
+ self.mtimes = {}
55
+ self.initialized = False
56
+ self.ignore_dirs = {'.git', '__pycache__', 'venv', '.venv', 'env', 'node_modules', '.idea', '.vscode'}
57
+ self.debug_mode = debug_mode
58
+ self.scan()
59
+
60
+ def _is_ignored(self, path: Path):
61
+ for part in path.parts:
62
+ if part in self.ignore_dirs:
63
+ return True
64
+ return False
65
+
66
+ def scan(self):
67
+ """Scan current directory for py files and their mtimes"""
68
+ for p in Path(".").rglob("*.py"):
69
+ if self._is_ignored(p): continue
70
+ try:
71
+ # Use absolute path to ensure consistency
72
+ abs_p = p.resolve()
73
+ self.mtimes[abs_p] = abs_p.stat().st_mtime
74
+ except OSError:
75
+ pass
76
+
77
+ def check(self):
78
+ """Check if any file changed"""
79
+ # Re-scan to detect new files or modifications
80
+ # Optimization: Scan is cheap for small projects, but we could optimize.
81
+ # For now, simplistic scan is fine.
82
+ changed = False
83
+ for p in list(Path(".").rglob("*.py")):
84
+ if self._is_ignored(p): continue
85
+ try:
86
+ abs_p = p.resolve()
87
+ mtime = abs_p.stat().st_mtime
88
+
89
+ if abs_p not in self.mtimes:
90
+ self.mtimes[abs_p] = mtime
91
+ # Only print if this isn't the first check (initialized)
92
+ # Use sys.stdout.write for immediate output
93
+ if self.initialized:
94
+ if self.debug_mode:
95
+ print(f"\n[HOT RELOAD] New file detected: {p}", flush=True)
96
+ changed = True
97
+ elif mtime > self.mtimes[abs_p]:
98
+ self.mtimes[abs_p] = mtime
99
+ if self.initialized:
100
+ if self.debug_mode:
101
+ print(f"\n[HOT RELOAD] File changed: {p}", flush=True)
102
+ changed = True
103
+ except OSError:
104
+ pass
105
+
106
+ self.initialized = True
107
+ return changed
108
+
109
+
110
+ class SidebarProxy:
111
+ """Proxy for sidebar context"""
112
+ def __init__(self, app):
113
+ self.app = app
114
+ def __enter__(self):
115
+ self.token = layout_ctx.set("sidebar")
116
+ return self
117
+ def __exit__(self, exc_type, exc_val, exc_tb):
118
+ layout_ctx.reset(self.token)
119
+ def __getattr__(self, name):
120
+ attr = getattr(self.app, name)
121
+ if callable(attr):
122
+ def wrapper(*args, **kwargs):
123
+ token = layout_ctx.set("sidebar")
124
+ try:
125
+ return attr(*args, **kwargs)
126
+ finally:
127
+ layout_ctx.reset(token)
128
+ return wrapper
129
+ return attr
130
+
131
+
132
+ class Page:
133
+ """Represents a page in multi-page app"""
134
+ def __init__(self, entry_point, title=None, icon=None, url_path=None):
135
+ self.entry_point = entry_point
136
+ self.title = title or entry_point.__name__.replace("_", " ").title()
137
+ self.icon = icon
138
+ self.url_path = url_path or self.title.lower().replace(" ", "-")
139
+ self.key = f"page_{self.url_path}"
140
+
141
+ def run(self):
142
+ self.entry_point()
143
+
144
+
145
+ class App(
146
+ TextWidgetsMixin,
147
+ InputWidgetsMixin,
148
+ DataWidgetsMixin,
149
+ ChartWidgetsMixin,
150
+ MediaWidgetsMixin,
151
+ LayoutWidgetsMixin,
152
+ StatusWidgetsMixin,
153
+ FormWidgetsMixin,
154
+ ChatWidgetsMixin,
155
+ CardWidgetsMixin,
156
+ ListWidgetsMixin,
157
+ ):
158
+ """Main Violit App class"""
159
+
160
+ def __init__(self, mode='ws', title="Violit App", theme='light', allow_selection=True, animation_mode='soft', icon=None, width=1024, height=768, on_top=True, container_width='800px'):
161
+ self.mode = mode
162
+ self.app_title = title # Renamed to avoid conflict with title() method
163
+ self.theme_manager = Theme(theme)
164
+ self.fastapi = FastAPI()
165
+
166
+ # Debug mode: Check for --debug flag
167
+ self.debug_mode = '--debug' in sys.argv
168
+
169
+ # Native mode security: Read from environment
170
+ self.native_token = os.environ.get("VIOLIT_NATIVE_TOKEN")
171
+ self.is_native_mode = bool(os.environ.get("VIOLIT_NATIVE_MODE"))
172
+
173
+ # CSRF protection (disabled in native mode for local app security)
174
+ self.csrf_enabled = not self.is_native_mode
175
+ self.csrf_secret = secrets.token_urlsafe(32)
176
+
177
+ if self.debug_mode:
178
+ if self.is_native_mode and self.native_token:
179
+ print(f"[SECURITY] Native mode detected - Token: {self.native_token[:20]}... - CSRF disabled")
180
+ elif self.is_native_mode:
181
+ print("[WARNING] Native mode flag set but no token found!")
182
+
183
+ # Icon Setup
184
+ self.app_icon = icon
185
+ if self.app_icon is None:
186
+ # Set default icon path
187
+ base_path = os.path.dirname(os.path.abspath(__file__))
188
+ default_icon = os.path.join(base_path, "assets", "violit_icon_sol.ico")
189
+ if os.path.exists(default_icon):
190
+ self.app_icon = default_icon
191
+
192
+ self.width = width
193
+ self.height = height
194
+ self.on_top = on_top
195
+
196
+ # Container width: numeric (px), percentage (%), or 'none' (full width)
197
+ if container_width == 'none' or container_width == '100%':
198
+ self.container_max_width = 'none'
199
+ elif isinstance(container_width, int):
200
+ self.container_max_width = f'{container_width}px'
201
+ else:
202
+ self.container_max_width = container_width
203
+
204
+
205
+ # Static definitions
206
+ self.static_builders: Dict[str, Callable] = {}
207
+ self.static_order: List[str] = []
208
+ self.static_sidebar_order: List[str] = []
209
+ self.static_actions: Dict[str, Callable] = {}
210
+ self.static_fragments: Dict[str, Callable] = {}
211
+ self.static_fragment_components: Dict[str, List[Any]] = {}
212
+
213
+ self.state_count = 0
214
+ self._fragments: Dict[str, Callable] = {}
215
+
216
+ # Broadcasting System
217
+ self.broadcaster = Broadcaster(self)
218
+ self._fragment_count = 0
219
+
220
+ # Internal theme/settings state
221
+ self._theme_state = self.state(self.theme_manager.mode)
222
+ self._selection_state = self.state(allow_selection)
223
+ self._animation_state = self.state(animation_mode)
224
+
225
+ self.ws_engine = WsEngine() if mode == 'ws' else None
226
+ self.lite_engine = LiteEngine() if mode == 'lite' else None
227
+ app_instance_ref[0] = self
228
+
229
+ # Register core fragments/updaters
230
+ self._theme_updater()
231
+ self._selection_updater()
232
+ self._animation_updater()
233
+
234
+ self._setup_routes()
235
+
236
+ @property
237
+ def sidebar(self):
238
+ """Access sidebar context"""
239
+ return SidebarProxy(self)
240
+
241
+ @property
242
+ def engine(self):
243
+ """Get current engine (WS or Lite)"""
244
+ return self.ws_engine if self.mode == 'ws' else self.lite_engine
245
+
246
+ def _generate_csrf_token(self, session_id: str) -> str:
247
+ """Generate CSRF token for session"""
248
+ if not session_id:
249
+ return ""
250
+ message = f"{session_id}:{self.csrf_secret}"
251
+ return hmac.new(
252
+ self.csrf_secret.encode(),
253
+ message.encode(),
254
+ hashlib.sha256
255
+ ).hexdigest()
256
+
257
+ def _verify_csrf_token(self, session_id: str, token: str) -> bool:
258
+ """Verify CSRF token"""
259
+ if not self.csrf_enabled:
260
+ return True
261
+ if not session_id or not token:
262
+ return False
263
+ expected = self._generate_csrf_token(session_id)
264
+ return hmac.compare_digest(expected, token)
265
+
266
+ def debug_print(self, *args, **kwargs):
267
+ """Print only in debug mode"""
268
+ if self.debug_mode:
269
+ print(*args, **kwargs)
270
+
271
+ def state(self, default_value, key=None) -> State:
272
+ """Create a reactive state variable"""
273
+ if key is None:
274
+ name = f"state_{self.state_count}"
275
+ self.state_count += 1
276
+ else:
277
+ name = key
278
+ return State(name, default_value)
279
+
280
+ def _get_next_cid(self, prefix: str) -> str:
281
+ """Generate next component ID"""
282
+ store = get_session_store()
283
+ cid = f"{prefix}_{store['component_count']}"
284
+ store['component_count'] += 1
285
+ return cid
286
+
287
+ def _register_component(self, cid: str, builder: Callable, action: Optional[Callable] = None):
288
+ """Register a component with builder and optional action"""
289
+ store = get_session_store()
290
+ sid = session_ctx.get()
291
+
292
+ store['builders'][cid] = builder
293
+ if action:
294
+ store['actions'][cid] = action
295
+
296
+ curr_frag = fragment_ctx.get()
297
+ l_ctx = layout_ctx.get()
298
+
299
+ if curr_frag:
300
+ # Inside a fragment
301
+ # IMPORTANT: Still respect sidebar context even inside fragments!
302
+ if l_ctx == "sidebar":
303
+ # Register to sidebar, not fragment
304
+ if sid is None:
305
+ self.static_builders[cid] = builder
306
+ if action: self.static_actions[cid] = action
307
+ if cid not in self.static_sidebar_order:
308
+ self.static_sidebar_order.append(cid)
309
+ else:
310
+ if action: store['actions'][cid] = action
311
+ store['sidebar_order'].append(cid)
312
+ else:
313
+ # Normal fragment component registration
314
+ if sid is None:
315
+ # Static nesting (e.g. inside columns/expander at top level)
316
+ if curr_frag not in self.static_fragment_components:
317
+ self.static_fragment_components[curr_frag] = []
318
+ self.static_fragment_components[curr_frag].append((cid, builder))
319
+ # Store action and builder for components inside fragments
320
+ if action:
321
+ self.static_actions[cid] = action
322
+ self.static_builders[cid] = builder
323
+ else:
324
+ # Dynamic Nesting (Runtime)
325
+ if curr_frag not in store['fragment_components']:
326
+ store['fragment_components'][curr_frag] = []
327
+ store['fragment_components'][curr_frag].append((cid, builder))
328
+ else:
329
+ if sid is None:
330
+ # Static Root Registration
331
+ self.static_builders[cid] = builder
332
+ if action: self.static_actions[cid] = action
333
+
334
+ if l_ctx == "sidebar":
335
+ if cid not in self.static_sidebar_order:
336
+ self.static_sidebar_order.append(cid)
337
+ else:
338
+ if cid not in self.static_order:
339
+ self.static_order.append(cid)
340
+ else:
341
+ # Dynamic Root Registration
342
+ if action: store['actions'][cid] = action
343
+
344
+ if l_ctx == "sidebar":
345
+ store['sidebar_order'].append(cid)
346
+ else:
347
+ store['order'].append(cid)
348
+
349
+ def simple_card(self, content_fn: Union[Callable, str, State]):
350
+ """Display content in a simple card
351
+
352
+ Accepts State object, callable, or string content
353
+ """
354
+ cid = self._get_next_cid("simple_card")
355
+ def builder():
356
+ token = rendering_ctx.set(cid)
357
+ # Handle State object, callable, or direct value
358
+ if isinstance(content_fn, State):
359
+ val = content_fn.value
360
+ elif callable(content_fn):
361
+ val = content_fn()
362
+ else:
363
+ val = content_fn
364
+
365
+ if val is None:
366
+ val = "_No data_"
367
+
368
+ rendering_ctx.reset(token)
369
+ return Component("div", id=cid, content=str(val), class_="card")
370
+ self._register_component(cid, builder)
371
+
372
+ def fragment(self, func: Callable) -> Callable:
373
+ """Create a reactive fragment (decorator)
374
+
375
+ .. deprecated::
376
+ This method is deprecated. Please use alternative patterns for managing reactive content.
377
+
378
+ Always returns a wrapper that registers on call.
379
+ Call the decorated function to register and render it.
380
+ """
381
+ warnings.warn(
382
+ "@app.fragment is deprecated and will be removed in a future version. "
383
+ "Please consider using alternative patterns.",
384
+ DeprecationWarning,
385
+ stacklevel=2
386
+ )
387
+
388
+ fid = f"fragment_{self._fragment_count}"
389
+ self._fragment_count += 1
390
+
391
+ # Track if already registered
392
+ registered = [False]
393
+
394
+ def fragment_builder():
395
+ token = fragment_ctx.set(fid)
396
+ render_token = rendering_ctx.set(fid)
397
+ store = get_session_store()
398
+ store['fragment_components'][fid] = []
399
+
400
+ # Execute fragment logic
401
+ func()
402
+
403
+ # Render children
404
+ htmls = []
405
+ for cid, b in store['fragment_components'][fid]:
406
+ htmls.append(b().render())
407
+
408
+ fragment_ctx.reset(token)
409
+ rendering_ctx.reset(render_token)
410
+
411
+ inner = f'<div id="{fid}" class="fragment">{" ".join(htmls)}</div>'
412
+ return Component("div", id=f"{fid}_wrapper", content=inner)
413
+
414
+ # Store builder
415
+ self.static_builders[fid] = fragment_builder
416
+ self.static_fragments[fid] = func
417
+
418
+ def wrapper():
419
+ """Wrapper that registers fragment on first call"""
420
+ if registered[0]:
421
+ return
422
+ registered[0] = True
423
+
424
+ sid = session_ctx.get()
425
+ if sid is None:
426
+ # Static context: add to static_order
427
+ if fid not in self.static_order:
428
+ self.static_order.append(fid)
429
+ else:
430
+ # Dynamic context: add to dynamic order
431
+ self._register_component(fid, fragment_builder)
432
+
433
+ return wrapper
434
+
435
+ def reactivity(self, func: Optional[Callable] = None):
436
+ """Create a reactive scope for complex control flow
437
+
438
+ Use as context manager for reactive if/for loops
439
+ """
440
+ if func is not None:
441
+ # Decorator mode - deprecated
442
+ warnings.warn(
443
+ "@app.reactivity decorator is deprecated and will be removed in a future version. "
444
+ "Please use 'with app.reactivity():' context manager instead.",
445
+ DeprecationWarning,
446
+ stacklevel=2
447
+ )
448
+ return self.fragment(func)
449
+
450
+ # Context manager mode
451
+ class ReactivityContext:
452
+ def __init__(ctx_self, app):
453
+ ctx_self.app = app
454
+ ctx_self.fid = None
455
+ ctx_self.fragment_token = None
456
+ # DON'T create new rendering_ctx - keep parent's!
457
+
458
+ def __enter__(ctx_self):
459
+ # Create a temporary fragment scope for component collection
460
+ ctx_self.fid = f"reactivity_{self._fragment_count}"
461
+ self._fragment_count += 1
462
+
463
+ # Set fragment context only (state access registers with parent)
464
+ ctx_self.fragment_token = fragment_ctx.set(ctx_self.fid)
465
+
466
+ store = get_session_store()
467
+ store['fragment_components'][ctx_self.fid] = []
468
+ return ctx_self
469
+
470
+ def __exit__(ctx_self, exc_type, exc_val, exc_tb):
471
+ store = get_session_store()
472
+
473
+ # Build the fragment
474
+ def reactivity_builder():
475
+ htmls = []
476
+ for cid, b in store['fragment_components'][ctx_self.fid]:
477
+ htmls.append(b().render())
478
+ inner = f'<div id="{ctx_self.fid}" class="fragment">{" ".join(htmls)}</div>'
479
+ return Component("div", id=f"{ctx_self.fid}_wrapper", content=inner)
480
+
481
+ fragment_ctx.reset(ctx_self.fragment_token)
482
+
483
+ # Register the reactivity scope as a component
484
+ self._register_component(ctx_self.fid, reactivity_builder)
485
+
486
+ return ReactivityContext(self)
487
+
488
+ def _render_all(self):
489
+ """Render all components"""
490
+ store = get_session_store()
491
+
492
+ main_html = []
493
+ sidebar_html = []
494
+
495
+ def render_cids(cids, target_list):
496
+ for cid in cids:
497
+ builder = store['builders'].get(cid) or self.static_builders.get(cid)
498
+ if builder:
499
+ target_list.append(builder().render())
500
+
501
+ # Static Components
502
+ render_cids(self.static_order, main_html)
503
+ render_cids(self.static_sidebar_order, sidebar_html)
504
+
505
+ # Dynamic Components
506
+ render_cids(store['order'], main_html)
507
+ render_cids(store['sidebar_order'], sidebar_html)
508
+
509
+ return "".join(main_html), "".join(sidebar_html)
510
+
511
+ def _get_dirty_rendered(self):
512
+ """Get components that need updating"""
513
+ store = get_session_store()
514
+ dirty_states = store.get('dirty_states', set())
515
+ aff = set()
516
+ for s in dirty_states: aff.update(store['tracker'].get_dirty_components(s))
517
+ store['dirty_states'] = set()
518
+
519
+ res = []
520
+ for cid in aff:
521
+ builder = store['builders'].get(cid) or self.static_builders.get(cid)
522
+ if builder:
523
+ res.append(builder())
524
+ return res
525
+
526
+ # Theme and settings methods
527
+ def set_theme(self, p):
528
+ """Set theme preset"""
529
+ import time
530
+ store = get_session_store()
531
+ store['theme'].set_preset(p)
532
+ if self._theme_state:
533
+ # Use timestamp to force dirty even if same theme selected twice
534
+ self._theme_state.set(f"{p}_{time.time()}")
535
+
536
+ def set_selection_mode(self, enabled: bool):
537
+ """Enable/disable text selection"""
538
+ if self._selection_state:
539
+ self._selection_state.set(enabled)
540
+
541
+ def set_animation_mode(self, mode: str):
542
+ """Set animation mode ('soft' or 'hard')"""
543
+ if self._animation_state:
544
+ self._animation_state.set(mode)
545
+
546
+ def set_primary_color(self, c):
547
+ """Set primary theme color"""
548
+ store = get_session_store()
549
+ store['theme'].set_color('primary', c)
550
+ if self._theme_state:
551
+ self._theme_state.set(str(time.time()))
552
+
553
+ def _selection_updater(self):
554
+ """Update selection mode"""
555
+ cid = "__selection_updater__"
556
+ def builder():
557
+ token = rendering_ctx.set(cid)
558
+ enabled = self._selection_state.value
559
+ rendering_ctx.reset(token)
560
+
561
+ action = "remove" if enabled else "add"
562
+ script = f"<script>document.body.classList.{action}('no-select');</script>"
563
+ return Component("div", id=cid, style="display:none", content=script)
564
+ self._register_component(cid, builder)
565
+
566
+ def _patch_webview_icon(self):
567
+ """Monkey-patch pywebview's WinForms BrowserForm to use custom icon"""
568
+ if os.name != 'nt' or not self.app_icon:
569
+ return
570
+
571
+ try:
572
+ from webview.platforms import winforms
573
+
574
+ # Store reference to icon path for closure
575
+ icon_path = self.app_icon
576
+
577
+ # Check if already patched
578
+ if hasattr(winforms.BrowserView.BrowserForm, '_violit_patched'):
579
+ return
580
+
581
+ # Get original __init__
582
+ original_init = winforms.BrowserView.BrowserForm.__init__
583
+
584
+ def patched_init(self, window, cache_dir):
585
+ """Patched __init__ that sets custom icon after original init"""
586
+ original_init(self, window, cache_dir)
587
+
588
+ try:
589
+ from System.Drawing import Icon as DotNetIcon
590
+ if os.path.exists(icon_path):
591
+ self.Icon = DotNetIcon(icon_path)
592
+ except Exception:
593
+ pass # Silently fail if icon can't be set
594
+
595
+ # Apply patch
596
+ winforms.BrowserView.BrowserForm.__init__ = patched_init
597
+ winforms.BrowserView.BrowserForm._violit_patched = True
598
+
599
+ except Exception:
600
+ pass # If patching fails, continue without custom icon
601
+
602
+ def _animation_updater(self):
603
+ """Update animation mode"""
604
+ cid = "__animation_updater__"
605
+ def builder():
606
+ token = rendering_ctx.set(cid)
607
+ mode = self._animation_state.value
608
+ rendering_ctx.reset(token)
609
+
610
+ script = f"<script>document.body.classList.remove('anim-soft', 'anim-hard'); document.body.classList.add('anim-{mode}');</script>"
611
+ return Component("div", id=cid, style="display:none", content=script)
612
+ self._register_component(cid, builder)
613
+
614
+ def _theme_updater(self):
615
+ """Update theme"""
616
+ cid = "__theme_updater__"
617
+ def builder():
618
+ token = rendering_ctx.set(cid)
619
+ _ = self._theme_state.value
620
+ rendering_ctx.reset(token)
621
+
622
+ store = get_session_store()
623
+ t = store['theme']
624
+ vars_str = t.to_css_vars()
625
+ cls = t.theme_class
626
+
627
+ script_content = f'''
628
+ <script>
629
+ (function() {{
630
+ document.documentElement.className = '{cls}';
631
+ const root = document.documentElement;
632
+ const vars = `{vars_str}`.split('\\n');
633
+ vars.forEach(v => {{
634
+ const parts = v.split(':');
635
+ if(parts.length === 2) {{
636
+ const key = parts[0].trim();
637
+ const val = parts[1].replace(';', '').trim();
638
+ root.style.setProperty(key, val);
639
+ }}
640
+ }});
641
+
642
+ // Update Extra CSS
643
+ let extraStyle = document.getElementById('theme-extra');
644
+ if (!extraStyle) {{
645
+ extraStyle = document.createElement('style');
646
+ extraStyle.id = 'theme-extra';
647
+ document.head.appendChild(extraStyle);
648
+ }}
649
+ extraStyle.textContent = `{t.extra_css}`;
650
+ }})();
651
+ </script>
652
+ '''
653
+ return Component("div", id=cid, style="display:none", content=script_content)
654
+ self._register_component(cid, builder)
655
+
656
+ def navigation(self, pages: List[Any], position="sidebar", auto_run=True):
657
+ """Create multi-page navigation"""
658
+ # Normalize pages
659
+ final_pages = []
660
+ for p in pages:
661
+ if isinstance(p, Page): final_pages.append(p)
662
+ elif callable(p): final_pages.append(Page(p))
663
+
664
+ if not final_pages: return None
665
+
666
+ # Singleton state for navigation
667
+ current_page_key_state = self.state(final_pages[0].key, key="__nav_selection__")
668
+
669
+ # Navigation Menu Builder
670
+ cid = self._get_next_cid("nav_menu")
671
+ nav_cid = cid # Capture for use in nav_action closure
672
+ def nav_builder():
673
+ token = rendering_ctx.set(cid)
674
+ curr = current_page_key_state.value
675
+
676
+ items = []
677
+ for p in final_pages:
678
+ is_active = p.key == curr
679
+ click_attr = ""
680
+ if self.mode == 'lite':
681
+ # Lite mode: update hash and HTMX post
682
+ page_hash = p.key.replace("page_", "")
683
+ click_attr = f'onclick="window.location.hash = \'{page_hash}\'" hx-post="/action/{cid}" hx-vals=\'{{"value": "{p.key}"}}\' hx-target="#{cid}" hx-swap="outerHTML"'
684
+ else:
685
+ # WebSocket mode (including native)
686
+ click_attr = f'onclick="window.sendAction(\'{cid}\', \'{p.key}\')"'
687
+
688
+ # Styling for active/inactive nav items
689
+ if is_active:
690
+ style = "width: 100%; justify-content: start; --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary);"
691
+ variant = "primary"
692
+ else:
693
+ style = "width: 100%; justify-content: start; --sl-color-neutral-700: var(--sl-text);"
694
+ variant = "text"
695
+
696
+ icon_html = f'<sl-icon name="{p.icon}" slot="prefix"></sl-icon> ' if p.icon else ""
697
+ items.append(f'<sl-button style="{style}" variant="{variant}" {click_attr}>{icon_html}{p.title}</sl-button>')
698
+
699
+ rendering_ctx.reset(token)
700
+ return Component("div", id=cid, content="<br>".join(items), class_="nav-container")
701
+
702
+ def nav_action(key):
703
+ current_page_key_state.set(key)
704
+
705
+ # Register Nav Component
706
+ if position == "sidebar":
707
+ token = layout_ctx.set("sidebar")
708
+ try:
709
+ self._register_component(cid, nav_builder, action=nav_action)
710
+ finally:
711
+ layout_ctx.reset(token)
712
+ else:
713
+ self._register_component(cid, nav_builder, action=nav_action)
714
+
715
+ # Return the runner wrapper
716
+ current_key = current_page_key_state.value
717
+
718
+ class PageRunner:
719
+ def __init__(self, app, page_state, pages_map):
720
+ self.app = app
721
+ self.state = page_state
722
+ self.pages_map = pages_map
723
+
724
+ def run(self):
725
+ # Progressive Mode: Register page renderer as a regular component
726
+ # The builder function reads the navigation state, enabling reactivity
727
+ # WITHOUT wrapping the entire page function in a fragment
728
+ cid = self.app._get_next_cid("page_renderer")
729
+
730
+ def page_builder():
731
+ # Read the navigation state here - this creates the dependency
732
+ token = rendering_ctx.set(cid)
733
+ try:
734
+ key = self.state.value
735
+
736
+ # Execute the current page function
737
+ p = self.pages_map.get(key)
738
+ if p:
739
+ # Collect components from the page
740
+ store = get_session_store()
741
+ # Clear previous dynamic order for this page render
742
+ previous_order = store['order'].copy()
743
+ previous_fragments = {k: v.copy() for k, v in store['fragment_components'].items()}
744
+ store['order'] = []
745
+ store['fragment_components'] = {} # Clear fragments to prevent duplicates
746
+
747
+ try:
748
+ # Execute page function
749
+ # CRITICAL: Execute inside rendering_ctx so any state access registers dependency
750
+ p.entry_point()
751
+
752
+ # Render all components from this page
753
+ htmls = []
754
+ for page_cid in store['order']:
755
+ builder = store['builders'].get(page_cid) or self.app.static_builders.get(page_cid)
756
+ if builder:
757
+ htmls.append(builder().render())
758
+
759
+ content = '\n'.join(htmls)
760
+ return Component("div", id=cid, content=content, class_="page-container")
761
+ finally:
762
+ # Restore previous state (always, even on exception)
763
+ store['order'] = previous_order
764
+ store['fragment_components'] = previous_fragments
765
+
766
+ return Component("div", id=cid, content="", class_="page-container")
767
+ finally:
768
+ rendering_ctx.reset(token)
769
+
770
+ # Register the page renderer as a regular component
771
+ self.app._register_component(cid, page_builder)
772
+
773
+
774
+ page_runner = PageRunner(self, current_page_key_state, {p.key: p for p in final_pages})
775
+
776
+ # Auto-run if enabled
777
+ if auto_run:
778
+ page_runner.run()
779
+
780
+ return page_runner
781
+
782
+ # --- Routes ---
783
+ def _setup_routes(self):
784
+ """Setup FastAPI routes"""
785
+ @self.fastapi.middleware("http")
786
+ async def mw(request: Request, call_next):
787
+ # Native mode security: Block web browser access
788
+ if self.native_token is not None:
789
+ token_from_request = request.query_params.get("_native_token")
790
+ token_from_cookie = request.cookies.get("_native_token")
791
+ user_agent = request.headers.get("user-agent", "")
792
+
793
+ # Debug logging for security check
794
+ self.debug_print(f"[NATIVE SECURITY CHECK]")
795
+ self.debug_print(f" Token from request: {token_from_request[:20] if token_from_request else None}...")
796
+ self.debug_print(f" Token from cookie: {token_from_cookie[:20] if token_from_cookie else None}...")
797
+ self.debug_print(f" Expected token: {self.native_token[:20]}...")
798
+ self.debug_print(f" User-Agent: {user_agent}")
799
+
800
+ # Verify token
801
+ is_valid_token = (token_from_request == self.native_token or token_from_cookie == self.native_token)
802
+
803
+ # Block if token is invalid
804
+ if not is_valid_token:
805
+ from fastapi.responses import HTMLResponse
806
+ self.debug_print(f" [X] ACCESS DENIED - Invalid or missing token")
807
+ return HTMLResponse(
808
+ content="""
809
+ <html>
810
+ <head><title>Access Denied</title></head>
811
+ <body style="font-family: system-ui; padding: 2rem; text-align: center;">
812
+ <h1>[LOCK] Access Denied</h1>
813
+ <p>This application is running in <strong>native desktop mode</strong>.</p>
814
+ <p>Web browser access is disabled for security reasons.</p>
815
+ <hr style="margin: 2rem auto; width: 50%;">
816
+ <small>If you are the owner, please use the desktop application.</small>
817
+ </body>
818
+ </html>
819
+ """,
820
+ status_code=403
821
+ )
822
+ else:
823
+ self.debug_print(f" [OK] ACCESS GRANTED - Valid token")
824
+
825
+ # Session ID: get from cookie (all tabs share same session)
826
+ sid = request.cookies.get("ss_sid") or str(uuid.uuid4())
827
+
828
+ t = session_ctx.set(sid)
829
+ response = await call_next(request)
830
+ session_ctx.reset(t)
831
+
832
+ # Set cookie
833
+ is_https = request.url.scheme == "https"
834
+ response.set_cookie(
835
+ "ss_sid",
836
+ sid,
837
+ httponly=True,
838
+ secure=is_https,
839
+ samesite="lax"
840
+ )
841
+
842
+ # Set native token cookie
843
+ if self.native_token and not request.cookies.get("_native_token"):
844
+ response.set_cookie(
845
+ "_native_token",
846
+ self.native_token,
847
+ httponly=True,
848
+ secure=is_https,
849
+ samesite="strict"
850
+ )
851
+
852
+ return response
853
+
854
+ @self.fastapi.get("/")
855
+ async def index(request: Request):
856
+ # Note: _theme_state, _selection_state, _animation_state and their updaters
857
+ # are already initialized in __init__, no need to re-initialize here
858
+
859
+ main_c, sidebar_c = self._render_all()
860
+ store = get_session_store()
861
+ t = store['theme']
862
+
863
+ sidebar_style = "" if (sidebar_c or self.static_sidebar_order) else "display: none;"
864
+
865
+ # Generate CSRF token
866
+ # Get sid from context (set by middleware) instead of cookies (not set yet on first visit)
867
+ try:
868
+ sid = session_ctx.get()
869
+ except LookupError:
870
+ sid = request.cookies.get("ss_sid")
871
+
872
+ csrf_token = self._generate_csrf_token(sid) if sid and self.csrf_enabled else ""
873
+ csrf_script = f'<script>window._csrf_token = "{csrf_token}";</script>' if csrf_token else ""
874
+
875
+ if self.debug_mode:
876
+ print(f"[DEBUG] Session ID: {sid[:8] if sid else 'None'}...")
877
+ print(f"[DEBUG] CSRF enabled: {self.csrf_enabled}")
878
+ print(f"[DEBUG] CSRF token generated: {bool(csrf_token)}")
879
+
880
+ # Debug flag injection
881
+ debug_script = f'<script>window._debug_mode = {str(self.debug_mode).lower()};</script>'
882
+
883
+ html = HTML_TEMPLATE.replace("%CONTENT%", main_c).replace("%SIDEBAR_CONTENT%", sidebar_c).replace("%SIDEBAR_STYLE%", sidebar_style).replace("%MODE%", self.mode).replace("%TITLE%", self.app_title).replace("%THEME_CLASS%", t.theme_class).replace("%CSS_VARS%", t.to_css_vars()).replace("%SPLASH%", self._splash_html if self.show_splash else "").replace("%CONTAINER_MAX_WIDTH%", self.container_max_width).replace("%CSRF_SCRIPT%", csrf_script).replace("%DEBUG_SCRIPT%", debug_script)
884
+ return HTMLResponse(html)
885
+
886
+ @self.fastapi.post("/action/{cid}")
887
+ async def action(request: Request, cid: str):
888
+ # Session ID: get from cookie
889
+ sid = request.cookies.get("ss_sid")
890
+
891
+ # CSRF verification
892
+ if self.csrf_enabled:
893
+ f = await request.form()
894
+ csrf_token = f.get("_csrf_token") or request.headers.get("X-CSRF-Token")
895
+
896
+ if not csrf_token or not self._verify_csrf_token(sid, csrf_token):
897
+ from fastapi.responses import JSONResponse
898
+ return JSONResponse(
899
+ {"error": "Invalid CSRF token"},
900
+ status_code=403
901
+ )
902
+ else:
903
+ f = await request.form()
904
+
905
+ v = f.get("value")
906
+ store = get_session_store()
907
+ act = store['actions'].get(cid) or self.static_actions.get(cid)
908
+ if act:
909
+ if not callable(act):
910
+ # Debug: print what we got instead
911
+ self.debug_print(f"ERROR: Action for {cid} is not callable. Got: {type(act)} = {repr(act)}")
912
+ return HTMLResponse("")
913
+
914
+ store['eval_queue'] = []
915
+ act(v) if v is not None else act()
916
+
917
+ dirty = self._get_dirty_rendered()
918
+
919
+ # Separate clicked component from other updates
920
+ clicked_component = None
921
+ other_dirty = []
922
+ for c in dirty:
923
+ if c.id == cid:
924
+ clicked_component = c
925
+ else:
926
+ other_dirty.append(c)
927
+
928
+ # Re-render clicked component if not dirty
929
+ if clicked_component is None:
930
+ builder = store['builders'].get(cid) or self.static_builders.get(cid)
931
+ if builder:
932
+ clicked_component = builder()
933
+
934
+ # Build response: clicked component HTML + OOB for others
935
+ response_html = clicked_component.render() if clicked_component else ""
936
+ response_html += self.lite_engine.wrap_oob(other_dirty)
937
+
938
+ # Process Toasts
939
+ toasts = store.get('toasts', [])
940
+ if toasts:
941
+ import html as html_lib
942
+ toasts_json = json.dumps(toasts)
943
+ toasts_escaped = html_lib.escape(toasts_json)
944
+
945
+ toast_injector = f'''<div id="toast-injector" hx-swap-oob="true" data-toasts="{toasts_escaped}">
946
+ <script>
947
+ (function() {{
948
+ var container = document.getElementById('toast-injector');
949
+ if (!container) return;
950
+ var toastsAttr = container.getAttribute('data-toasts');
951
+ if (!toastsAttr) return;
952
+ var toasts = JSON.parse(toastsAttr);
953
+ toasts.forEach(function(t) {{
954
+ if (typeof createToast === 'function') {{
955
+ createToast(t.message, t.variant, t.icon);
956
+ }}
957
+ }});
958
+ container.removeAttribute('data-toasts');
959
+ }})();
960
+ </script>
961
+ </div>'''
962
+ response_html += toast_injector
963
+ store['toasts'] = []
964
+
965
+ # Process Effects (Balloons, Snow)
966
+ effects = store.get('effects', [])
967
+ if effects:
968
+ effects_json = json.dumps(effects)
969
+ effect_injector = f'''<div id="effects-injector" hx-swap-oob="true" data-effects='{effects_json}'>
970
+ <script>
971
+ (function() {{
972
+ const container = document.getElementById('effects-injector');
973
+ if (!container) return;
974
+ const effects = JSON.parse(container.getAttribute('data-effects'));
975
+ effects.forEach(e => {{
976
+ if (e === 'balloons') createBalloons();
977
+ if (e === 'snow') createSnow();
978
+ }});
979
+ container.removeAttribute('data-effects');
980
+ }})();
981
+ </script>
982
+ </div>'''
983
+ response_html += effect_injector
984
+ store['effects'] = []
985
+
986
+ return HTMLResponse(response_html)
987
+ return HTMLResponse("")
988
+
989
+ @self.fastapi.websocket("/ws")
990
+ async def ws(ws: WebSocket):
991
+ await ws.accept()
992
+
993
+ # Session ID: get from cookie (all tabs share same session)
994
+ sid = ws.cookies.get("ss_sid") or str(uuid.uuid4())
995
+
996
+ self.debug_print(f"[WEBSOCKET] Session: {sid[:8]}...")
997
+
998
+ # Set session context (outside while loop - very important!)
999
+ t = session_ctx.set(sid)
1000
+ self.ws_engine.sockets[sid] = ws
1001
+
1002
+ # Message processing function
1003
+ async def process_message(data):
1004
+ if data.get('type') != 'click':
1005
+ return
1006
+
1007
+ # Debug WebSocket data
1008
+ self.debug_print(f"[WEBSOCKET ACTION] CID: {data.get('id')}")
1009
+ self.debug_print(f" Native mode: {self.native_token is not None}")
1010
+ self.debug_print(f" CSRF enabled: {self.csrf_enabled}")
1011
+ self.debug_print(f" Native token in payload: {data.get('_native_token')[:20] if data.get('_native_token') else None}...")
1012
+
1013
+ # Native mode verification (high priority)
1014
+ if self.native_token is not None:
1015
+ native_token = data.get('_native_token')
1016
+ if native_token != self.native_token:
1017
+ self.debug_print(f" [X] Native token mismatch!")
1018
+ await ws.send_json({"type": "error", "message": "Invalid native token"})
1019
+ return
1020
+ else:
1021
+ self.debug_print(f" [OK] Native token valid - Skipping CSRF check")
1022
+ else:
1023
+ # CSRF verification for WebSocket (non-native only)
1024
+ if self.csrf_enabled:
1025
+ csrf_token = data.get('_csrf_token')
1026
+ if not csrf_token or not self._verify_csrf_token(sid, csrf_token):
1027
+ self.debug_print(f" [X] CSRF token invalid")
1028
+ await ws.send_json({"type": "error", "message": "Invalid CSRF token"})
1029
+ return
1030
+ else:
1031
+ self.debug_print(f" [OK] CSRF token valid")
1032
+
1033
+ cid, v = data.get('id'), data.get('value')
1034
+ store = get_session_store()
1035
+ act = store['actions'].get(cid) or self.static_actions.get(cid)
1036
+
1037
+ self.debug_print(f" Action found: {act is not None}")
1038
+
1039
+ if act:
1040
+ store['eval_queue'] = []
1041
+ self.debug_print(f" Executing action for CID: {cid}...")
1042
+ act(v) if v is not None else act()
1043
+ self.debug_print(f" Action executed")
1044
+
1045
+ for code in store.get('eval_queue', []):
1046
+ await self.ws_engine.push_eval(sid, code)
1047
+ store['eval_queue'] = []
1048
+
1049
+ dirty = self._get_dirty_rendered()
1050
+ self.debug_print(f" Dirty components: {len(dirty)} ({[c.id for c in dirty]})")
1051
+
1052
+ # Send all dirty components via WebSocket
1053
+ if dirty:
1054
+ self.debug_print(f" Sending {len(dirty)} updates via WebSocket...")
1055
+ await self.ws_engine.push_updates(sid, dirty)
1056
+ self.debug_print(f" [OK] Updates sent successfully")
1057
+ else:
1058
+ self.debug_print(f" [!] No dirty components found - nothing to update")
1059
+
1060
+ try:
1061
+ # Message processing loop
1062
+ while True:
1063
+ data = await ws.receive_json()
1064
+ await process_message(data)
1065
+ except WebSocketDisconnect:
1066
+ if sid and sid in self.ws_engine.sockets:
1067
+ del self.ws_engine.sockets[sid]
1068
+ self.debug_print(f"[WEBSOCKET] Disconnected: {sid[:8]}...")
1069
+ finally:
1070
+ if t is not None:
1071
+ session_ctx.reset(t)
1072
+
1073
+ def _run_web_reload(self, args):
1074
+ """Run with hot reload in web mode (process restart)"""
1075
+ self.debug_print(f"[HOT RELOAD] Watching {os.getcwd()}...")
1076
+
1077
+ iteration = 0
1078
+ while True:
1079
+ iteration += 1
1080
+ # Prepare environment
1081
+ env = os.environ.copy()
1082
+ env["VIOLIT_WORKER"] = "1"
1083
+
1084
+ # Start worker
1085
+ self.debug_print(f"\n[Web Reload] Starting server (iteration {iteration})...", flush=True)
1086
+ p = subprocess.Popen([sys.executable] + sys.argv, env=env)
1087
+
1088
+ # Watch for changes
1089
+ watcher = FileWatcher(debug_mode=self.debug_mode)
1090
+ intentional_restart = False
1091
+
1092
+ try:
1093
+ while p.poll() is None:
1094
+ if watcher.check():
1095
+ self.debug_print("\n[Web Reload] πŸ”„ Reloading server...", flush=True)
1096
+ intentional_restart = True
1097
+ p.terminate()
1098
+ try:
1099
+ p.wait(timeout=2)
1100
+ self.debug_print("[Web Reload] βœ“ Server stopped gracefully", flush=True)
1101
+ except subprocess.TimeoutExpired:
1102
+ self.debug_print("[Web Reload] WARNING: Force killing server...", flush=True)
1103
+ p.kill()
1104
+ p.wait()
1105
+ break
1106
+ time.sleep(0.5)
1107
+ except KeyboardInterrupt:
1108
+ p.terminate()
1109
+ sys.exit(0)
1110
+
1111
+ # If it was an intentional restart, wait a bit so browser can detect server is down
1112
+ if intentional_restart:
1113
+ time.sleep(1.5) # Give browser time to detect server is down (increased for reliability)
1114
+ continue
1115
+
1116
+ # If process exited unexpectedly (crashed), wait for file change
1117
+ if p.returncode is not None:
1118
+ self.debug_print("[Web Reload] WARNING: Server exited unexpectedly. Waiting for file changes...", flush=True)
1119
+ while not watcher.check():
1120
+ time.sleep(0.5)
1121
+ self.debug_print("[Web Reload] Reloading after crash...", flush=True)
1122
+
1123
+ def _run_native_reload(self, args):
1124
+ """Run with hot reload in desktop mode"""
1125
+ # Generate security token for native mode
1126
+ self.native_token = secrets.token_urlsafe(32)
1127
+ self.is_native_mode = True
1128
+
1129
+ self.debug_print(f"[HOT RELOAD] Desktop mode - Watching {os.getcwd()}...")
1130
+
1131
+ # Shared state for the server process
1132
+ server_process = [None]
1133
+ should_exit = [False]
1134
+
1135
+ def server_manager():
1136
+ iteration = 0
1137
+ while not should_exit[0]:
1138
+ iteration += 1
1139
+ env = os.environ.copy()
1140
+ env["VIOLIT_WORKER"] = "1"
1141
+ env["VIOLIT_SERVER_ONLY"] = "1"
1142
+ env["VIOLIT_NATIVE_TOKEN"] = self.native_token
1143
+ env["VIOLIT_NATIVE_MODE"] = "1"
1144
+
1145
+ # Start server
1146
+ self.debug_print(f"\n[Server Manager] Starting server (iteration {iteration})...", flush=True)
1147
+ server_process[0] = subprocess.Popen(
1148
+ [sys.executable] + sys.argv,
1149
+ env=env,
1150
+ stdout=subprocess.PIPE if iteration > 1 else None,
1151
+ stderr=subprocess.STDOUT if iteration > 1 else None
1152
+ )
1153
+
1154
+ # Give server time to start
1155
+ time.sleep(0.3)
1156
+
1157
+ watcher = FileWatcher(debug_mode=self.debug_mode)
1158
+
1159
+ # Watch loop
1160
+ intentional_restart = False
1161
+ while server_process[0].poll() is None and not should_exit[0]:
1162
+ if watcher.check():
1163
+ self.debug_print("\n[Server Manager] πŸ”„ Reloading server...", flush=True)
1164
+ intentional_restart = True
1165
+ server_process[0].terminate()
1166
+ try:
1167
+ server_process[0].wait(timeout=2)
1168
+ self.debug_print("[Server Manager] βœ“ Server stopped gracefully", flush=True)
1169
+ except subprocess.TimeoutExpired:
1170
+ self.debug_print("[Server Manager] WARNING: Force killing server...", flush=True)
1171
+ server_process[0].kill()
1172
+ server_process[0].wait()
1173
+ break
1174
+ time.sleep(0.5)
1175
+
1176
+ # If it was an intentional restart, reload webview and continue
1177
+ if intentional_restart:
1178
+ # Wait for server to be ready
1179
+ time.sleep(0.5)
1180
+ # Reload webview
1181
+ try:
1182
+ if webview.windows:
1183
+ webview.windows[0].load_url(f"http://127.0.0.1:{args.port}?_native_token={self.native_token}")
1184
+ self.debug_print("[Server Manager] \u2713 Webview reloaded", flush=True)
1185
+ except Exception as e:
1186
+ self.debug_print(f"[Server Manager] \u26a0 Webview reload failed: {e}", flush=True)
1187
+ continue
1188
+
1189
+ # If exited unexpectedly (crashed), wait for file change
1190
+ if server_process[0].poll() is not None and not should_exit[0]:
1191
+ self.debug_print("[Server Manager] WARNING: Server exited unexpectedly. Waiting for file changes...", flush=True)
1192
+ while not watcher.check() and not should_exit[0]:
1193
+ time.sleep(0.5)
1194
+
1195
+ # Start server manager thread
1196
+ t = threading.Thread(target=server_manager, daemon=True)
1197
+ t.start()
1198
+
1199
+ # Patch webview to use custom icon (Windows)
1200
+ self._patch_webview_icon()
1201
+
1202
+ # Start WebView (Main Thread)
1203
+ win_args = {
1204
+ 'text_select': True,
1205
+ 'width': self.width,
1206
+ 'height': self.height,
1207
+ 'on_top': self.on_top
1208
+ }
1209
+
1210
+ # Pass icon to start (for non-WinForms backends)
1211
+ start_args = {}
1212
+ sig_start = inspect.signature(webview.start)
1213
+ if 'icon' in sig_start.parameters and self.app_icon:
1214
+ start_args['icon'] = self.app_icon
1215
+
1216
+ webview.create_window(self.app_title, f"http://127.0.0.1:{args.port}?_native_token={self.native_token}", **win_args)
1217
+ webview.start(**start_args)
1218
+
1219
+ # Cleanup
1220
+ should_exit[0] = True
1221
+ if server_process[0]:
1222
+ try:
1223
+ server_process[0].terminate()
1224
+ except:
1225
+ pass
1226
+ sys.exit(0)
1227
+
1228
+ # Broadcasting Methods (WebSocket-based real-time sync)
1229
+ def broadcast_eval(self, js_code: str, exclude_current: bool = False):
1230
+ self.broadcaster.eval_all(js_code, exclude_current=exclude_current)
1231
+
1232
+ def broadcast_reload(self, exclude_current: bool = False):
1233
+ self.broadcaster.reload_all(exclude_current=exclude_current)
1234
+
1235
+ def broadcast_dom_update(self, container_id: str, html: str,
1236
+ position: str = 'prepend', animate: bool = True,
1237
+ exclude_current: bool = False):
1238
+ self.broadcaster.broadcast_dom_update(
1239
+ container_id, html, position, animate, exclude_current
1240
+ )
1241
+
1242
+ def broadcast_event(self, event_name: str, data: dict,
1243
+ exclude_current: bool = False):
1244
+ self.broadcaster.broadcast_event(event_name, data, exclude_current)
1245
+
1246
+ def broadcast_dom_remove(self, selector: str = None,
1247
+ element_id: str = None,
1248
+ data_attribute: tuple = None,
1249
+ animate: bool = True,
1250
+ animation_type: str = 'fade-right',
1251
+ duration: int = 500,
1252
+ exclude_current: bool = False):
1253
+ """Remove DOM element from all clients with animation
1254
+
1255
+ Example:
1256
+ # Remove by ID
1257
+ app.broadcast_dom_remove(element_id='my-element')
1258
+
1259
+ # Remove by CSS selector
1260
+ app.broadcast_dom_remove(selector='.old-posts')
1261
+ """
1262
+ self.broadcaster.broadcast_dom_remove(
1263
+ selector=selector,
1264
+ element_id=element_id,
1265
+ data_attribute=data_attribute,
1266
+ animate=animate,
1267
+ animation_type=animation_type,
1268
+ duration=duration,
1269
+ exclude_current=exclude_current
1270
+ )
1271
+
1272
+ def run(self):
1273
+ """Run the application"""
1274
+ p = argparse.ArgumentParser()
1275
+ p.add_argument("--native", action="store_true")
1276
+ p.add_argument("--nosplash", action="store_true", help="Disable splash screen")
1277
+ p.add_argument("--reload", action="store_true", help="Enable hot reload")
1278
+ p.add_argument("--lite", action="store_true", help="Use Lite mode (HTMX)")
1279
+ p.add_argument("--debug", action="store_true", help="Enable developer tools (native mode)")
1280
+ p.add_argument("--port", type=int, default=8000)
1281
+ args, _ = p.parse_known_args()
1282
+
1283
+ if args.lite:
1284
+ self.mode = "lite"
1285
+ # Also create lite engine if not already created
1286
+ if self.lite_engine is None:
1287
+ from .engine import LiteEngine
1288
+ self.lite_engine = LiteEngine()
1289
+
1290
+ # Handle internal env var to force "Server Only" mode (for native reload)
1291
+ if os.environ.get("VIOLIT_SERVER_ONLY"):
1292
+ args.native = False
1293
+
1294
+ # Hot Reload Manager Logic
1295
+ if args.reload and not os.environ.get("VIOLIT_WORKER"):
1296
+ if args.native:
1297
+ self._run_native_reload(args)
1298
+ else:
1299
+ self._run_web_reload(args)
1300
+ return
1301
+
1302
+ self.show_splash = not args.nosplash
1303
+ if self.show_splash:
1304
+ self._splash_html = """
1305
+ <div id="splash" style="position:fixed;top:0;left:0;width:100%;height:100%;background:var(--sl-bg);z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:opacity 0.4s ease;">
1306
+ <sl-spinner style="font-size: 3rem; --indicator-color: var(--sl-primary); margin-bottom: 1rem;"></sl-spinner>
1307
+ <div style="font-size:1.5rem;font-weight:600;color:var(--sl-text);" class="gradient-text">Loading...</div>
1308
+ </div>
1309
+ <script>
1310
+ window.addEventListener('load', ()=>{
1311
+ setTimeout(()=>{
1312
+ const s=document.getElementById('splash');
1313
+ if(s){
1314
+ s.style.opacity=0;
1315
+ setTimeout(()=>s.remove(), 400);
1316
+ }
1317
+ }, 800);
1318
+ });
1319
+ </script>
1320
+ """
1321
+
1322
+ if args.native:
1323
+ # Generate security token for native mode
1324
+ self.native_token = secrets.token_urlsafe(32)
1325
+ self.is_native_mode = True
1326
+
1327
+ # Disable CSRF in native mode (local app security)
1328
+ self.csrf_enabled = False
1329
+ print("[SECURITY] CSRF protection disabled (native mode)")
1330
+
1331
+ # Use a shared flag to signal server shutdown
1332
+ server_shutdown = threading.Event()
1333
+
1334
+ def srv():
1335
+ # Run uvicorn in a way we can control or just let it die with daemon
1336
+ # Since we use daemon=True, it should die when main thread dies.
1337
+ # However, sometimes keeping the main thread alive for webview.start()
1338
+ # might cause issues if not cleaned up properly.
1339
+ # We'll stick to daemon=True but force exit after webview.start returns.
1340
+ uvicorn.run(self.fastapi, host="127.0.0.1", port=args.port, log_level="warning")
1341
+
1342
+ t = threading.Thread(target=srv, daemon=True)
1343
+ t.start()
1344
+
1345
+ time.sleep(1)
1346
+
1347
+ # Patch webview to use custom icon (Windows)
1348
+ self._patch_webview_icon()
1349
+
1350
+ # Start WebView - This blocks until window is closed
1351
+ win_args = {
1352
+ 'text_select': True,
1353
+ 'width': self.width,
1354
+ 'height': self.height,
1355
+ 'on_top': self.on_top
1356
+ }
1357
+
1358
+ # Pass icon and debug mode to start (for non-WinForms backends)
1359
+ start_args = {}
1360
+ sig_start = inspect.signature(webview.start)
1361
+
1362
+ # Enable developer tools (when --debug flag is used)
1363
+ if args.debug:
1364
+ start_args['debug'] = True
1365
+ print("πŸ” Debug mode enabled: Press F12 or Ctrl+Shift+I to open developer tools")
1366
+
1367
+ if 'icon' in sig_start.parameters and self.app_icon:
1368
+ start_args['icon'] = self.app_icon
1369
+
1370
+ # Add native token to URL for initial access
1371
+ webview.create_window(self.app_title, f"http://127.0.0.1:{args.port}?_native_token={self.native_token}", **win_args)
1372
+ webview.start(**start_args)
1373
+
1374
+ # Force exit after window closes to kill the uvicorn thread immediately
1375
+ print("App closed. Exiting...")
1376
+ os._exit(0)
1377
+ else:
1378
+ uvicorn.run(self.fastapi, host="0.0.0.0", port=args.port)
1379
+
1380
+
1381
+ HTML_TEMPLATE = """
1382
+ <!DOCTYPE html>
1383
+ <html class="%THEME_CLASS%">
1384
+ <head>
1385
+ <meta charset="UTF-8">
1386
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1387
+ <meta name="htmx-config" content='{"defaultSwapDelay":0,"defaultSettleDelay":0}'>
1388
+ <title>%TITLE%</title>
1389
+ %CSRF_SCRIPT%
1390
+ %DEBUG_SCRIPT%
1391
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/themes/light.css" />
1392
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/themes/dark.css" />
1393
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/shoelace-autoloader.js"></script>
1394
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
1395
+ <script src="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/dist/ag-grid-community.min.js"></script>
1396
+ <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
1397
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
1398
+ <style>
1399
+ :root {
1400
+ %CSS_VARS%
1401
+ }
1402
+ sl-alert { --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary); }
1403
+ sl-alert::part(base) { border: 1px solid var(--sl-border); }
1404
+
1405
+ sl-button {
1406
+ --sl-color-primary-500: var(--sl-primary);
1407
+ --sl-color-primary-600: color-mix(in srgb, var(--sl-primary), black 10%);
1408
+ }
1409
+ body { margin: 0; background: var(--sl-bg); color: var(--sl-text); font-family: 'Inter', sans-serif; min-height: 100vh; transition: background 0.3s, color 0.3s; }
1410
+
1411
+ /* Soft Animation Mode - Only for sidebar and page transitions */
1412
+ body.anim-soft #sidebar { transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s ease, opacity 0.3s ease; }
1413
+ body.anim-soft .page-container { animation: fade-in 0.3s ease-out; }
1414
+
1415
+ /* Hard Animation Mode */
1416
+ body.anim-hard *, body.anim-hard ::part(base) { transition: none !important; animation: none !important; }
1417
+
1418
+ #root { display: flex; width: 100%; min-height: 100vh; }
1419
+ #sidebar {
1420
+ width: 300px;
1421
+ background: var(--sl-bg-card);
1422
+ border-right: 1px solid var(--sl-border);
1423
+ padding: 2rem 1rem;
1424
+ display: flex;
1425
+ flex-direction: column;
1426
+ gap: 1rem;
1427
+ flex-shrink: 0;
1428
+ overflow-y: auto;
1429
+ overflow-x: hidden;
1430
+ white-space: nowrap;
1431
+ position: sticky;
1432
+ top: 0;
1433
+ height: 100vh;
1434
+ }
1435
+ #sidebar.collapsed { width: 0; padding: 2rem 0; border-right: none; opacity: 0; }
1436
+
1437
+ #main {
1438
+ flex: 1;
1439
+ display: flex;
1440
+ flex-direction: column;
1441
+ align-items: center;
1442
+ padding: 0 1.5rem 3rem 1.5rem;
1443
+ transition: padding 0.3s ease;
1444
+ }
1445
+ #header { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; padding: 1rem 0; display: flex; align-items: center; }
1446
+ #app { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; display: flex; flex-direction: column; gap: 1.5rem; }
1447
+
1448
+ .fragment { display: flex; flex-direction: column; gap: 1.25rem; width: 100%; }
1449
+ .page-container { display: flex; flex-direction: column; gap: 1rem; width: 100%; }
1450
+ .card { background: var(--sl-bg-card); border: 1px solid var(--sl-border); padding: 1.5rem; border-radius: var(--sl-radius); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); margin-bottom: 0.5rem; }
1451
+
1452
+ /* Widget spacing - natural breathing room */
1453
+ .page-container > div { margin-bottom: 0.5rem; }
1454
+
1455
+ /* Headings need more space above to separate sections */
1456
+ h1, h2, h3 { font-weight: 600; margin: 0; }
1457
+ h1 { font-size: 2.25rem; line-height: 1.2; margin-bottom: 1rem; }
1458
+ h2 { font-size: 1.5rem; line-height: 1.3; margin-top: 1.5rem; margin-bottom: 0.75rem; }
1459
+ h3 { font-size: 1.25rem; line-height: 1.4; margin-top: 1.25rem; margin-bottom: 0.5rem; }
1460
+ .page-container > h1:first-child, .page-container > h2:first-child, .page-container > h3:first-child,
1461
+ h1:first-child, h2:first-child, h3:first-child { margin-top: 0; }
1462
+
1463
+ /* Shoelace component spacing */
1464
+ sl-input, sl-select, sl-textarea, sl-range, sl-checkbox, sl-switch, sl-radio-group, sl-color-picker {
1465
+ display: block;
1466
+ margin-bottom: 1rem;
1467
+ }
1468
+ sl-alert {
1469
+ display: block;
1470
+ margin-bottom: 1.25rem;
1471
+ }
1472
+ sl-button {
1473
+ margin-top: 0.25rem;
1474
+ margin-bottom: 0.5rem;
1475
+ }
1476
+ sl-divider, .divider {
1477
+ --color: var(--sl-border);
1478
+ margin: 1.5rem 0;
1479
+ width: 100%;
1480
+ }
1481
+
1482
+ /* Column layouts */
1483
+ .columns { display: flex; gap: 1rem; width: 100%; margin-bottom: 0.5rem; }
1484
+ .column { flex: 1; display: flex; flex-direction: column; gap: 0.75rem; }
1485
+
1486
+ /* List container - predefined layout for reactive lists */
1487
+ .violit-list-container {
1488
+ display: flex;
1489
+ flex-direction: column;
1490
+ gap: 1rem;
1491
+ width: 100%;
1492
+ }
1493
+ .violit-list-container > * {
1494
+ width: 100%;
1495
+ }
1496
+ .violit-list-container sl-card {
1497
+ width: 100%;
1498
+ }
1499
+
1500
+ .gradient-text { background: linear-gradient(to right, var(--sl-primary), var(--sl-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
1501
+ .text-muted { color: var(--sl-text-muted); }
1502
+ .metric-label { color: var(--sl-text-muted); font-size: 0.875rem; margin-bottom: 0.25rem; }
1503
+ .metric-value { font-size: 2rem; font-weight: 600; }
1504
+ .no-select { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; }
1505
+
1506
+ @keyframes fade-in {
1507
+ from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
1508
+ to { opacity: 1; transform: translateY(0); filter: blur(0); }
1509
+ }
1510
+
1511
+ /* Animations for Balloons and Snow */
1512
+ @keyframes float-up {
1513
+ 0% { transform: translateY(var(--start-y, 100vh)) rotate(0deg); opacity: 1; }
1514
+ 100% { transform: translateY(-20vh) rotate(360deg); opacity: 0; }
1515
+ }
1516
+ @keyframes fall-down {
1517
+ 0% { transform: translateY(-10vh) rotate(0deg); opacity: 0; }
1518
+ 10% { opacity: 1; }
1519
+ 90% { opacity: 1; }
1520
+ 100% { transform: translateY(110vh) rotate(360deg); opacity: 0; }
1521
+ }
1522
+ .balloon, .snowflake {
1523
+ position: fixed;
1524
+ z-index: 9999;
1525
+ pointer-events: none;
1526
+ font-size: 2rem;
1527
+ user-select: none;
1528
+ }
1529
+ .balloon { animation: float-up var(--duration) linear forwards; }
1530
+ .snowflake { animation: fall-down var(--duration) linear forwards; }
1531
+ </style>
1532
+ <script>
1533
+ const mode = "%MODE%";
1534
+
1535
+ // Debug logging helper
1536
+ const debugLog = (...args) => {
1537
+ if (window._debug_mode) {
1538
+ console.log(...args);
1539
+ }
1540
+ };
1541
+
1542
+ // [LOCK] HTMX에 CSRF 토큰 μžλ™ μΆ”κ°€ (Lite Mode)
1543
+ if (mode === 'lite' && window._csrf_token) {
1544
+ document.addEventListener('DOMContentLoaded', function() {
1545
+ document.body.addEventListener('htmx:configRequest', function(evt) {
1546
+ evt.detail.parameters['_csrf_token'] = window._csrf_token;
1547
+ });
1548
+ });
1549
+ }
1550
+
1551
+ // Helper to clean up Plotly instances before removing elements
1552
+ function purgePlotly(container) {
1553
+ if (!window.Plotly) return;
1554
+ if (container.classList && container.classList.contains('js-plotly-plot')) {
1555
+ Plotly.purge(container);
1556
+ }
1557
+ if (container.querySelectorAll) {
1558
+ container.querySelectorAll('.js-plotly-plot').forEach(p => Plotly.purge(p));
1559
+ }
1560
+ }
1561
+
1562
+ if (mode === 'ws') {
1563
+ // [FIX] Pre-define sendAction with queue to handle clicks before WebSocket connects
1564
+ // Use window properties for debugging access
1565
+ window._wsReady = false;
1566
+ window._actionQueue = [];
1567
+ window._ws = null;
1568
+
1569
+ // Define sendAction IMMEDIATELY (before WebSocket connection)
1570
+ window.sendAction = (cid, val) => {
1571
+ debugLog(`[sendAction] Called with cid=${cid}, val=${val}`);
1572
+
1573
+ const payload = {
1574
+ type: 'click',
1575
+ id: cid,
1576
+ value: val
1577
+ };
1578
+
1579
+ // CSRF 토큰 μΆ”κ°€
1580
+ if (window._csrf_token) {
1581
+ payload._csrf_token = window._csrf_token;
1582
+ }
1583
+
1584
+ // [SECURE] Native 토큰 μΆ”κ°€ (pywebviewμ—μ„œ ν•„μš”)
1585
+ const urlParams = new URLSearchParams(window.location.search);
1586
+ const nativeToken = urlParams.get('_native_token');
1587
+ if (nativeToken) {
1588
+ payload._native_token = nativeToken;
1589
+ }
1590
+
1591
+ // Check if this is a navigation menu click (nav_menu_X)
1592
+ if (cid.startsWith('nav_menu')) {
1593
+ // Update URL hash to reflect current page
1594
+ // val is "page_reactive-logic", we make it #reactive-logic
1595
+ const pageName = val.replace('page_', '');
1596
+ window.location.hash = pageName;
1597
+ debugLog(`πŸ”— Updated Hash: #${pageName}`);
1598
+ }
1599
+
1600
+ // Queue action if WebSocket not ready, otherwise send immediately
1601
+ if (!window._wsReady || !window._ws) {
1602
+ debugLog(`⏳ WebSocket not ready (wsReady=${window._wsReady}, ws=${!!window._ws}), queueing action: ${cid}`);
1603
+ window._actionQueue.push(payload);
1604
+ } else {
1605
+ debugLog(`βœ… Sending action to server: ${cid}`);
1606
+ window._ws.send(JSON.stringify(payload));
1607
+ }
1608
+ };
1609
+
1610
+ // Now connect WebSocket
1611
+ window._ws = new WebSocket((location.protocol === 'https:' ? 'wss:' : 'ws:') + "//" + location.host + "/ws");
1612
+
1613
+ // Auto-reconnect/reload logic
1614
+ window._ws.onclose = () => {
1615
+ window._wsReady = false;
1616
+ debugLog("πŸ”Œ Connection lost. Auto-reloading...");
1617
+
1618
+ const checkServer = () => {
1619
+ fetch(location.href)
1620
+ .then(r => {
1621
+ if(r.ok) {
1622
+ debugLog("βœ“ Server back online. Reloading...");
1623
+ window.location.reload();
1624
+ } else {
1625
+ setTimeout(checkServer, 300);
1626
+ }
1627
+ })
1628
+ .catch(() => setTimeout(checkServer, 300));
1629
+ };
1630
+ setTimeout(checkServer, 300);
1631
+ };
1632
+
1633
+ // CRITICAL: Restore from hash ONLY after WebSocket is connected
1634
+ window._ws.onopen = () => {
1635
+ debugLog("βœ… [WebSocket] Connected successfully!");
1636
+ window._wsReady = true;
1637
+
1638
+ // Process queued actions
1639
+ if (window._actionQueue.length > 0) {
1640
+ debugLog(`πŸ“€ Processing ${window._actionQueue.length} queued action(s)...`);
1641
+ window._actionQueue.forEach(payload => {
1642
+ window._ws.send(JSON.stringify(payload));
1643
+ });
1644
+ window._actionQueue.length = 0; // Clear queue
1645
+ }
1646
+
1647
+ // Restore from hash after processing queue
1648
+ setTimeout(restoreFromHash, 100);
1649
+ };
1650
+
1651
+ // Handle WebSocket errors
1652
+ window._ws.onerror = (error) => {
1653
+ window._wsReady = false;
1654
+ debugLog("❌ WebSocket error:", error);
1655
+ };
1656
+
1657
+ window._ws.onmessage = (e) => {
1658
+ debugLog("[WebSocket] Message received");
1659
+ const msg = JSON.parse(e.data);
1660
+ if(msg.type === 'update') {
1661
+ // Separate page transitions from regular updates
1662
+ const pageUpdates = [];
1663
+ const regularUpdates = [];
1664
+
1665
+ msg.payload.forEach(item => {
1666
+ // Only page_renderer gets smooth transition
1667
+ if (item.id.startsWith('page_renderer')) {
1668
+ pageUpdates.push(item);
1669
+ } else {
1670
+ regularUpdates.push(item);
1671
+ }
1672
+ });
1673
+
1674
+ // Regular updates: apply immediately without animation
1675
+ regularUpdates.forEach(item => {
1676
+ const el = document.getElementById(item.id);
1677
+
1678
+ // Focus Guard: Skip update if element is focused input to prevent interrupting typing
1679
+ if (document.activeElement && el) {
1680
+ const isSelfOrChild = document.activeElement.id === item.id || el.contains(document.activeElement);
1681
+ const isShadowChild = document.activeElement.closest && document.activeElement.closest(`#${item.id}`);
1682
+
1683
+ if (isSelfOrChild || isShadowChild) {
1684
+ // Check if it's actually an input that needs protection
1685
+ const tag = document.activeElement.tagName.toLowerCase();
1686
+ const isInput = tag === 'input' || tag === 'textarea' || tag.startsWith('sl-input') || tag.startsWith('sl-textarea');
1687
+
1688
+ // If it's an input, block update. If it's a button (nav menu), ALLOW update.
1689
+ if (isInput) {
1690
+ return;
1691
+ }
1692
+ }
1693
+ }
1694
+
1695
+ if(el) {
1696
+ // Smart update for specific widget types to preserve animations/instances
1697
+ const widgetType = item.id.split('_')[0];
1698
+ let smartUpdated = false;
1699
+
1700
+ // Checkbox/Toggle: Update checked property only (preserve animation)
1701
+ if (widgetType === 'checkbox' || widgetType === 'toggle') {
1702
+ // Parse new HTML to extract checked state
1703
+ const temp = document.createElement('div');
1704
+ temp.innerHTML = item.html;
1705
+ const newCheckbox = temp.querySelector('sl-checkbox, sl-switch');
1706
+
1707
+ if (newCheckbox) {
1708
+ // Find the actual checkbox element (may be direct or nested)
1709
+ const checkboxEl = el.tagName && (el.tagName.toLowerCase() === 'sl-checkbox' || el.tagName.toLowerCase() === 'sl-switch')
1710
+ ? el
1711
+ : el.querySelector('sl-checkbox, sl-switch');
1712
+
1713
+ if (checkboxEl) {
1714
+ const shouldBeChecked = newCheckbox.hasAttribute('checked');
1715
+ // Only update if different to avoid interrupting user interaction
1716
+ if (checkboxEl.checked !== shouldBeChecked) {
1717
+ checkboxEl.checked = shouldBeChecked;
1718
+ }
1719
+ smartUpdated = true;
1720
+ }
1721
+ }
1722
+ }
1723
+
1724
+ // Data Editor: Update AG Grid data only
1725
+ if (widgetType === 'data' && item.id.includes('editor')) {
1726
+ // item.id is like "data_editor_xxx_wrapper", extract base cid
1727
+ const baseCid = item.id.replace('_wrapper', '');
1728
+ const gridApi = window['gridApi_' + baseCid];
1729
+ if (gridApi) {
1730
+ // Extract rowData from new HTML
1731
+ const match = item.html.match(/rowData:\s*(\[.*?\])/s);
1732
+ if (match) {
1733
+ try {
1734
+ const newData = JSON.parse(match[1]);
1735
+ gridApi.setRowData(newData);
1736
+ smartUpdated = true;
1737
+ } catch (e) {
1738
+ console.error('Failed to parse AG Grid data:', e);
1739
+ }
1740
+ }
1741
+ }
1742
+ }
1743
+
1744
+ // Default: Full DOM replacement
1745
+ if (!smartUpdated) {
1746
+ purgePlotly(el);
1747
+ el.outerHTML = item.html;
1748
+
1749
+ // Execute scripts
1750
+ const temp = document.createElement('div');
1751
+ temp.innerHTML = item.html;
1752
+ temp.querySelectorAll('script').forEach(s => {
1753
+ const script = document.createElement('script');
1754
+ script.textContent = s.textContent;
1755
+ document.body.appendChild(script);
1756
+ script.remove();
1757
+ });
1758
+ }
1759
+ }
1760
+ });
1761
+
1762
+ // Page updates: use View Transitions if available
1763
+ if (pageUpdates.length > 0) {
1764
+ const updatePages = () => {
1765
+ pageUpdates.forEach(item => {
1766
+ const el = document.getElementById(item.id);
1767
+ if(el) {
1768
+ purgePlotly(el);
1769
+ el.outerHTML = item.html;
1770
+
1771
+ const temp = document.createElement('div');
1772
+ temp.innerHTML = item.html;
1773
+ temp.querySelectorAll('script').forEach(s => {
1774
+ const script = document.createElement('script');
1775
+ script.textContent = s.textContent;
1776
+ document.body.appendChild(script);
1777
+ script.remove();
1778
+ });
1779
+ }
1780
+ });
1781
+ };
1782
+
1783
+ if (document.body.classList.contains('anim-soft') && document.startViewTransition) {
1784
+ document.startViewTransition(() => updatePages());
1785
+ } else {
1786
+ updatePages();
1787
+ }
1788
+ }
1789
+ } else if (msg.type === 'eval') {
1790
+ const func = new Function(msg.code);
1791
+ func();
1792
+ }
1793
+ };
1794
+ } else {
1795
+ // Lite Mode (HTMX) specifics
1796
+ document.addEventListener('DOMContentLoaded', () => {
1797
+ document.body.addEventListener('htmx:beforeSwap', function(evt) {
1798
+ if (evt.detail.target) {
1799
+ purgePlotly(evt.detail.target);
1800
+ }
1801
+ });
1802
+
1803
+ // Hot reload support for lite mode: poll server health
1804
+ let serverAlive = true;
1805
+ const checkServerHealth = () => {
1806
+ // Add timestamp to prevent caching
1807
+ const pollUrl = location.href.split('#')[0] + (location.href.indexOf('?') === -1 ? '?' : '&') + '_t=' + Date.now();
1808
+
1809
+ fetch(pollUrl, { cache: 'no-store' })
1810
+ .then(r => {
1811
+ if (r.ok) {
1812
+ if (!serverAlive) {
1813
+ debugLog("βœ“ Server back online. Reloading...");
1814
+ document.body.style.opacity = '1'; // Restore opacity
1815
+ window.location.reload();
1816
+ }
1817
+ serverAlive = true;
1818
+ // Ensure opacity is 1 if server is alive
1819
+ document.body.style.opacity = '1';
1820
+ document.body.style.pointerEvents = 'auto';
1821
+ } else {
1822
+ throw new Error('Server error');
1823
+ }
1824
+ })
1825
+ .catch(() => {
1826
+ if (serverAlive) {
1827
+ debugLog("πŸ”Œ Server down. Waiting for restart...");
1828
+ // Dim the page to indicate connection lost
1829
+ document.body.style.transition = 'opacity 0.5s';
1830
+ document.body.style.opacity = '0.5';
1831
+ document.body.style.pointerEvents = 'none';
1832
+ }
1833
+ serverAlive = false;
1834
+ });
1835
+ };
1836
+ setInterval(checkServerHealth, 200);
1837
+ });
1838
+ }
1839
+
1840
+ function toggleSidebar() {
1841
+ const sb = document.getElementById('sidebar');
1842
+ sb.classList.toggle('collapsed');
1843
+ }
1844
+ function createToast(message, variant = 'primary', icon = 'info-circle') {
1845
+ const variantColors = { primary: '#0ea5e9', success: '#10b981', warning: '#f59e0b', danger: '#ef4444' };
1846
+ const toast = document.createElement('div');
1847
+ // Use CSS variables directly so theme changes are reflected automatically
1848
+ toast.style.cssText = `position: fixed; top: 20px; right: 20px; z-index: 10000; min-width: 300px; background: var(--sl-panel-background-color, var(--sl-bg-card)); color: var(--sl-text); border: 1px solid var(--sl-border); border-left: 4px solid ${variantColors[variant]}; border-radius: 4px; padding: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); display: flex; align-items: center; gap: 12px; opacity: 0; transform: translateX(400px); transition: all 0.3s;`;
1849
+ toast.innerHTML = `<div style="flex: 1; font-size: 14px;">${message}</div><button onclick="this.parentElement.remove()" style="background: none; border: none; cursor: pointer; padding: 4px; color: var(--sl-text-muted); font-size: 20px;">&times;</button>`;
1850
+ document.body.appendChild(toast);
1851
+ requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateX(0)'; });
1852
+ setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(400px)'; setTimeout(() => toast.remove(), 300); }, 3300);
1853
+ }
1854
+ function createBalloons() {
1855
+ const emojis = ['🎈', '🎈', '🎈', '✨', '🎈', '🎈'];
1856
+ for (let i = 0; i < 60; i++) {
1857
+ const b = document.createElement('div');
1858
+ b.className = 'balloon';
1859
+ b.textContent = emojis[Math.floor(Math.random() * emojis.length)];
1860
+ b.style.left = Math.random() * 100 + 'vw';
1861
+ const startY = 10;
1862
+ b.style.setProperty('--start-y', startY + 'vh');
1863
+ const duration = 3 + Math.random() * 3;
1864
+ b.style.setProperty('--duration', duration + 's');
1865
+ b.style.animationDelay = Math.random() * 0.2 + 's';
1866
+ document.body.appendChild(b);
1867
+ setTimeout(() => b.remove(), (duration + 1) * 1000);
1868
+ }
1869
+ }
1870
+ function createSnow() {
1871
+ const emojis = ['❄️', '❅', '❆', '❄️'];
1872
+ for (let i = 0; i < 50; i++) {
1873
+ const s = document.createElement('div');
1874
+ s.className = 'snowflake';
1875
+ s.textContent = emojis[Math.floor(Math.random() * emojis.length)];
1876
+ s.style.left = Math.random() * 100 + 'vw';
1877
+ const duration = 4 + Math.random() * 4;
1878
+ s.style.setProperty('--duration', duration + 's');
1879
+ s.style.animationDelay = Math.random() * 1.0 + 's';
1880
+ document.body.appendChild(s);
1881
+ setTimeout(() => s.remove(), (duration + 5) * 1000);
1882
+ }
1883
+ }
1884
+
1885
+ // Restore state from URL Hash (or force Home if no hash)
1886
+ function restoreFromHash() {
1887
+ // πŸ”„ URL ν•΄μ‹œ λ””μ½”λ”© (ν•œκΈ€ λ“± μΈμ½”λ”©λœ 문자 처리)
1888
+ let hash = window.location.hash.substring(1); // Remove #
1889
+ try {
1890
+ hash = decodeURIComponent(hash);
1891
+ } catch (e) {
1892
+ debugLog('Hash decode error:', e);
1893
+ }
1894
+
1895
+ // If no hash, force navigation to Home to reset server state
1896
+ if (!hash || hash === 'home' || hash === 'ν™ˆ') {
1897
+ debugLog('🏠 No hash - forcing Home page');
1898
+ const tryClickHome = (attempts = 0) => {
1899
+ if (attempts >= 20) return;
1900
+ const navButtons = document.querySelectorAll('#sidebar sl-button');
1901
+ if (navButtons.length > 0) {
1902
+ const homeBtn = navButtons[0]; // First button is Home
1903
+ if (homeBtn.getAttribute('variant') !== 'primary') {
1904
+ homeBtn.click();
1905
+ debugLog('🏠 Clicked Home button');
1906
+ }
1907
+ } else {
1908
+ setTimeout(() => tryClickHome(attempts + 1), 100);
1909
+ }
1910
+ };
1911
+ tryClickHome();
1912
+ return;
1913
+ }
1914
+
1915
+ const targetKey = 'page_' + hash;
1916
+ debugLog(`πŸ“ Restoring from Hash: "${hash}" (key: ${targetKey})`);
1917
+
1918
+ const tryRestore = (attempts = 0) => {
1919
+ // Stop after 5 seconds
1920
+ if (attempts >= 50) {
1921
+ console.warn(`⚠ Failed to restore hash "${hash}"`);
1922
+ return;
1923
+ }
1924
+
1925
+ const navButtons = document.querySelectorAll('#sidebar sl-button');
1926
+ if (navButtons.length === 0) {
1927
+ setTimeout(() => tryRestore(attempts + 1), 100);
1928
+ return;
1929
+ }
1930
+
1931
+ for (let btn of navButtons) {
1932
+ const onclick = btn.getAttribute('onclick') || '';
1933
+ const hxVals = btn.getAttribute('hx-vals') || '';
1934
+
1935
+ // Match either onclick (WS mode) or hx-vals (Lite mode)
1936
+ if (onclick.includes(targetKey) || hxVals.includes(targetKey)) {
1937
+ debugLog(`βœ“ Found target button for hash "${hash}". Clicking...`);
1938
+
1939
+ // Check if already active to avoid redundant clicks
1940
+ if (btn.getAttribute('variant') === 'primary') {
1941
+ debugLog(' - Already active, skipping click.');
1942
+ return;
1943
+ }
1944
+
1945
+ btn.click();
1946
+ return;
1947
+ }
1948
+ }
1949
+
1950
+ // Keep retrying in case the specific button hasn't rendered yet (unlikely if container exists)
1951
+ setTimeout(() => tryRestore(attempts + 1), 100);
1952
+ };
1953
+
1954
+ tryRestore();
1955
+ }
1956
+
1957
+ // Note: For ws mode, restoreFromHash is called from ws.onopen
1958
+ // For lite mode, call it on load:
1959
+ if (mode !== 'ws') {
1960
+ if (document.readyState === 'loading') {
1961
+ document.addEventListener('DOMContentLoaded', () => setTimeout(restoreFromHash, 200));
1962
+ } else {
1963
+ setTimeout(restoreFromHash, 200);
1964
+ }
1965
+ }
1966
+ </script>
1967
+ </head>
1968
+ <body>
1969
+ %SPLASH%
1970
+ <div id="root">
1971
+ <div id="sidebar" style="%SIDEBAR_STYLE%">
1972
+ %SIDEBAR_CONTENT%
1973
+ </div>
1974
+ <div id="main">
1975
+ <div id="header">
1976
+ <sl-icon-button name="list" style="font-size: 1.5rem; color: var(--sl-text);" onclick="toggleSidebar()"></sl-icon-button>
1977
+ </div>
1978
+ <div id="app">%CONTENT%</div>
1979
+ </div>
1980
+ </div>
1981
+ <div id="toast-injector" style="display:none;"></div>
1982
+ </body>
1983
+ </html>
1984
+ """