violit 0.0.4__py3-none-any.whl → 0.0.5__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 CHANGED
@@ -1,1988 +1,2229 @@
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
- # Streamlit-style: Generate stable key from caller's location
275
- frame = inspect.currentframe()
276
- try:
277
- caller_frame = frame.f_back
278
- filename = os.path.basename(caller_frame.f_code.co_filename)
279
- lineno = caller_frame.f_lineno
280
- # Create stable key: filename_linenumber
281
- name = f"state_{filename}_{lineno}"
282
- finally:
283
- del frame # Avoid reference cycles
284
- else:
285
- name = key
286
- return State(name, default_value)
287
-
288
- def _get_next_cid(self, prefix: str) -> str:
289
- """Generate next component ID"""
290
- store = get_session_store()
291
- cid = f"{prefix}_{store['component_count']}"
292
- store['component_count'] += 1
293
- return cid
294
-
295
- def _register_component(self, cid: str, builder: Callable, action: Optional[Callable] = None):
296
- """Register a component with builder and optional action"""
297
- store = get_session_store()
298
- sid = session_ctx.get()
299
-
300
- store['builders'][cid] = builder
301
- if action:
302
- store['actions'][cid] = action
303
-
304
- curr_frag = fragment_ctx.get()
305
- l_ctx = layout_ctx.get()
306
-
307
- if curr_frag:
308
- # Inside a fragment
309
- # IMPORTANT: Still respect sidebar context even inside fragments!
310
- if l_ctx == "sidebar":
311
- # Register to sidebar, not fragment
312
- if sid is None:
313
- self.static_builders[cid] = builder
314
- if action: self.static_actions[cid] = action
315
- if cid not in self.static_sidebar_order:
316
- self.static_sidebar_order.append(cid)
317
- else:
318
- if action: store['actions'][cid] = action
319
- store['sidebar_order'].append(cid)
320
- else:
321
- # Normal fragment component registration
322
- if sid is None:
323
- # Static nesting (e.g. inside columns/expander at top level)
324
- if curr_frag not in self.static_fragment_components:
325
- self.static_fragment_components[curr_frag] = []
326
- self.static_fragment_components[curr_frag].append((cid, builder))
327
- # Store action and builder for components inside fragments
328
- if action:
329
- self.static_actions[cid] = action
330
- self.static_builders[cid] = builder
331
- else:
332
- # Dynamic Nesting (Runtime)
333
- if curr_frag not in store['fragment_components']:
334
- store['fragment_components'][curr_frag] = []
335
- store['fragment_components'][curr_frag].append((cid, builder))
336
- else:
337
- if sid is None:
338
- # Static Root Registration
339
- self.static_builders[cid] = builder
340
- if action: self.static_actions[cid] = action
341
-
342
- if l_ctx == "sidebar":
343
- if cid not in self.static_sidebar_order:
344
- self.static_sidebar_order.append(cid)
345
- else:
346
- if cid not in self.static_order:
347
- self.static_order.append(cid)
348
- else:
349
- # Dynamic Root Registration
350
- if action: store['actions'][cid] = action
351
-
352
- if l_ctx == "sidebar":
353
- store['sidebar_order'].append(cid)
354
- else:
355
- store['order'].append(cid)
356
-
357
- def simple_card(self, content_fn: Union[Callable, str, State]):
358
- """Display content in a simple card
359
-
360
- Accepts State object, callable, or string content
361
- """
362
- cid = self._get_next_cid("simple_card")
363
- def builder():
364
- token = rendering_ctx.set(cid)
365
- # Handle State object, callable, or direct value
366
- if isinstance(content_fn, State):
367
- val = content_fn.value
368
- elif callable(content_fn):
369
- val = content_fn()
370
- else:
371
- val = content_fn
372
-
373
- if val is None:
374
- val = "_No data_"
375
-
376
- rendering_ctx.reset(token)
377
- return Component("div", id=cid, content=str(val), class_="card")
378
- self._register_component(cid, builder)
379
-
380
- def fragment(self, func: Callable) -> Callable:
381
- """Create a reactive fragment (decorator)
382
-
383
- .. deprecated::
384
- This method is deprecated. Please use alternative patterns for managing reactive content.
385
-
386
- Always returns a wrapper that registers on call.
387
- Call the decorated function to register and render it.
388
- """
389
- warnings.warn(
390
- "@app.fragment is deprecated and will be removed in a future version. "
391
- "Please consider using alternative patterns.",
392
- DeprecationWarning,
393
- stacklevel=2
394
- )
395
-
396
- fid = f"fragment_{self._fragment_count}"
397
- self._fragment_count += 1
398
-
399
- # Track if already registered
400
- registered = [False]
401
-
402
- def fragment_builder():
403
- token = fragment_ctx.set(fid)
404
- render_token = rendering_ctx.set(fid)
405
- store = get_session_store()
406
- store['fragment_components'][fid] = []
407
-
408
- # Execute fragment logic
409
- func()
410
-
411
- # Render children
412
- htmls = []
413
- for cid, b in store['fragment_components'][fid]:
414
- htmls.append(b().render())
415
-
416
- fragment_ctx.reset(token)
417
- rendering_ctx.reset(render_token)
418
-
419
- inner = f'<div id="{fid}" class="fragment">{" ".join(htmls)}</div>'
420
- return Component("div", id=f"{fid}_wrapper", content=inner)
421
-
422
- # Store builder
423
- self.static_builders[fid] = fragment_builder
424
- self.static_fragments[fid] = func
425
-
426
- def wrapper():
427
- """Wrapper that registers fragment on first call"""
428
- if registered[0]:
429
- return
430
- registered[0] = True
431
-
432
- sid = session_ctx.get()
433
- if sid is None:
434
- # Static context: add to static_order
435
- if fid not in self.static_order:
436
- self.static_order.append(fid)
437
- else:
438
- # Dynamic context: add to dynamic order
439
- self._register_component(fid, fragment_builder)
440
-
441
- return wrapper
442
-
443
- def reactivity(self, func: Optional[Callable] = None):
444
- """Create a reactive scope for complex control flow
445
-
446
- Use as context manager for reactive if/for loops
447
- """
448
- if func is not None:
449
- # Decorator mode - deprecated
450
- warnings.warn(
451
- "@app.reactivity decorator is deprecated and will be removed in a future version. "
452
- "Please use 'with app.reactivity():' context manager instead.",
453
- DeprecationWarning,
454
- stacklevel=2
455
- )
456
- return self.fragment(func)
457
-
458
- # Context manager mode
459
- class ReactivityContext:
460
- def __init__(ctx_self, app):
461
- ctx_self.app = app
462
- ctx_self.fid = None
463
- ctx_self.fragment_token = None
464
- # DON'T create new rendering_ctx - keep parent's!
465
-
466
- def __enter__(ctx_self):
467
- # Create a temporary fragment scope for component collection
468
- ctx_self.fid = f"reactivity_{self._fragment_count}"
469
- self._fragment_count += 1
470
-
471
- # Set fragment context only (state access registers with parent)
472
- ctx_self.fragment_token = fragment_ctx.set(ctx_self.fid)
473
-
474
- store = get_session_store()
475
- store['fragment_components'][ctx_self.fid] = []
476
- return ctx_self
477
-
478
- def __exit__(ctx_self, exc_type, exc_val, exc_tb):
479
- store = get_session_store()
480
-
481
- # Build the fragment
482
- def reactivity_builder():
483
- htmls = []
484
- for cid, b in store['fragment_components'][ctx_self.fid]:
485
- htmls.append(b().render())
486
- inner = f'<div id="{ctx_self.fid}" class="fragment">{" ".join(htmls)}</div>'
487
- return Component("div", id=f"{ctx_self.fid}_wrapper", content=inner)
488
-
489
- fragment_ctx.reset(ctx_self.fragment_token)
490
-
491
- # Register the reactivity scope as a component
492
- self._register_component(ctx_self.fid, reactivity_builder)
493
-
494
- return ReactivityContext(self)
495
-
496
- def _render_all(self):
497
- """Render all components"""
498
- store = get_session_store()
499
-
500
- main_html = []
501
- sidebar_html = []
502
-
503
- def render_cids(cids, target_list):
504
- for cid in cids:
505
- builder = store['builders'].get(cid) or self.static_builders.get(cid)
506
- if builder:
507
- target_list.append(builder().render())
508
-
509
- # Static Components
510
- render_cids(self.static_order, main_html)
511
- render_cids(self.static_sidebar_order, sidebar_html)
512
-
513
- # Dynamic Components
514
- render_cids(store['order'], main_html)
515
- render_cids(store['sidebar_order'], sidebar_html)
516
-
517
- return "".join(main_html), "".join(sidebar_html)
518
-
519
- def _get_dirty_rendered(self):
520
- """Get components that need updating"""
521
- store = get_session_store()
522
- dirty_states = store.get('dirty_states', set())
523
- aff = set()
524
- for s in dirty_states: aff.update(store['tracker'].get_dirty_components(s))
525
- store['dirty_states'] = set()
526
-
527
- res = []
528
- for cid in aff:
529
- builder = store['builders'].get(cid) or self.static_builders.get(cid)
530
- if builder:
531
- res.append(builder())
532
- return res
533
-
534
- # Theme and settings methods
535
- def set_theme(self, p):
536
- """Set theme preset"""
537
- import time
538
- store = get_session_store()
539
- store['theme'].set_preset(p)
540
- if self._theme_state:
541
- # Use timestamp to force dirty even if same theme selected twice
542
- self._theme_state.set(f"{p}_{time.time()}")
543
-
544
- def set_selection_mode(self, enabled: bool):
545
- """Enable/disable text selection"""
546
- if self._selection_state:
547
- self._selection_state.set(enabled)
548
-
549
- def set_animation_mode(self, mode: str):
550
- """Set animation mode ('soft' or 'hard')"""
551
- if self._animation_state:
552
- self._animation_state.set(mode)
553
-
554
- def set_primary_color(self, c):
555
- """Set primary theme color"""
556
- store = get_session_store()
557
- store['theme'].set_color('primary', c)
558
- if self._theme_state:
559
- self._theme_state.set(str(time.time()))
560
-
561
- def _selection_updater(self):
562
- """Update selection mode"""
563
- cid = "__selection_updater__"
564
- def builder():
565
- token = rendering_ctx.set(cid)
566
- enabled = self._selection_state.value
567
- rendering_ctx.reset(token)
568
-
569
- action = "remove" if enabled else "add"
570
- script = f"<script>document.body.classList.{action}('no-select');</script>"
571
- return Component("div", id=cid, style="display:none", content=script)
572
- self._register_component(cid, builder)
573
-
574
- def _patch_webview_icon(self):
575
- """Monkey-patch pywebview's WinForms BrowserForm to use custom icon"""
576
- if os.name != 'nt' or not self.app_icon:
577
- return
578
-
579
- try:
580
- from webview.platforms import winforms
581
-
582
- # Store reference to icon path for closure
583
- icon_path = self.app_icon
584
-
585
- # Check if already patched
586
- if hasattr(winforms.BrowserView.BrowserForm, '_violit_patched'):
587
- return
588
-
589
- # Get original __init__
590
- original_init = winforms.BrowserView.BrowserForm.__init__
591
-
592
- def patched_init(self, window, cache_dir):
593
- """Patched __init__ that sets custom icon after original init"""
594
- original_init(self, window, cache_dir)
595
-
596
- try:
597
- from System.Drawing import Icon as DotNetIcon
598
- if os.path.exists(icon_path):
599
- self.Icon = DotNetIcon(icon_path)
600
- except Exception:
601
- pass # Silently fail if icon can't be set
602
-
603
- # Apply patch
604
- winforms.BrowserView.BrowserForm.__init__ = patched_init
605
- winforms.BrowserView.BrowserForm._violit_patched = True
606
-
607
- except Exception:
608
- pass # If patching fails, continue without custom icon
609
-
610
- def _animation_updater(self):
611
- """Update animation mode"""
612
- cid = "__animation_updater__"
613
- def builder():
614
- token = rendering_ctx.set(cid)
615
- mode = self._animation_state.value
616
- rendering_ctx.reset(token)
617
-
618
- script = f"<script>document.body.classList.remove('anim-soft', 'anim-hard'); document.body.classList.add('anim-{mode}');</script>"
619
- return Component("div", id=cid, style="display:none", content=script)
620
- self._register_component(cid, builder)
621
-
622
- def _theme_updater(self):
623
- """Update theme"""
624
- cid = "__theme_updater__"
625
- def builder():
626
- token = rendering_ctx.set(cid)
627
- _ = self._theme_state.value
628
- rendering_ctx.reset(token)
629
-
630
- store = get_session_store()
631
- t = store['theme']
632
- vars_str = t.to_css_vars()
633
- cls = t.theme_class
634
-
635
- script_content = f'''
636
- <script>
637
- (function() {{
638
- document.documentElement.className = '{cls}';
639
- const root = document.documentElement;
640
- const vars = `{vars_str}`.split('\\n');
641
- vars.forEach(v => {{
642
- const parts = v.split(':');
643
- if(parts.length === 2) {{
644
- const key = parts[0].trim();
645
- const val = parts[1].replace(';', '').trim();
646
- root.style.setProperty(key, val);
647
- }}
648
- }});
649
-
650
- // Update Extra CSS
651
- let extraStyle = document.getElementById('theme-extra');
652
- if (!extraStyle) {{
653
- extraStyle = document.createElement('style');
654
- extraStyle.id = 'theme-extra';
655
- document.head.appendChild(extraStyle);
656
- }}
657
- extraStyle.textContent = `{t.extra_css}`;
658
- }})();
659
- </script>
660
- '''
661
- return Component("div", id=cid, style="display:none", content=script_content)
662
- self._register_component(cid, builder)
663
-
664
- def navigation(self, pages: List[Any], position="sidebar", auto_run=True):
665
- """Create multi-page navigation"""
666
- # Normalize pages
667
- final_pages = []
668
- for p in pages:
669
- if isinstance(p, Page): final_pages.append(p)
670
- elif callable(p): final_pages.append(Page(p))
671
-
672
- if not final_pages: return None
673
-
674
- # Singleton state for navigation
675
- current_page_key_state = self.state(final_pages[0].key, key="__nav_selection__")
676
-
677
- # Navigation Menu Builder
678
- cid = self._get_next_cid("nav_menu")
679
- nav_cid = cid # Capture for use in nav_action closure
680
- def nav_builder():
681
- token = rendering_ctx.set(cid)
682
- curr = current_page_key_state.value
683
-
684
- items = []
685
- for p in final_pages:
686
- is_active = p.key == curr
687
- click_attr = ""
688
- if self.mode == 'lite':
689
- # Lite mode: update hash and HTMX post
690
- page_hash = p.key.replace("page_", "")
691
- click_attr = f'onclick="window.location.hash = \'{page_hash}\'" hx-post="/action/{cid}" hx-vals=\'{{"value": "{p.key}"}}\' hx-target="#{cid}" hx-swap="outerHTML"'
692
- else:
693
- # WebSocket mode (including native)
694
- click_attr = f'onclick="window.sendAction(\'{cid}\', \'{p.key}\')"'
695
-
696
- # Styling for active/inactive nav items
697
- if is_active:
698
- style = "width: 100%; justify-content: start; --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary);"
699
- variant = "primary"
700
- else:
701
- style = "width: 100%; justify-content: start; --sl-color-neutral-700: var(--sl-text);"
702
- variant = "text"
703
-
704
- icon_html = f'<sl-icon name="{p.icon}" slot="prefix"></sl-icon> ' if p.icon else ""
705
- items.append(f'<sl-button style="{style}" variant="{variant}" {click_attr}>{icon_html}{p.title}</sl-button>')
706
-
707
- rendering_ctx.reset(token)
708
- return Component("div", id=cid, content="<br>".join(items), class_="nav-container")
709
-
710
- def nav_action(key):
711
- current_page_key_state.set(key)
712
-
713
- # Register Nav Component
714
- if position == "sidebar":
715
- token = layout_ctx.set("sidebar")
716
- try:
717
- self._register_component(cid, nav_builder, action=nav_action)
718
- finally:
719
- layout_ctx.reset(token)
720
- else:
721
- self._register_component(cid, nav_builder, action=nav_action)
722
-
723
- # Return the runner wrapper
724
- current_key = current_page_key_state.value
725
-
726
- class PageRunner:
727
- def __init__(self, app, page_state, pages_map):
728
- self.app = app
729
- self.state = page_state
730
- self.pages_map = pages_map
731
-
732
- def run(self):
733
- # Progressive Mode: Register page renderer as a regular component
734
- # The builder function reads the navigation state, enabling reactivity
735
- # WITHOUT wrapping the entire page function in a fragment
736
- cid = self.app._get_next_cid("page_renderer")
737
-
738
- def page_builder():
739
- # Read the navigation state here - this creates the dependency
740
- token = rendering_ctx.set(cid)
741
- try:
742
- key = self.state.value
743
-
744
- # Execute the current page function
745
- p = self.pages_map.get(key)
746
- if p:
747
- # Collect components from the page
748
- store = get_session_store()
749
- # Clear previous dynamic order for this page render
750
- previous_order = store['order'].copy()
751
- previous_fragments = {k: v.copy() for k, v in store['fragment_components'].items()}
752
- store['order'] = []
753
- store['fragment_components'] = {} # Clear fragments to prevent duplicates
754
-
755
- try:
756
- # Execute page function
757
- # CRITICAL: Execute inside rendering_ctx so any state access registers dependency
758
- p.entry_point()
759
-
760
- # Render all components from this page
761
- htmls = []
762
- for page_cid in store['order']:
763
- builder = store['builders'].get(page_cid) or self.app.static_builders.get(page_cid)
764
- if builder:
765
- htmls.append(builder().render())
766
-
767
- content = '\n'.join(htmls)
768
- return Component("div", id=cid, content=content, class_="page-container")
769
- finally:
770
- # Restore previous state (always, even on exception)
771
- store['order'] = previous_order
772
- store['fragment_components'] = previous_fragments
773
-
774
- return Component("div", id=cid, content="", class_="page-container")
775
- finally:
776
- rendering_ctx.reset(token)
777
-
778
- # Register the page renderer as a regular component
779
- self.app._register_component(cid, page_builder)
780
-
781
-
782
- page_runner = PageRunner(self, current_page_key_state, {p.key: p for p in final_pages})
783
-
784
- # Auto-run if enabled
785
- if auto_run:
786
- page_runner.run()
787
-
788
- return page_runner
789
-
790
- # --- Routes ---
791
- def _setup_routes(self):
792
- """Setup FastAPI routes"""
793
- @self.fastapi.middleware("http")
794
- async def mw(request: Request, call_next):
795
- # Native mode security: Block web browser access
796
- if self.native_token is not None:
797
- token_from_request = request.query_params.get("_native_token")
798
- token_from_cookie = request.cookies.get("_native_token")
799
- user_agent = request.headers.get("user-agent", "")
800
-
801
- # Debug logging for security check
802
- self.debug_print(f"[NATIVE SECURITY CHECK]")
803
- self.debug_print(f" Token from request: {token_from_request[:20] if token_from_request else None}...")
804
- self.debug_print(f" Token from cookie: {token_from_cookie[:20] if token_from_cookie else None}...")
805
- self.debug_print(f" Expected token: {self.native_token[:20]}...")
806
- self.debug_print(f" User-Agent: {user_agent}")
807
-
808
- # Verify token
809
- is_valid_token = (token_from_request == self.native_token or token_from_cookie == self.native_token)
810
-
811
- # Block if token is invalid
812
- if not is_valid_token:
813
- from fastapi.responses import HTMLResponse
814
- self.debug_print(f" [X] ACCESS DENIED - Invalid or missing token")
815
- return HTMLResponse(
816
- content="""
817
- <html>
818
- <head><title>Access Denied</title></head>
819
- <body style="font-family: system-ui; padding: 2rem; text-align: center;">
820
- <h1>[LOCK] Access Denied</h1>
821
- <p>This application is running in <strong>native desktop mode</strong>.</p>
822
- <p>Web browser access is disabled for security reasons.</p>
823
- <hr style="margin: 2rem auto; width: 50%;">
824
- <small>If you are the owner, please use the desktop application.</small>
825
- </body>
826
- </html>
827
- """,
828
- status_code=403
829
- )
830
- else:
831
- self.debug_print(f" [OK] ACCESS GRANTED - Valid token")
832
-
833
- # Session ID: get from cookie (all tabs share same session)
834
- sid = request.cookies.get("ss_sid") or str(uuid.uuid4())
835
-
836
- t = session_ctx.set(sid)
837
- response = await call_next(request)
838
- session_ctx.reset(t)
839
-
840
- # Set cookie
841
- is_https = request.url.scheme == "https"
842
- response.set_cookie(
843
- "ss_sid",
844
- sid,
845
- httponly=True,
846
- secure=is_https,
847
- samesite="lax"
848
- )
849
-
850
- # Set native token cookie
851
- if self.native_token and not request.cookies.get("_native_token"):
852
- response.set_cookie(
853
- "_native_token",
854
- self.native_token,
855
- httponly=True,
856
- secure=is_https,
857
- samesite="strict"
858
- )
859
-
860
- return response
861
-
862
- @self.fastapi.get("/")
863
- async def index(request: Request):
864
- # Note: _theme_state, _selection_state, _animation_state and their updaters
865
- # are already initialized in __init__, no need to re-initialize here
866
-
867
- main_c, sidebar_c = self._render_all()
868
- store = get_session_store()
869
- t = store['theme']
870
-
871
- sidebar_style = "" if (sidebar_c or self.static_sidebar_order) else "display: none;"
872
- main_class = "" if (sidebar_c or self.static_sidebar_order) else "sidebar-collapsed"
873
-
874
- # Generate CSRF token
875
- # Get sid from context (set by middleware) instead of cookies (not set yet on first visit)
876
- try:
877
- sid = session_ctx.get()
878
- except LookupError:
879
- sid = request.cookies.get("ss_sid")
880
-
881
- csrf_token = self._generate_csrf_token(sid) if sid and self.csrf_enabled else ""
882
- csrf_script = f'<script>window._csrf_token = "{csrf_token}";</script>' if csrf_token else ""
883
-
884
- if self.debug_mode:
885
- print(f"[DEBUG] Session ID: {sid[:8] if sid else 'None'}...")
886
- print(f"[DEBUG] CSRF enabled: {self.csrf_enabled}")
887
- print(f"[DEBUG] CSRF token generated: {bool(csrf_token)}")
888
-
889
- # Debug flag injection
890
- debug_script = f'<script>window._debug_mode = {str(self.debug_mode).lower()};</script>'
891
-
892
- html = HTML_TEMPLATE.replace("%CONTENT%", main_c).replace("%SIDEBAR_CONTENT%", sidebar_c).replace("%SIDEBAR_STYLE%", sidebar_style).replace("%MAIN_CLASS%", main_class).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)
893
- return HTMLResponse(html)
894
-
895
- @self.fastapi.post("/action/{cid}")
896
- async def action(request: Request, cid: str):
897
- # Session ID: get from cookie
898
- sid = request.cookies.get("ss_sid")
899
-
900
- # CSRF verification
901
- if self.csrf_enabled:
902
- f = await request.form()
903
- csrf_token = f.get("_csrf_token") or request.headers.get("X-CSRF-Token")
904
-
905
- if not csrf_token or not self._verify_csrf_token(sid, csrf_token):
906
- from fastapi.responses import JSONResponse
907
- return JSONResponse(
908
- {"error": "Invalid CSRF token"},
909
- status_code=403
910
- )
911
- else:
912
- f = await request.form()
913
-
914
- v = f.get("value")
915
- store = get_session_store()
916
- act = store['actions'].get(cid) or self.static_actions.get(cid)
917
- if act:
918
- if not callable(act):
919
- # Debug: print what we got instead
920
- self.debug_print(f"ERROR: Action for {cid} is not callable. Got: {type(act)} = {repr(act)}")
921
- return HTMLResponse("")
922
-
923
- store['eval_queue'] = []
924
- act(v) if v is not None else act()
925
-
926
- dirty = self._get_dirty_rendered()
927
-
928
- # Separate clicked component from other updates
929
- clicked_component = None
930
- other_dirty = []
931
- for c in dirty:
932
- if c.id == cid:
933
- clicked_component = c
934
- else:
935
- other_dirty.append(c)
936
-
937
- # Re-render clicked component if not dirty
938
- if clicked_component is None:
939
- builder = store['builders'].get(cid) or self.static_builders.get(cid)
940
- if builder:
941
- clicked_component = builder()
942
-
943
- # Build response: clicked component HTML + OOB for others
944
- response_html = clicked_component.render() if clicked_component else ""
945
- response_html += self.lite_engine.wrap_oob(other_dirty)
946
-
947
- # Process Toasts
948
- toasts = store.get('toasts', [])
949
- if toasts:
950
- import html as html_lib
951
- toasts_json = json.dumps(toasts)
952
- toasts_escaped = html_lib.escape(toasts_json)
953
-
954
- toast_injector = f'''<div id="toast-injector" hx-swap-oob="true" data-toasts="{toasts_escaped}">
955
- <script>
956
- (function() {{
957
- var container = document.getElementById('toast-injector');
958
- if (!container) return;
959
- var toastsAttr = container.getAttribute('data-toasts');
960
- if (!toastsAttr) return;
961
- var toasts = JSON.parse(toastsAttr);
962
- toasts.forEach(function(t) {{
963
- if (typeof createToast === 'function') {{
964
- createToast(t.message, t.variant, t.icon);
965
- }}
966
- }});
967
- container.removeAttribute('data-toasts');
968
- }})();
969
- </script>
970
- </div>'''
971
- response_html += toast_injector
972
- store['toasts'] = []
973
-
974
- # Process Effects (Balloons, Snow)
975
- effects = store.get('effects', [])
976
- if effects:
977
- effects_json = json.dumps(effects)
978
- effect_injector = f'''<div id="effects-injector" hx-swap-oob="true" data-effects='{effects_json}'>
979
- <script>
980
- (function() {{
981
- const container = document.getElementById('effects-injector');
982
- if (!container) return;
983
- const effects = JSON.parse(container.getAttribute('data-effects'));
984
- effects.forEach(e => {{
985
- if (e === 'balloons') createBalloons();
986
- if (e === 'snow') createSnow();
987
- }});
988
- container.removeAttribute('data-effects');
989
- }})();
990
- </script>
991
- </div>'''
992
- response_html += effect_injector
993
- store['effects'] = []
994
-
995
- return HTMLResponse(response_html)
996
- return HTMLResponse("")
997
-
998
- @self.fastapi.websocket("/ws")
999
- async def ws(ws: WebSocket):
1000
- await ws.accept()
1001
-
1002
- # Session ID: get from cookie (all tabs share same session)
1003
- sid = ws.cookies.get("ss_sid") or str(uuid.uuid4())
1004
-
1005
- self.debug_print(f"[WEBSOCKET] Session: {sid[:8]}...")
1006
-
1007
- # Set session context (outside while loop - very important!)
1008
- t = session_ctx.set(sid)
1009
- self.ws_engine.sockets[sid] = ws
1010
-
1011
- # Message processing function
1012
- async def process_message(data):
1013
- if data.get('type') != 'click':
1014
- return
1015
-
1016
- # Debug WebSocket data
1017
- self.debug_print(f"[WEBSOCKET ACTION] CID: {data.get('id')}")
1018
- self.debug_print(f" Native mode: {self.native_token is not None}")
1019
- self.debug_print(f" CSRF enabled: {self.csrf_enabled}")
1020
- self.debug_print(f" Native token in payload: {data.get('_native_token')[:20] if data.get('_native_token') else None}...")
1021
-
1022
- # Native mode verification (high priority)
1023
- if self.native_token is not None:
1024
- native_token = data.get('_native_token')
1025
- if native_token != self.native_token:
1026
- self.debug_print(f" [X] Native token mismatch!")
1027
- await ws.send_json({"type": "error", "message": "Invalid native token"})
1028
- return
1029
- else:
1030
- self.debug_print(f" [OK] Native token valid - Skipping CSRF check")
1031
- else:
1032
- # CSRF verification for WebSocket (non-native only)
1033
- if self.csrf_enabled:
1034
- csrf_token = data.get('_csrf_token')
1035
- if not csrf_token or not self._verify_csrf_token(sid, csrf_token):
1036
- self.debug_print(f" [X] CSRF token invalid")
1037
- await ws.send_json({"type": "error", "message": "Invalid CSRF token"})
1038
- return
1039
- else:
1040
- self.debug_print(f" [OK] CSRF token valid")
1041
-
1042
- cid, v = data.get('id'), data.get('value')
1043
- store = get_session_store()
1044
- act = store['actions'].get(cid) or self.static_actions.get(cid)
1045
-
1046
- self.debug_print(f" Action found: {act is not None}")
1047
-
1048
- # Detect if this is a navigation action (nav menu click)
1049
- is_navigation = cid.startswith('nav_menu')
1050
-
1051
- if act:
1052
- store['eval_queue'] = []
1053
- self.debug_print(f" Executing action for CID: {cid} (navigation={is_navigation})...")
1054
- act(v) if v is not None else act()
1055
- self.debug_print(f" Action executed")
1056
-
1057
- for code in store.get('eval_queue', []):
1058
- await self.ws_engine.push_eval(sid, code)
1059
- store['eval_queue'] = []
1060
-
1061
- dirty = self._get_dirty_rendered()
1062
- self.debug_print(f" Dirty components: {len(dirty)} ({[c.id for c in dirty]})")
1063
-
1064
- # Send all dirty components via WebSocket
1065
- # Pass is_navigation flag to enable/disable smooth transitions
1066
- if dirty:
1067
- self.debug_print(f" Sending {len(dirty)} updates via WebSocket (navigation={is_navigation})...")
1068
- await self.ws_engine.push_updates(sid, dirty, is_navigation=is_navigation)
1069
- self.debug_print(f" [OK] Updates sent successfully")
1070
- else:
1071
- self.debug_print(f" [!] No dirty components found - nothing to update")
1072
-
1073
- try:
1074
- # Message processing loop
1075
- while True:
1076
- data = await ws.receive_json()
1077
- await process_message(data)
1078
- except WebSocketDisconnect:
1079
- if sid and sid in self.ws_engine.sockets:
1080
- del self.ws_engine.sockets[sid]
1081
- self.debug_print(f"[WEBSOCKET] Disconnected: {sid[:8]}...")
1082
- finally:
1083
- if t is not None:
1084
- session_ctx.reset(t)
1085
-
1086
- def _run_web_reload(self, args):
1087
- """Run with hot reload in web mode (process restart)"""
1088
- self.debug_print(f"[HOT RELOAD] Watching {os.getcwd()}...")
1089
-
1090
- iteration = 0
1091
- while True:
1092
- iteration += 1
1093
- # Prepare environment
1094
- env = os.environ.copy()
1095
- env["VIOLIT_WORKER"] = "1"
1096
-
1097
- # Start worker
1098
- self.debug_print(f"\n[Web Reload] Starting server (iteration {iteration})...", flush=True)
1099
- p = subprocess.Popen([sys.executable] + sys.argv, env=env)
1100
-
1101
- # Watch for changes
1102
- watcher = FileWatcher(debug_mode=self.debug_mode)
1103
- intentional_restart = False
1104
-
1105
- try:
1106
- while p.poll() is None:
1107
- if watcher.check():
1108
- self.debug_print("\n[Web Reload] 🔄 Reloading server...", flush=True)
1109
- intentional_restart = True
1110
- p.terminate()
1111
- try:
1112
- p.wait(timeout=2)
1113
- self.debug_print("[Web Reload] ✓ Server stopped gracefully", flush=True)
1114
- except subprocess.TimeoutExpired:
1115
- self.debug_print("[Web Reload] WARNING: Force killing server...", flush=True)
1116
- p.kill()
1117
- p.wait()
1118
- break
1119
- time.sleep(0.5)
1120
- except KeyboardInterrupt:
1121
- p.terminate()
1122
- sys.exit(0)
1123
-
1124
- # If it was an intentional restart, wait a bit so browser can detect server is down
1125
- if intentional_restart:
1126
- time.sleep(1.5) # Give browser time to detect server is down (increased for reliability)
1127
- continue
1128
-
1129
- # If process exited unexpectedly (crashed), wait for file change
1130
- if p.returncode is not None:
1131
- self.debug_print("[Web Reload] WARNING: Server exited unexpectedly. Waiting for file changes...", flush=True)
1132
- while not watcher.check():
1133
- time.sleep(0.5)
1134
- self.debug_print("[Web Reload] Reloading after crash...", flush=True)
1135
-
1136
- def _run_native_reload(self, args):
1137
- """Run with hot reload in desktop mode"""
1138
- # Generate security token for native mode
1139
- self.native_token = secrets.token_urlsafe(32)
1140
- self.is_native_mode = True
1141
-
1142
- self.debug_print(f"[HOT RELOAD] Desktop mode - Watching {os.getcwd()}...")
1143
-
1144
- # Shared state for the server process
1145
- server_process = [None]
1146
- should_exit = [False]
1147
-
1148
- def server_manager():
1149
- iteration = 0
1150
- while not should_exit[0]:
1151
- iteration += 1
1152
- env = os.environ.copy()
1153
- env["VIOLIT_WORKER"] = "1"
1154
- env["VIOLIT_SERVER_ONLY"] = "1"
1155
- env["VIOLIT_NATIVE_TOKEN"] = self.native_token
1156
- env["VIOLIT_NATIVE_MODE"] = "1"
1157
-
1158
- # Start server
1159
- self.debug_print(f"\n[Server Manager] Starting server (iteration {iteration})...", flush=True)
1160
- server_process[0] = subprocess.Popen(
1161
- [sys.executable] + sys.argv,
1162
- env=env,
1163
- stdout=subprocess.PIPE if iteration > 1 else None,
1164
- stderr=subprocess.STDOUT if iteration > 1 else None
1165
- )
1166
-
1167
- # Give server time to start
1168
- time.sleep(0.3)
1169
-
1170
- watcher = FileWatcher(debug_mode=self.debug_mode)
1171
-
1172
- # Watch loop
1173
- intentional_restart = False
1174
- while server_process[0].poll() is None and not should_exit[0]:
1175
- if watcher.check():
1176
- self.debug_print("\n[Server Manager] 🔄 Reloading server...", flush=True)
1177
- intentional_restart = True
1178
- server_process[0].terminate()
1179
- try:
1180
- server_process[0].wait(timeout=2)
1181
- self.debug_print("[Server Manager] ✓ Server stopped gracefully", flush=True)
1182
- except subprocess.TimeoutExpired:
1183
- self.debug_print("[Server Manager] WARNING: Force killing server...", flush=True)
1184
- server_process[0].kill()
1185
- server_process[0].wait()
1186
- break
1187
- time.sleep(0.5)
1188
-
1189
- # If it was an intentional restart, reload webview and continue
1190
- if intentional_restart:
1191
- # Wait for server to be ready
1192
- time.sleep(0.5)
1193
- # Reload webview
1194
- try:
1195
- if webview.windows:
1196
- webview.windows[0].load_url(f"http://127.0.0.1:{args.port}?_native_token={self.native_token}")
1197
- self.debug_print("[Server Manager] \u2713 Webview reloaded", flush=True)
1198
- except Exception as e:
1199
- self.debug_print(f"[Server Manager] \u26a0 Webview reload failed: {e}", flush=True)
1200
- continue
1201
-
1202
- # If exited unexpectedly (crashed), wait for file change
1203
- if server_process[0].poll() is not None and not should_exit[0]:
1204
- self.debug_print("[Server Manager] WARNING: Server exited unexpectedly. Waiting for file changes...", flush=True)
1205
- while not watcher.check() and not should_exit[0]:
1206
- time.sleep(0.5)
1207
-
1208
- # Start server manager thread
1209
- t = threading.Thread(target=server_manager, daemon=True)
1210
- t.start()
1211
-
1212
- # Patch webview to use custom icon (Windows)
1213
- self._patch_webview_icon()
1214
-
1215
- # Start WebView (Main Thread)
1216
- win_args = {
1217
- 'text_select': True,
1218
- 'width': self.width,
1219
- 'height': self.height,
1220
- 'on_top': self.on_top
1221
- }
1222
-
1223
- # Pass icon to start (for non-WinForms backends)
1224
- start_args = {}
1225
- sig_start = inspect.signature(webview.start)
1226
- if 'icon' in sig_start.parameters and self.app_icon:
1227
- start_args['icon'] = self.app_icon
1228
-
1229
- webview.create_window(self.app_title, f"http://127.0.0.1:{args.port}?_native_token={self.native_token}", **win_args)
1230
- webview.start(**start_args)
1231
-
1232
- # Cleanup
1233
- should_exit[0] = True
1234
- if server_process[0]:
1235
- try:
1236
- server_process[0].terminate()
1237
- except:
1238
- pass
1239
- sys.exit(0)
1240
-
1241
- # Broadcasting Methods (WebSocket-based real-time sync)
1242
- def broadcast_eval(self, js_code: str, exclude_current: bool = False):
1243
- self.broadcaster.eval_all(js_code, exclude_current=exclude_current)
1244
-
1245
- def broadcast_reload(self, exclude_current: bool = False):
1246
- self.broadcaster.reload_all(exclude_current=exclude_current)
1247
-
1248
- def broadcast_dom_update(self, container_id: str, html: str,
1249
- position: str = 'prepend', animate: bool = True,
1250
- exclude_current: bool = False):
1251
- self.broadcaster.broadcast_dom_update(
1252
- container_id, html, position, animate, exclude_current
1253
- )
1254
-
1255
- def broadcast_event(self, event_name: str, data: dict,
1256
- exclude_current: bool = False):
1257
- self.broadcaster.broadcast_event(event_name, data, exclude_current)
1258
-
1259
- def broadcast_dom_remove(self, selector: str = None,
1260
- element_id: str = None,
1261
- data_attribute: tuple = None,
1262
- animate: bool = True,
1263
- animation_type: str = 'fade-right',
1264
- duration: int = 500,
1265
- exclude_current: bool = False):
1266
- """Remove DOM element from all clients with animation
1267
-
1268
- Example:
1269
- # Remove by ID
1270
- app.broadcast_dom_remove(element_id='my-element')
1271
-
1272
- # Remove by CSS selector
1273
- app.broadcast_dom_remove(selector='.old-posts')
1274
- """
1275
- self.broadcaster.broadcast_dom_remove(
1276
- selector=selector,
1277
- element_id=element_id,
1278
- data_attribute=data_attribute,
1279
- animate=animate,
1280
- animation_type=animation_type,
1281
- duration=duration,
1282
- exclude_current=exclude_current
1283
- )
1284
-
1285
- def run(self):
1286
- """Run the application"""
1287
- p = argparse.ArgumentParser()
1288
- p.add_argument("--native", action="store_true")
1289
- p.add_argument("--nosplash", action="store_true", help="Disable splash screen")
1290
- p.add_argument("--reload", action="store_true", help="Enable hot reload")
1291
- p.add_argument("--lite", action="store_true", help="Use Lite mode (HTMX)")
1292
- p.add_argument("--debug", action="store_true", help="Enable developer tools (native mode)")
1293
- p.add_argument("--port", type=int, default=8000)
1294
- args, _ = p.parse_known_args()
1295
-
1296
- if args.lite:
1297
- self.mode = "lite"
1298
- # Also create lite engine if not already created
1299
- if self.lite_engine is None:
1300
- from .engine import LiteEngine
1301
- self.lite_engine = LiteEngine()
1302
-
1303
- # Handle internal env var to force "Server Only" mode (for native reload)
1304
- if os.environ.get("VIOLIT_SERVER_ONLY"):
1305
- args.native = False
1306
-
1307
- # Hot Reload Manager Logic
1308
- if args.reload and not os.environ.get("VIOLIT_WORKER"):
1309
- if args.native:
1310
- self._run_native_reload(args)
1311
- else:
1312
- self._run_web_reload(args)
1313
- return
1314
-
1315
- self.show_splash = not args.nosplash
1316
- if self.show_splash:
1317
- self._splash_html = """
1318
- <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;">
1319
- <sl-spinner style="font-size: 3rem; --indicator-color: var(--sl-primary); margin-bottom: 1rem;"></sl-spinner>
1320
- <div style="font-size:1.5rem;font-weight:600;color:var(--sl-text);" class="gradient-text">Loading...</div>
1321
- </div>
1322
- <script>
1323
- window.addEventListener('load', ()=>{
1324
- setTimeout(()=>{
1325
- const s=document.getElementById('splash');
1326
- if(s){
1327
- s.style.opacity=0;
1328
- setTimeout(()=>s.remove(), 400);
1329
- }
1330
- }, 800);
1331
- });
1332
- </script>
1333
- """
1334
-
1335
- if args.native:
1336
- # Generate security token for native mode
1337
- self.native_token = secrets.token_urlsafe(32)
1338
- self.is_native_mode = True
1339
-
1340
- # Disable CSRF in native mode (local app security)
1341
- self.csrf_enabled = False
1342
- print("[SECURITY] CSRF protection disabled (native mode)")
1343
-
1344
- # Use a shared flag to signal server shutdown
1345
- server_shutdown = threading.Event()
1346
-
1347
- def srv():
1348
- # Run uvicorn in a way we can control or just let it die with daemon
1349
- # Since we use daemon=True, it should die when main thread dies.
1350
- # However, sometimes keeping the main thread alive for webview.start()
1351
- # might cause issues if not cleaned up properly.
1352
- # We'll stick to daemon=True but force exit after webview.start returns.
1353
- uvicorn.run(self.fastapi, host="127.0.0.1", port=args.port, log_level="warning")
1354
-
1355
- t = threading.Thread(target=srv, daemon=True)
1356
- t.start()
1357
-
1358
- time.sleep(1)
1359
-
1360
- # Patch webview to use custom icon (Windows)
1361
- self._patch_webview_icon()
1362
-
1363
- # Start WebView - This blocks until window is closed
1364
- win_args = {
1365
- 'text_select': True,
1366
- 'width': self.width,
1367
- 'height': self.height,
1368
- 'on_top': self.on_top
1369
- }
1370
-
1371
- # Pass icon and debug mode to start (for non-WinForms backends)
1372
- start_args = {}
1373
- sig_start = inspect.signature(webview.start)
1374
-
1375
- # Enable developer tools (when --debug flag is used)
1376
- if args.debug:
1377
- start_args['debug'] = True
1378
- print("🔍 Debug mode enabled: Press F12 or Ctrl+Shift+I to open developer tools")
1379
-
1380
- if 'icon' in sig_start.parameters and self.app_icon:
1381
- start_args['icon'] = self.app_icon
1382
-
1383
- # Add native token to URL for initial access
1384
- webview.create_window(self.app_title, f"http://127.0.0.1:{args.port}?_native_token={self.native_token}", **win_args)
1385
- webview.start(**start_args)
1386
-
1387
- # Force exit after window closes to kill the uvicorn thread immediately
1388
- print("App closed. Exiting...")
1389
- os._exit(0)
1390
- else:
1391
- uvicorn.run(self.fastapi, host="0.0.0.0", port=args.port)
1392
-
1393
-
1394
- HTML_TEMPLATE = """
1395
- <!DOCTYPE html>
1396
- <html class="%THEME_CLASS%">
1397
- <head>
1398
- <meta charset="UTF-8">
1399
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1400
- <meta name="htmx-config" content='{"defaultSwapDelay":0,"defaultSettleDelay":0}'>
1401
- <title>%TITLE%</title>
1402
- %CSRF_SCRIPT%
1403
- %DEBUG_SCRIPT%
1404
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/themes/light.css" />
1405
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/themes/dark.css" />
1406
- <script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/shoelace-autoloader.js"></script>
1407
- <script src="https://unpkg.com/htmx.org@1.9.10"></script>
1408
- <script src="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/dist/ag-grid-community.min.js"></script>
1409
- <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
1410
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
1411
- <style>
1412
- :root {
1413
- %CSS_VARS%
1414
- --sidebar-width: 300px;
1415
- }
1416
- sl-alert { --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary); }
1417
- sl-alert::part(base) { border: 1px solid var(--sl-border); }
1418
-
1419
- sl-button {
1420
- --sl-color-primary-500: var(--sl-primary);
1421
- --sl-color-primary-600: color-mix(in srgb, var(--sl-primary), black 10%);
1422
- }
1423
- 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; }
1424
-
1425
- /* Soft Animation Mode - Only for sidebar and page transitions */
1426
- body.anim-soft #sidebar { transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s ease, opacity 0.3s ease; }
1427
- body.anim-soft .page-container { animation: fade-in 0.3s ease-out; }
1428
-
1429
- /* Hard Animation Mode */
1430
- body.anim-hard *, body.anim-hard ::part(base) { transition: none !important; animation: none !important; }
1431
-
1432
- #root { display: flex; width: 100%; min-height: 100vh; }
1433
- #sidebar {
1434
- position: fixed;
1435
- top: 0;
1436
- left: 0;
1437
- width: var(--sidebar-width);
1438
- height: 100vh;
1439
- background: var(--sl-bg-card);
1440
- border-right: 1px solid var(--sl-border);
1441
- padding: 2rem 1rem;
1442
- display: flex;
1443
- flex-direction: column;
1444
- gap: 1rem;
1445
- overflow-y: auto;
1446
- overflow-x: hidden;
1447
- white-space: nowrap;
1448
- z-index: 1100;
1449
- }
1450
- #sidebar.collapsed { width: 0; padding: 2rem 0; border-right: none; opacity: 0; }
1451
-
1452
- #main {
1453
- flex: 1;
1454
- margin-left: var(--sidebar-width);
1455
- display: flex;
1456
- flex-direction: column;
1457
- align-items: center;
1458
- padding: 0 1.5rem 3rem 2.5rem;
1459
- transition: margin-left 0.3s ease, padding 0.3s ease;
1460
- }
1461
- #main.sidebar-collapsed { margin-left: 0; }
1462
- /* Chat input container positioning - respects sidebar */
1463
- .chat-input-container { left: var(--sidebar-width) !important; transition: left 0.3s ease; }
1464
- #sidebar.collapsed ~ #main .chat-input-container,
1465
- #main.sidebar-collapsed .chat-input-container { left: 0 !important; }
1466
-
1467
- #header { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; padding: 1rem 0; display: flex; align-items: center; }
1468
- #app { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; display: flex; flex-direction: column; gap: 1.5rem; }
1469
-
1470
- .fragment { display: flex; flex-direction: column; gap: 1.25rem; width: 100%; }
1471
- .page-container { display: flex; flex-direction: column; gap: 1rem; width: 100%; }
1472
- .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; }
1473
-
1474
- /* Widget spacing - natural breathing room */
1475
- .page-container > div { margin-bottom: 0.5rem; }
1476
-
1477
- /* Headings need more space above to separate sections */
1478
- h1, h2, h3 { font-weight: 600; margin: 0; }
1479
- h1 { font-size: 2.25rem; line-height: 1.2; margin-bottom: 1rem; }
1480
- h2 { font-size: 1.5rem; line-height: 1.3; margin-top: 1.5rem; margin-bottom: 0.75rem; }
1481
- h3 { font-size: 1.25rem; line-height: 1.4; margin-top: 1.25rem; margin-bottom: 0.5rem; }
1482
- .page-container > h1:first-child, .page-container > h2:first-child, .page-container > h3:first-child,
1483
- h1:first-child, h2:first-child, h3:first-child { margin-top: 0; }
1484
-
1485
- /* Shoelace component spacing */
1486
- sl-input, sl-select, sl-textarea, sl-range, sl-checkbox, sl-switch, sl-radio-group, sl-color-picker {
1487
- display: block;
1488
- margin-bottom: 1rem;
1489
- }
1490
- sl-alert {
1491
- display: block;
1492
- margin-bottom: 1.25rem;
1493
- }
1494
- sl-button {
1495
- margin-top: 0.25rem;
1496
- margin-bottom: 0.5rem;
1497
- }
1498
- sl-divider, .divider {
1499
- --color: var(--sl-border);
1500
- margin: 1.5rem 0;
1501
- width: 100%;
1502
- }
1503
-
1504
- /* Column layouts */
1505
- .columns { display: flex; gap: 1rem; width: 100%; margin-bottom: 0.5rem; }
1506
- .column { flex: 1; display: flex; flex-direction: column; gap: 0.75rem; }
1507
-
1508
- /* List container - predefined layout for reactive lists */
1509
- .violit-list-container {
1510
- display: flex;
1511
- flex-direction: column;
1512
- gap: 1rem;
1513
- width: 100%;
1514
- }
1515
- .violit-list-container > * {
1516
- width: 100%;
1517
- }
1518
- .violit-list-container sl-card {
1519
- width: 100%;
1520
- }
1521
-
1522
- .gradient-text { background: linear-gradient(to right, var(--sl-primary), var(--sl-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
1523
- .text-muted { color: var(--sl-text-muted); }
1524
- .metric-label { color: var(--sl-text-muted); font-size: 0.875rem; margin-bottom: 0.25rem; }
1525
- .metric-value { font-size: 2rem; font-weight: 600; }
1526
- .no-select { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; }
1527
-
1528
- @keyframes fade-in {
1529
- from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
1530
- to { opacity: 1; transform: translateY(0); filter: blur(0); }
1531
- }
1532
-
1533
- /* Animations for Balloons and Snow */
1534
- @keyframes float-up {
1535
- 0% { transform: translateY(var(--start-y, 100vh)) rotate(0deg); opacity: 1; }
1536
- 100% { transform: translateY(-20vh) rotate(360deg); opacity: 0; }
1537
- }
1538
- @keyframes fall-down {
1539
- 0% { transform: translateY(-10vh) rotate(0deg); opacity: 0; }
1540
- 10% { opacity: 1; }
1541
- 90% { opacity: 1; }
1542
- 100% { transform: translateY(110vh) rotate(360deg); opacity: 0; }
1543
- }
1544
- .balloon, .snowflake {
1545
- position: fixed;
1546
- z-index: 9999;
1547
- pointer-events: none;
1548
- font-size: 2rem;
1549
- user-select: none;
1550
- }
1551
- .balloon { animation: float-up var(--duration) linear forwards; }
1552
- .snowflake { animation: fall-down var(--duration) linear forwards; }
1553
- </style>
1554
- <script>
1555
- const mode = "%MODE%";
1556
-
1557
- // Debug logging helper
1558
- const debugLog = (...args) => {
1559
- if (window._debug_mode) {
1560
- console.log(...args);
1561
- }
1562
- };
1563
-
1564
- // [LOCK] HTMX에 CSRF 토큰 자동 추가 (Lite Mode)
1565
- if (mode === 'lite' && window._csrf_token) {
1566
- document.addEventListener('DOMContentLoaded', function() {
1567
- document.body.addEventListener('htmx:configRequest', function(evt) {
1568
- evt.detail.parameters['_csrf_token'] = window._csrf_token;
1569
- });
1570
- });
1571
- }
1572
-
1573
- // Helper to clean up Plotly instances before removing elements
1574
- function purgePlotly(container) {
1575
- if (!window.Plotly) return;
1576
- if (container.classList && container.classList.contains('js-plotly-plot')) {
1577
- Plotly.purge(container);
1578
- }
1579
- if (container.querySelectorAll) {
1580
- container.querySelectorAll('.js-plotly-plot').forEach(p => Plotly.purge(p));
1581
- }
1582
- }
1583
-
1584
- if (mode === 'ws') {
1585
- // [FIX] Pre-define sendAction with queue to handle clicks before WebSocket connects
1586
- // Use window properties for debugging access
1587
- window._wsReady = false;
1588
- window._actionQueue = [];
1589
- window._ws = null;
1590
-
1591
- // Define sendAction IMMEDIATELY (before WebSocket connection)
1592
- window.sendAction = (cid, val) => {
1593
- debugLog(`[sendAction] Called with cid=${cid}, val=${val}`);
1594
-
1595
- const payload = {
1596
- type: 'click',
1597
- id: cid,
1598
- value: val
1599
- };
1600
-
1601
- // CSRF 토큰 추가
1602
- if (window._csrf_token) {
1603
- payload._csrf_token = window._csrf_token;
1604
- }
1605
-
1606
- // [SECURE] Native 토큰 추가 (pywebview에서 필요)
1607
- const urlParams = new URLSearchParams(window.location.search);
1608
- const nativeToken = urlParams.get('_native_token');
1609
- if (nativeToken) {
1610
- payload._native_token = nativeToken;
1611
- }
1612
-
1613
- // Check if this is a navigation menu click (nav_menu_X)
1614
- if (cid.startsWith('nav_menu')) {
1615
- // Update URL hash to reflect current page
1616
- // val is "page_reactive-logic", we make it #reactive-logic
1617
- const pageName = val.replace('page_', '');
1618
- window.location.hash = pageName;
1619
- debugLog(`🔗 Updated Hash: #${pageName}`);
1620
- }
1621
-
1622
- // Queue action if WebSocket not ready, otherwise send immediately
1623
- if (!window._wsReady || !window._ws) {
1624
- debugLog(`⏳ WebSocket not ready (wsReady=${window._wsReady}, ws=${!!window._ws}), queueing action: ${cid}`);
1625
- window._actionQueue.push(payload);
1626
- } else {
1627
- debugLog(`✅ Sending action to server: ${cid}`);
1628
- window._ws.send(JSON.stringify(payload));
1629
- }
1630
- };
1631
-
1632
- // Now connect WebSocket
1633
- window._ws = new WebSocket((location.protocol === 'https:' ? 'wss:' : 'ws:') + "//" + location.host + "/ws");
1634
-
1635
- // Auto-reconnect/reload logic
1636
- window._ws.onclose = () => {
1637
- window._wsReady = false;
1638
- debugLog("🔌 Connection lost. Auto-reloading...");
1639
-
1640
- const checkServer = () => {
1641
- fetch(location.href)
1642
- .then(r => {
1643
- if(r.ok) {
1644
- debugLog("✓ Server back online. Reloading...");
1645
- window.location.reload();
1646
- } else {
1647
- setTimeout(checkServer, 300);
1648
- }
1649
- })
1650
- .catch(() => setTimeout(checkServer, 300));
1651
- };
1652
- setTimeout(checkServer, 300);
1653
- };
1654
-
1655
- // CRITICAL: Restore from hash ONLY after WebSocket is connected
1656
- window._ws.onopen = () => {
1657
- debugLog("✅ [WebSocket] Connected successfully!");
1658
- window._wsReady = true;
1659
-
1660
- // Process queued actions
1661
- if (window._actionQueue.length > 0) {
1662
- debugLog(`📤 Processing ${window._actionQueue.length} queued action(s)...`);
1663
- window._actionQueue.forEach(payload => {
1664
- window._ws.send(JSON.stringify(payload));
1665
- });
1666
- window._actionQueue.length = 0; // Clear queue
1667
- }
1668
-
1669
- // Restore from hash after processing queue
1670
- setTimeout(restoreFromHash, 100);
1671
- };
1672
-
1673
- // Handle WebSocket errors
1674
- window._ws.onerror = (error) => {
1675
- window._wsReady = false;
1676
- debugLog("❌ WebSocket error:", error);
1677
- };
1678
-
1679
- window._ws.onmessage = (e) => {
1680
- debugLog("[WebSocket] Message received");
1681
- const msg = JSON.parse(e.data);
1682
- if(msg.type === 'update') {
1683
- // Check if this is a navigation update (page transition)
1684
- // Server sends isNavigation flag based on action type
1685
- const isNavigation = msg.isNavigation === true;
1686
-
1687
- // Helper function to apply updates
1688
- const applyUpdates = (items) => {
1689
- items.forEach(item => {
1690
- const el = document.getElementById(item.id);
1691
-
1692
- // Focus Guard: Skip update if element is focused input to prevent interrupting typing
1693
- if (document.activeElement && el) {
1694
- const isSelfOrChild = document.activeElement.id === item.id || el.contains(document.activeElement);
1695
- const isShadowChild = document.activeElement.closest && document.activeElement.closest(`#${item.id}`);
1696
-
1697
- if (isSelfOrChild || isShadowChild) {
1698
- // Check if it's actually an input that needs protection
1699
- const tag = document.activeElement.tagName.toLowerCase();
1700
- const isInput = tag === 'input' || tag === 'textarea' || tag.startsWith('sl-input') || tag.startsWith('sl-textarea');
1701
-
1702
- // If it's an input, block update. If it's a button (nav menu), ALLOW update.
1703
- if (isInput) {
1704
- return;
1705
- }
1706
- }
1707
- }
1708
-
1709
- if(el) {
1710
- // Smart update for specific widget types to preserve animations/instances
1711
- const widgetType = item.id.split('_')[0];
1712
- let smartUpdated = false;
1713
-
1714
- // Checkbox/Toggle: Update checked property only (preserve animation)
1715
- if (widgetType === 'checkbox' || widgetType === 'toggle') {
1716
- // Parse new HTML to extract checked state
1717
- const temp = document.createElement('div');
1718
- temp.innerHTML = item.html;
1719
- const newCheckbox = temp.querySelector('sl-checkbox, sl-switch');
1720
-
1721
- if (newCheckbox) {
1722
- // Find the actual checkbox element (may be direct or nested)
1723
- const checkboxEl = el.tagName && (el.tagName.toLowerCase() === 'sl-checkbox' || el.tagName.toLowerCase() === 'sl-switch')
1724
- ? el
1725
- : el.querySelector('sl-checkbox, sl-switch');
1726
-
1727
- if (checkboxEl) {
1728
- const shouldBeChecked = newCheckbox.hasAttribute('checked');
1729
- // Only update if different to avoid interrupting user interaction
1730
- if (checkboxEl.checked !== shouldBeChecked) {
1731
- checkboxEl.checked = shouldBeChecked;
1732
- }
1733
- smartUpdated = true;
1734
- }
1735
- }
1736
- }
1737
-
1738
- // Data Editor: Update AG Grid data only
1739
- if (widgetType === 'data' && item.id.includes('editor')) {
1740
- // item.id is like "data_editor_xxx_wrapper", extract base cid
1741
- const baseCid = item.id.replace('_wrapper', '');
1742
- const gridApi = window['gridApi_' + baseCid];
1743
- if (gridApi) {
1744
- // Extract rowData from new HTML
1745
- const match = item.html.match(/rowData:\s*(\[.*?\])/s);
1746
- if (match) {
1747
- try {
1748
- const newData = JSON.parse(match[1]);
1749
- gridApi.setRowData(newData);
1750
- smartUpdated = true;
1751
- } catch (e) {
1752
- console.error('Failed to parse AG Grid data:', e);
1753
- }
1754
- }
1755
- }
1756
- }
1757
-
1758
- // Default: Full DOM replacement
1759
- if (!smartUpdated) {
1760
- purgePlotly(el);
1761
- el.outerHTML = item.html;
1762
-
1763
- // Execute scripts
1764
- const temp = document.createElement('div');
1765
- temp.innerHTML = item.html;
1766
- temp.querySelectorAll('script').forEach(s => {
1767
- const script = document.createElement('script');
1768
- script.textContent = s.textContent;
1769
- document.body.appendChild(script);
1770
- script.remove();
1771
- });
1772
- }
1773
- }
1774
- });
1775
- };
1776
-
1777
- // Apply updates: use View Transitions ONLY for navigation
1778
- if (isNavigation && document.body.classList.contains('anim-soft') && document.startViewTransition) {
1779
- // Navigation: smooth page transition
1780
- document.startViewTransition(() => applyUpdates(msg.payload));
1781
- } else {
1782
- // Regular updates: apply immediately without animation for snappy response
1783
- applyUpdates(msg.payload);
1784
- }
1785
- } else if (msg.type === 'eval') {
1786
- const func = new Function(msg.code);
1787
- func();
1788
- }
1789
- };
1790
- } else {
1791
- // Lite Mode (HTMX) specifics
1792
- document.addEventListener('DOMContentLoaded', () => {
1793
- document.body.addEventListener('htmx:beforeSwap', function(evt) {
1794
- if (evt.detail.target) {
1795
- purgePlotly(evt.detail.target);
1796
- }
1797
- });
1798
-
1799
- // Hot reload support for lite mode: poll server health
1800
- let serverAlive = true;
1801
- const checkServerHealth = () => {
1802
- // Add timestamp to prevent caching
1803
- const pollUrl = location.href.split('#')[0] + (location.href.indexOf('?') === -1 ? '?' : '&') + '_t=' + Date.now();
1804
-
1805
- fetch(pollUrl, { cache: 'no-store' })
1806
- .then(r => {
1807
- if (r.ok) {
1808
- if (!serverAlive) {
1809
- debugLog("✓ Server back online. Reloading...");
1810
- document.body.style.opacity = '1'; // Restore opacity
1811
- window.location.reload();
1812
- }
1813
- serverAlive = true;
1814
- // Ensure opacity is 1 if server is alive
1815
- document.body.style.opacity = '1';
1816
- document.body.style.pointerEvents = 'auto';
1817
- } else {
1818
- throw new Error('Server error');
1819
- }
1820
- })
1821
- .catch(() => {
1822
- if (serverAlive) {
1823
- debugLog("🔌 Server down. Waiting for restart...");
1824
- // Dim the page to indicate connection lost
1825
- document.body.style.transition = 'opacity 0.5s';
1826
- document.body.style.opacity = '0.5';
1827
- document.body.style.pointerEvents = 'none';
1828
- }
1829
- serverAlive = false;
1830
- });
1831
- };
1832
- setInterval(checkServerHealth, 200);
1833
- });
1834
- }
1835
-
1836
- function toggleSidebar() {
1837
- const sb = document.getElementById('sidebar');
1838
- const main = document.getElementById('main');
1839
- const chatInput = document.querySelector('.chat-input-container');
1840
- sb.classList.toggle('collapsed');
1841
- main.classList.toggle('sidebar-collapsed');
1842
- // Also adjust chat input container if present
1843
- if (chatInput) {
1844
- chatInput.style.left = sb.classList.contains('collapsed') ? '0' : '300px';
1845
- }
1846
- }
1847
-
1848
- function createToast(message, variant = 'primary', icon = 'info-circle') {
1849
- const variantColors = { primary: '#0ea5e9', success: '#10b981', warning: '#f59e0b', danger: '#ef4444' };
1850
- const toast = document.createElement('div');
1851
- // Use CSS variables directly so theme changes are reflected automatically
1852
- 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;`;
1853
- 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>`;
1854
- document.body.appendChild(toast);
1855
- requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateX(0)'; });
1856
- setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(400px)'; setTimeout(() => toast.remove(), 300); }, 3300);
1857
- }
1858
- function createBalloons() {
1859
- const emojis = ['🎈', '🎈', '🎈', '✨', '🎈', '🎈'];
1860
- for (let i = 0; i < 60; i++) {
1861
- const b = document.createElement('div');
1862
- b.className = 'balloon';
1863
- b.textContent = emojis[Math.floor(Math.random() * emojis.length)];
1864
- b.style.left = Math.random() * 100 + 'vw';
1865
- const startY = 10;
1866
- b.style.setProperty('--start-y', startY + 'vh');
1867
- const duration = 3 + Math.random() * 3;
1868
- b.style.setProperty('--duration', duration + 's');
1869
- b.style.animationDelay = Math.random() * 0.2 + 's';
1870
- document.body.appendChild(b);
1871
- setTimeout(() => b.remove(), (duration + 1) * 1000);
1872
- }
1873
- }
1874
- function createSnow() {
1875
- const emojis = ['❄️', '❅', '❆', '❄️'];
1876
- for (let i = 0; i < 50; i++) {
1877
- const s = document.createElement('div');
1878
- s.className = 'snowflake';
1879
- s.textContent = emojis[Math.floor(Math.random() * emojis.length)];
1880
- s.style.left = Math.random() * 100 + 'vw';
1881
- const duration = 4 + Math.random() * 4;
1882
- s.style.setProperty('--duration', duration + 's');
1883
- s.style.animationDelay = Math.random() * 1.0 + 's';
1884
- document.body.appendChild(s);
1885
- setTimeout(() => s.remove(), (duration + 5) * 1000);
1886
- }
1887
- }
1888
-
1889
- // Restore state from URL Hash (or force Home if no hash)
1890
- function restoreFromHash() {
1891
- // 🔄 URL 해시 디코딩 (한글 등 인코딩된 문자 처리)
1892
- let hash = window.location.hash.substring(1); // Remove #
1893
- try {
1894
- hash = decodeURIComponent(hash);
1895
- } catch (e) {
1896
- debugLog('Hash decode error:', e);
1897
- }
1898
-
1899
- // If no hash, force navigation to Home to reset server state
1900
- if (!hash || hash === 'home' || hash === '홈') {
1901
- debugLog('🏠 No hash - forcing Home page');
1902
- const tryClickHome = (attempts = 0) => {
1903
- if (attempts >= 20) return;
1904
- const navButtons = document.querySelectorAll('#sidebar sl-button');
1905
- if (navButtons.length > 0) {
1906
- const homeBtn = navButtons[0]; // First button is Home
1907
- if (homeBtn.getAttribute('variant') !== 'primary') {
1908
- homeBtn.click();
1909
- debugLog('🏠 Clicked Home button');
1910
- }
1911
- } else {
1912
- setTimeout(() => tryClickHome(attempts + 1), 100);
1913
- }
1914
- };
1915
- tryClickHome();
1916
- return;
1917
- }
1918
-
1919
- const targetKey = 'page_' + hash;
1920
- debugLog(`📍 Restoring from Hash: "${hash}" (key: ${targetKey})`);
1921
-
1922
- const tryRestore = (attempts = 0) => {
1923
- // Stop after 5 seconds
1924
- if (attempts >= 50) {
1925
- console.warn(`⚠ Failed to restore hash "${hash}"`);
1926
- return;
1927
- }
1928
-
1929
- const navButtons = document.querySelectorAll('#sidebar sl-button');
1930
- if (navButtons.length === 0) {
1931
- setTimeout(() => tryRestore(attempts + 1), 100);
1932
- return;
1933
- }
1934
-
1935
- for (let btn of navButtons) {
1936
- const onclick = btn.getAttribute('onclick') || '';
1937
- const hxVals = btn.getAttribute('hx-vals') || '';
1938
-
1939
- // Match either onclick (WS mode) or hx-vals (Lite mode)
1940
- if (onclick.includes(targetKey) || hxVals.includes(targetKey)) {
1941
- debugLog(`✓ Found target button for hash "${hash}". Clicking...`);
1942
-
1943
- // Check if already active to avoid redundant clicks
1944
- if (btn.getAttribute('variant') === 'primary') {
1945
- debugLog(' - Already active, skipping click.');
1946
- return;
1947
- }
1948
-
1949
- btn.click();
1950
- return;
1951
- }
1952
- }
1953
-
1954
- // Keep retrying in case the specific button hasn't rendered yet (unlikely if container exists)
1955
- setTimeout(() => tryRestore(attempts + 1), 100);
1956
- };
1957
-
1958
- tryRestore();
1959
- }
1960
-
1961
- // Note: For ws mode, restoreFromHash is called from ws.onopen
1962
- // For lite mode, call it on load:
1963
- if (mode !== 'ws') {
1964
- if (document.readyState === 'loading') {
1965
- document.addEventListener('DOMContentLoaded', () => setTimeout(restoreFromHash, 200));
1966
- } else {
1967
- setTimeout(restoreFromHash, 200);
1968
- }
1969
- }
1970
- </script>
1971
- </head>
1972
- <body>
1973
- %SPLASH%
1974
- <div id="root">
1975
- <div id="sidebar" style="%SIDEBAR_STYLE%">
1976
- %SIDEBAR_CONTENT%
1977
- </div>
1978
- <div id="main" class="%MAIN_CLASS%">
1979
- <div id="header">
1980
- <sl-icon-button name="list" style="font-size: 1.5rem; color: var(--sl-text);" onclick="toggleSidebar()"></sl-icon-button>
1981
- </div>
1982
- <div id="app">%CONTENT%</div>
1983
- </div>
1984
- </div>
1985
- <div id="toast-injector" style="display:none;"></div>
1986
- </body>
1987
- </html>
1988
- """
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, page_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
+ # Streamlit-style: Generate stable key from caller's location
275
+ frame = inspect.currentframe()
276
+ try:
277
+ caller_frame = frame.f_back
278
+ filename = os.path.basename(caller_frame.f_code.co_filename)
279
+ lineno = caller_frame.f_lineno
280
+ # Create stable key: filename_linenumber
281
+ name = f"state_{filename}_{lineno}"
282
+ finally:
283
+ del frame # Avoid reference cycles
284
+ else:
285
+ name = key
286
+ return State(name, default_value)
287
+
288
+ def _get_next_cid(self, prefix: str) -> str:
289
+ """Generate next component ID"""
290
+ store = get_session_store()
291
+ cid = f"{prefix}_{store['component_count']}"
292
+ store['component_count'] += 1
293
+ return cid
294
+
295
+ def _register_component(self, cid: str, builder: Callable, action: Optional[Callable] = None):
296
+ """Register a component with builder and optional action"""
297
+ store = get_session_store()
298
+ sid = session_ctx.get()
299
+
300
+ store['builders'][cid] = builder
301
+ if action:
302
+ store['actions'][cid] = action
303
+
304
+ curr_frag = fragment_ctx.get()
305
+ l_ctx = layout_ctx.get()
306
+
307
+ if curr_frag:
308
+ # Inside a fragment
309
+ # IMPORTANT: Still respect sidebar context even inside fragments!
310
+ if l_ctx == "sidebar":
311
+ # Register to sidebar, not fragment
312
+ if sid is None:
313
+ self.static_builders[cid] = builder
314
+ if action: self.static_actions[cid] = action
315
+ if cid not in self.static_sidebar_order:
316
+ self.static_sidebar_order.append(cid)
317
+ else:
318
+ if action: store['actions'][cid] = action
319
+ store['sidebar_order'].append(cid)
320
+ else:
321
+ # Normal fragment component registration
322
+ if sid is None:
323
+ # Static nesting (e.g. inside columns/expander at top level)
324
+ if curr_frag not in self.static_fragment_components:
325
+ self.static_fragment_components[curr_frag] = []
326
+ self.static_fragment_components[curr_frag].append((cid, builder))
327
+ # Store action and builder for components inside fragments
328
+ if action:
329
+ self.static_actions[cid] = action
330
+ self.static_builders[cid] = builder
331
+ else:
332
+ # Dynamic Nesting (Runtime)
333
+ if curr_frag not in store['fragment_components']:
334
+ store['fragment_components'][curr_frag] = []
335
+ store['fragment_components'][curr_frag].append((cid, builder))
336
+ else:
337
+ if sid is None:
338
+ # Static Root Registration
339
+ self.static_builders[cid] = builder
340
+ if action: self.static_actions[cid] = action
341
+
342
+ if l_ctx == "sidebar":
343
+ if cid not in self.static_sidebar_order:
344
+ self.static_sidebar_order.append(cid)
345
+ else:
346
+ if cid not in self.static_order:
347
+ self.static_order.append(cid)
348
+ else:
349
+ # Dynamic Root Registration
350
+ if action: store['actions'][cid] = action
351
+
352
+ if l_ctx == "sidebar":
353
+ store['sidebar_order'].append(cid)
354
+ else:
355
+ store['order'].append(cid)
356
+
357
+ def simple_card(self, content_fn: Union[Callable, str, State]):
358
+ """Display content in a simple card
359
+
360
+ Accepts State object, callable, or string content
361
+ """
362
+ cid = self._get_next_cid("simple_card")
363
+ def builder():
364
+ token = rendering_ctx.set(cid)
365
+ # Handle State object, callable, or direct value
366
+ if isinstance(content_fn, State):
367
+ val = content_fn.value
368
+ elif callable(content_fn):
369
+ val = content_fn()
370
+ else:
371
+ val = content_fn
372
+
373
+ if val is None:
374
+ val = "_No data_"
375
+
376
+ rendering_ctx.reset(token)
377
+ return Component("div", id=cid, content=str(val), class_="card")
378
+ self._register_component(cid, builder)
379
+
380
+ def fragment(self, func: Callable) -> Callable:
381
+ """Create a reactive fragment (decorator)
382
+
383
+ .. deprecated::
384
+ This method is deprecated. Please use alternative patterns for managing reactive content.
385
+
386
+ Always returns a wrapper that registers on call.
387
+ Call the decorated function to register and render it.
388
+ """
389
+ warnings.warn(
390
+ "@app.fragment is deprecated and will be removed in a future version. "
391
+ "Please consider using alternative patterns.",
392
+ DeprecationWarning,
393
+ stacklevel=2
394
+ )
395
+
396
+ fid = f"fragment_{self._fragment_count}"
397
+ self._fragment_count += 1
398
+
399
+ # Track if already registered
400
+ registered = [False]
401
+
402
+ def fragment_builder():
403
+ token = fragment_ctx.set(fid)
404
+ render_token = rendering_ctx.set(fid)
405
+ store = get_session_store()
406
+ store['fragment_components'][fid] = []
407
+
408
+ # Execute fragment logic
409
+ func()
410
+
411
+ # Render children
412
+ htmls = []
413
+ for cid, b in store['fragment_components'][fid]:
414
+ htmls.append(b().render())
415
+
416
+ fragment_ctx.reset(token)
417
+ rendering_ctx.reset(render_token)
418
+
419
+ inner = f'<div id="{fid}" class="fragment">{" ".join(htmls)}</div>'
420
+ return Component("div", id=f"{fid}_wrapper", content=inner)
421
+
422
+ # Store builder
423
+ self.static_builders[fid] = fragment_builder
424
+ self.static_fragments[fid] = func
425
+
426
+ def wrapper():
427
+ """Wrapper that registers fragment on first call"""
428
+ if registered[0]:
429
+ return
430
+ registered[0] = True
431
+
432
+ sid = session_ctx.get()
433
+ if sid is None:
434
+ # Static context: add to static_order
435
+ if fid not in self.static_order:
436
+ self.static_order.append(fid)
437
+ else:
438
+ # Dynamic context: add to dynamic order
439
+ self._register_component(fid, fragment_builder)
440
+
441
+ return wrapper
442
+
443
+ def reactivity(self, func: Optional[Callable] = None):
444
+ """Create a reactive scope for complex control flow
445
+
446
+ Can be used as:
447
+ 1. Decorator: @app.reactivity for function-wrapped reactive blocks
448
+ 2. Context Manager: with app.reactivity(): for inline reactive blocks (triggers page rerun)
449
+
450
+ Example (Decorator - Partial Rerun):
451
+ @app.reactivity
452
+ def my_reactive_block():
453
+ if count.value > 5:
454
+ app.success("Big!")
455
+ my_reactive_block()
456
+
457
+ Example (Context Manager - Page Rerun):
458
+ with app.reactivity():
459
+ if count.value > 5:
460
+ app.success("Big!")
461
+ """
462
+ if func is not None:
463
+ # Decorator mode: wrap function as a reactive fragment
464
+ # This enables PARTIAL RERUN of just this function
465
+ fid = f"reactivity_{self._fragment_count}"
466
+ self._fragment_count += 1
467
+
468
+ # Track if already registered
469
+ registered = [False]
470
+
471
+ def fragment_builder():
472
+ token = fragment_ctx.set(fid)
473
+ render_token = rendering_ctx.set(fid)
474
+ store = get_session_store()
475
+ store['fragment_components'][fid] = []
476
+
477
+ # Execute the user's function
478
+ func()
479
+
480
+ # Render children
481
+ htmls = []
482
+ for cid, b in store['fragment_components'][fid]:
483
+ htmls.append(b().render())
484
+
485
+ fragment_ctx.reset(token)
486
+ rendering_ctx.reset(render_token)
487
+
488
+ inner = f'<div id="{fid}" class="fragment">{" ".join(htmls)}</div>'
489
+ return Component("div", id=f"{fid}_wrapper", content=inner)
490
+
491
+ # Store builder
492
+ self.static_builders[fid] = fragment_builder
493
+ self.static_fragments[fid] = func
494
+
495
+ def wrapper():
496
+ """Wrapper that registers fragment on first call"""
497
+ sid = session_ctx.get()
498
+ if sid is None:
499
+ # Static context: register once
500
+ if registered[0]: return
501
+ registered[0] = True
502
+ if fid not in self.static_order:
503
+ self.static_order.append(fid)
504
+ else:
505
+ # Dynamic context: Always register to add to current order
506
+ self._register_component(fid, fragment_builder)
507
+
508
+ return wrapper
509
+
510
+ # Context manager mode
511
+ class ReactivityContext:
512
+ def __init__(ctx_self, app):
513
+ ctx_self.app = app
514
+ ctx_self.fid = None
515
+ ctx_self.fragment_token = None
516
+ # DON'T create new rendering_ctx - keep parent's!
517
+
518
+ def __enter__(ctx_self):
519
+ # Create a temporary fragment scope for component collection
520
+ ctx_self.fid = f"reactivity_{self._fragment_count}"
521
+ self._fragment_count += 1
522
+
523
+ # Set fragment context only (state access registers with parent)
524
+ ctx_self.fragment_token = fragment_ctx.set(ctx_self.fid)
525
+
526
+ # IMPORTANT: If inside a page, enable subscription to the page renderer
527
+ # This allows if/for blocks inside with app.reactivity(): to trigger page re-runs
528
+ p_ctx = page_ctx.get()
529
+ ctx_self.rendering_token = None
530
+ if p_ctx:
531
+ ctx_self.rendering_token = rendering_ctx.set(p_ctx)
532
+
533
+ store = get_session_store()
534
+ store['fragment_components'][ctx_self.fid] = []
535
+ return ctx_self
536
+
537
+ def __exit__(ctx_self, exc_type, exc_val, exc_tb):
538
+ store = get_session_store()
539
+
540
+ # Build the fragment
541
+ def reactivity_builder():
542
+ htmls = []
543
+ for cid, b in store['fragment_components'][ctx_self.fid]:
544
+ htmls.append(b().render())
545
+ inner = f'<div id="{ctx_self.fid}" class="fragment">{" ".join(htmls)}</div>'
546
+ return Component("div", id=f"{ctx_self.fid}_wrapper", content=inner)
547
+
548
+ fragment_ctx.reset(ctx_self.fragment_token)
549
+ if ctx_self.rendering_token:
550
+ rendering_ctx.reset(ctx_self.rendering_token)
551
+
552
+ # Register the reactivity scope as a component
553
+ self._register_component(ctx_self.fid, reactivity_builder)
554
+
555
+ return ReactivityContext(self)
556
+
557
+ def If(self, condition, then_block=None, else_block=None, *, then=None, else_=None):
558
+ """Reactive conditional rendering widget.
559
+
560
+ Args:
561
+ condition: Boolean or Callable[[], bool].
562
+ If callable (e.g. lambda: count.value > 5), it's re-evaluated on render.
563
+ then_block: Function to call when True
564
+ else_block: Function to call when False
565
+ """
566
+ # Resolve positional vs keyword
567
+ actual_then = then_block if then_block is not None else then
568
+ actual_else = else_block if else_block is not None else else_
569
+
570
+ cid = self._get_next_cid("if")
571
+
572
+ def if_builder():
573
+ # Set rendering context for dependency tracking
574
+ token = rendering_ctx.set(cid)
575
+ try:
576
+ # Evaluate condition dynamically
577
+ current_cond = condition
578
+ if callable(condition):
579
+ current_cond = condition()
580
+ elif hasattr(condition, 'value'):
581
+ current_cond = condition.value
582
+
583
+ if current_cond:
584
+ if actual_then:
585
+ store = get_session_store()
586
+ prev_order = store['order'].copy()
587
+ store['order'] = []
588
+
589
+ actual_then()
590
+
591
+ htmls = []
592
+ for child_cid in store['order']:
593
+ builder = store['builders'].get(child_cid) or self.static_builders.get(child_cid)
594
+ if builder:
595
+ htmls.append(builder().render())
596
+
597
+ store['order'] = prev_order
598
+ content = '\n'.join(htmls)
599
+ return Component("div", id=cid, content=content, class_="if-block if-then")
600
+ else:
601
+ if actual_else:
602
+ store = get_session_store()
603
+ prev_order = store['order'].copy()
604
+ store['order'] = []
605
+
606
+ actual_else()
607
+
608
+ htmls = []
609
+ for child_cid in store['order']:
610
+ builder = store['builders'].get(child_cid) or self.static_builders.get(child_cid)
611
+ if builder:
612
+ htmls.append(builder().render())
613
+
614
+ store['order'] = prev_order
615
+ content = '\n'.join(htmls)
616
+ return Component("div", id=cid, content=content, class_="if-block if-else")
617
+
618
+ return Component("div", id=cid, content="", class_="if-block if-empty")
619
+ finally:
620
+ rendering_ctx.reset(token)
621
+
622
+ self._register_component(cid, if_builder)
623
+
624
+ def For(self, items, render_fn=None, empty_fn=None, *, render=None, empty=None):
625
+ """Reactive loop rendering widget.
626
+
627
+ Args:
628
+ items: List, State, or Callable[[], List].
629
+ render_fn: Function(item) or Function(item, index)
630
+ empty_fn: Function when list is empty
631
+ """
632
+ # Resolve positional vs keyword
633
+ actual_render = render_fn if render_fn is not None else render
634
+ actual_empty = empty_fn if empty_fn is not None else empty
635
+
636
+ cid = self._get_next_cid("for")
637
+
638
+ def for_builder():
639
+ token = rendering_ctx.set(cid)
640
+ try:
641
+ store = get_session_store()
642
+
643
+ # Evaluate items dynamically
644
+ current_items = items
645
+ if hasattr(items, 'value'): # State object
646
+ current_items = items.value
647
+ elif callable(items): # Lambda
648
+ current_items = items()
649
+
650
+ # Check if empty
651
+ if not current_items or len(current_items) == 0:
652
+ if actual_empty:
653
+ prev_order = store['order'].copy()
654
+ store['order'] = []
655
+
656
+ actual_empty()
657
+
658
+ htmls = []
659
+ for child_cid in store['order']:
660
+ builder = store['builders'].get(child_cid) or self.static_builders.get(child_cid)
661
+ if builder:
662
+ htmls.append(builder().render())
663
+
664
+ store['order'] = prev_order
665
+ content = '\n'.join(htmls)
666
+ return Component("div", id=cid, content=content, class_="for-block for-empty")
667
+ else:
668
+ return Component("div", id=cid, content="", class_="for-block for-empty")
669
+
670
+ # Render each item
671
+ if actual_render:
672
+ all_htmls = []
673
+
674
+ for idx, item in enumerate(current_items):
675
+ prev_order = store['order'].copy()
676
+ store['order'] = []
677
+
678
+ # Try to call with (item, index), fall back to (item)
679
+ import inspect
680
+ sig = inspect.signature(actual_render)
681
+ if len(sig.parameters) >= 2:
682
+ actual_render(item, idx)
683
+ else:
684
+ actual_render(item)
685
+
686
+ for child_cid in store['order']:
687
+ builder = store['builders'].get(child_cid) or self.static_builders.get(child_cid)
688
+ if builder:
689
+ all_htmls.append(builder().render())
690
+
691
+ store['order'] = prev_order
692
+
693
+ content = '\n'.join(all_htmls)
694
+ return Component("div", id=cid, content=content, class_="for-block")
695
+
696
+ return Component("div", id=cid, content="", class_="for-block")
697
+ finally:
698
+ rendering_ctx.reset(token)
699
+
700
+ self._register_component(cid, for_builder)
701
+
702
+ def _render_all(self):
703
+ """Render all components"""
704
+ store = get_session_store()
705
+
706
+ main_html = []
707
+ sidebar_html = []
708
+
709
+ def render_cids(cids, target_list):
710
+ for cid in cids:
711
+ builder = store['builders'].get(cid) or self.static_builders.get(cid)
712
+ if builder:
713
+ target_list.append(builder().render())
714
+
715
+ # Static Components
716
+ render_cids(self.static_order, main_html)
717
+ render_cids(self.static_sidebar_order, sidebar_html)
718
+
719
+ # Dynamic Components
720
+ render_cids(store['order'], main_html)
721
+ render_cids(store['sidebar_order'], sidebar_html)
722
+
723
+ return "".join(main_html), "".join(sidebar_html)
724
+
725
+ def _get_dirty_rendered(self):
726
+ """Get components that need updating"""
727
+ store = get_session_store()
728
+ dirty_states = store.get('dirty_states', set())
729
+ aff = set()
730
+ for s in dirty_states: aff.update(store['tracker'].get_dirty_components(s))
731
+ store['dirty_states'] = set()
732
+
733
+ res = []
734
+ for cid in aff:
735
+ builder = store['builders'].get(cid) or self.static_builders.get(cid)
736
+ if builder:
737
+ res.append(builder())
738
+ return res
739
+
740
+ # Theme and settings methods
741
+ def set_theme(self, p):
742
+ """Set theme preset"""
743
+ import time
744
+ store = get_session_store()
745
+ store['theme'].set_preset(p)
746
+ if self._theme_state:
747
+ # Use timestamp to force dirty even if same theme selected twice
748
+ self._theme_state.set(f"{p}_{time.time()}")
749
+
750
+ def set_selection_mode(self, enabled: bool):
751
+ """Enable/disable text selection"""
752
+ if self._selection_state:
753
+ self._selection_state.set(enabled)
754
+
755
+ def set_animation_mode(self, mode: str):
756
+ """Set animation mode ('soft' or 'hard')"""
757
+ if self._animation_state:
758
+ self._animation_state.set(mode)
759
+
760
+ def set_primary_color(self, c):
761
+ """Set primary theme color"""
762
+ store = get_session_store()
763
+ store['theme'].set_color('primary', c)
764
+ if self._theme_state:
765
+ self._theme_state.set(str(time.time()))
766
+
767
+ def _selection_updater(self):
768
+ """Update selection mode"""
769
+ cid = "__selection_updater__"
770
+ def builder():
771
+ token = rendering_ctx.set(cid)
772
+ enabled = self._selection_state.value
773
+ rendering_ctx.reset(token)
774
+
775
+ action = "remove" if enabled else "add"
776
+ script = f"<script>document.body.classList.{action}('no-select');</script>"
777
+ return Component("div", id=cid, style="display:none", content=script)
778
+ self._register_component(cid, builder)
779
+
780
+ def _patch_webview_icon(self):
781
+ """Monkey-patch pywebview's WinForms BrowserForm to use custom icon"""
782
+ if os.name != 'nt' or not self.app_icon:
783
+ return
784
+
785
+ try:
786
+ from webview.platforms import winforms
787
+
788
+ # Store reference to icon path for closure
789
+ icon_path = self.app_icon
790
+
791
+ # Check if already patched
792
+ if hasattr(winforms.BrowserView.BrowserForm, '_violit_patched'):
793
+ return
794
+
795
+ # Get original __init__
796
+ original_init = winforms.BrowserView.BrowserForm.__init__
797
+
798
+ def patched_init(self, window, cache_dir):
799
+ """Patched __init__ that sets custom icon after original init"""
800
+ original_init(self, window, cache_dir)
801
+
802
+ try:
803
+ from System.Drawing import Icon as DotNetIcon
804
+ if os.path.exists(icon_path):
805
+ self.Icon = DotNetIcon(icon_path)
806
+ except Exception:
807
+ pass # Silently fail if icon can't be set
808
+
809
+ # Apply patch
810
+ winforms.BrowserView.BrowserForm.__init__ = patched_init
811
+ winforms.BrowserView.BrowserForm._violit_patched = True
812
+
813
+ except Exception:
814
+ pass # If patching fails, continue without custom icon
815
+
816
+ def _animation_updater(self):
817
+ """Update animation mode"""
818
+ cid = "__animation_updater__"
819
+ def builder():
820
+ token = rendering_ctx.set(cid)
821
+ mode = self._animation_state.value
822
+ rendering_ctx.reset(token)
823
+
824
+ script = f"<script>document.body.classList.remove('anim-soft', 'anim-hard'); document.body.classList.add('anim-{mode}');</script>"
825
+ return Component("div", id=cid, style="display:none", content=script)
826
+ self._register_component(cid, builder)
827
+
828
+ def _theme_updater(self):
829
+ """Update theme"""
830
+ cid = "__theme_updater__"
831
+ def builder():
832
+ token = rendering_ctx.set(cid)
833
+ _ = self._theme_state.value
834
+ rendering_ctx.reset(token)
835
+
836
+ store = get_session_store()
837
+ t = store['theme']
838
+ vars_str = t.to_css_vars()
839
+ cls = t.theme_class
840
+
841
+ script_content = f'''
842
+ <script>
843
+ (function() {{
844
+ document.documentElement.className = '{cls}';
845
+ const root = document.documentElement;
846
+ const vars = `{vars_str}`.split('\\n');
847
+ vars.forEach(v => {{
848
+ const parts = v.split(':');
849
+ if(parts.length === 2) {{
850
+ const key = parts[0].trim();
851
+ const val = parts[1].replace(';', '').trim();
852
+ root.style.setProperty(key, val);
853
+ }}
854
+ }});
855
+
856
+ // Update Extra CSS
857
+ let extraStyle = document.getElementById('theme-extra');
858
+ if (!extraStyle) {{
859
+ extraStyle = document.createElement('style');
860
+ extraStyle.id = 'theme-extra';
861
+ document.head.appendChild(extraStyle);
862
+ }}
863
+ extraStyle.textContent = `{t.extra_css}`;
864
+ }})();
865
+ </script>
866
+ '''
867
+ return Component("div", id=cid, style="display:none", content=script_content)
868
+ self._register_component(cid, builder)
869
+
870
+ def navigation(self, pages: List[Any], position="sidebar", auto_run=True, reactivity_mode=False):
871
+ """Create multi-page navigation
872
+
873
+ Args:
874
+ pages: List of Page objects or functions
875
+ position: 'sidebar' or 'top' (default: sidebar)
876
+ auto_run: Run logic immediately (default: True)
877
+ reactivity_mode: If True, treats each page as a reactive scope (auto pre-evaluates).
878
+ This allows standard 'if' statements to be reactive.
879
+ """
880
+ # Normalize pages
881
+ final_pages = []
882
+ for p in pages:
883
+ if isinstance(p, Page): final_pages.append(p)
884
+ elif callable(p): final_pages.append(Page(p))
885
+
886
+ if not final_pages: return None
887
+
888
+ # Singleton state for navigation
889
+ current_page_key_state = self.state(final_pages[0].key, key="__nav_selection__")
890
+
891
+ # Navigation Menu Builder
892
+ cid = self._get_next_cid("nav_menu")
893
+ nav_cid = cid # Capture for use in nav_action closure
894
+ def nav_builder():
895
+ token = rendering_ctx.set(cid)
896
+ curr = current_page_key_state.value
897
+
898
+ items = []
899
+ for p in final_pages:
900
+ is_active = p.key == curr
901
+ click_attr = ""
902
+ if self.mode == 'lite':
903
+ # Lite mode: update hash and HTMX post
904
+ page_hash = p.key.replace("page_", "")
905
+ click_attr = f'onclick="window.location.hash = \'{page_hash}\'" hx-post="/action/{cid}" hx-vals=\'{{"value": "{p.key}"}}\' hx-target="#{cid}" hx-swap="outerHTML"'
906
+ else:
907
+ # WebSocket mode (including native)
908
+ click_attr = f'onclick="window.sendAction(\'{cid}\', \'{p.key}\')"'
909
+
910
+ # Styling for active/inactive nav items
911
+ if is_active:
912
+ style = "width: 100%; justify-content: start; --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary);"
913
+ variant = "primary"
914
+ else:
915
+ style = "width: 100%; justify-content: start; --sl-color-neutral-700: var(--sl-text);"
916
+ variant = "text"
917
+
918
+ icon_html = f'<sl-icon name="{p.icon}" slot="prefix"></sl-icon> ' if p.icon else ""
919
+ items.append(f'<sl-button style="{style}" variant="{variant}" {click_attr}>{icon_html}{p.title}</sl-button>')
920
+
921
+ rendering_ctx.reset(token)
922
+ return Component("div", id=cid, content="<br>".join(items), class_="nav-container")
923
+
924
+ def nav_action(key):
925
+ current_page_key_state.set(key)
926
+
927
+ # Register Nav Component
928
+ if position == "sidebar":
929
+ token = layout_ctx.set("sidebar")
930
+ try:
931
+ self._register_component(cid, nav_builder, action=nav_action)
932
+ finally:
933
+ layout_ctx.reset(token)
934
+ else:
935
+ self._register_component(cid, nav_builder, action=nav_action)
936
+
937
+ # Return the runner wrapper
938
+ current_key = current_page_key_state.value
939
+
940
+ class PageRunner:
941
+ def __init__(self, app, page_state, pages_map, reactivity_mode):
942
+ self.app = app
943
+ self.state = page_state
944
+ self.pages_map = pages_map
945
+ self.reactivity_mode = reactivity_mode
946
+
947
+ def run(self):
948
+ # Progressive Mode: Register page renderer as a regular component
949
+ # Navigation state changes trigger page re-render.
950
+ # If reactivity_mode=True, we subscribe to state changes inside page function too.
951
+ cid = self.app._get_next_cid("page_renderer")
952
+
953
+ def page_builder():
954
+ # Set context ONLY for reading navigation state
955
+ token = rendering_ctx.set(cid)
956
+
957
+ # Store Current Page Renderer CID for Reactivity Blocks
958
+ p_token = page_ctx.set(cid)
959
+
960
+ try:
961
+ key = self.state.value # Subscribe to navigation state
962
+
963
+ # Execute the current page function
964
+ p = self.pages_map.get(key)
965
+ if p:
966
+ # Collect components from the page
967
+ store = get_session_store()
968
+ # Clear previous dynamic order for this page render
969
+ previous_order = store['order'].copy()
970
+ previous_fragments = {k: v.copy() for k, v in store['fragment_components'].items()}
971
+ store['order'] = []
972
+ store['fragment_components'] = {} # Clear fragments to prevent duplicates
973
+
974
+ try:
975
+ # Start executing page function
976
+ # If reactivity_mode is False, reset context (default, non-reactive page script)
977
+ # If reactivity_mode is True, KEEP context (page script registers dependencies on page_renderer)
978
+ if not self.reactivity_mode:
979
+ rendering_ctx.reset(token)
980
+
981
+ p.entry_point()
982
+
983
+ # Re-enable rendering_ctx if it was reset
984
+ if not self.reactivity_mode:
985
+ token = rendering_ctx.set(cid)
986
+
987
+ htmls = []
988
+ for page_cid in store['order']:
989
+ builder = store['builders'].get(page_cid) or self.app.static_builders.get(page_cid)
990
+ if builder:
991
+ htmls.append(builder().render())
992
+
993
+ content = '\n'.join(htmls)
994
+ return Component("div", id=cid, content=content, class_="page-container")
995
+ finally:
996
+ # Restore previous state (always, even on exception)
997
+ store['order'] = previous_order
998
+ store['fragment_components'] = previous_fragments
999
+
1000
+ return Component("div", id=cid, content="", class_="page-container")
1001
+ finally:
1002
+ # Ensure context is reset
1003
+ if rendering_ctx.get() == cid:
1004
+ rendering_ctx.reset(token)
1005
+ page_ctx.reset(p_token)
1006
+
1007
+ # Register the page renderer as a regular component
1008
+ self.app._register_component(cid, page_builder)
1009
+
1010
+
1011
+ page_runner = PageRunner(self, current_page_key_state, {p.key: p for p in final_pages}, reactivity_mode)
1012
+
1013
+ # Auto-run if enabled
1014
+ if auto_run:
1015
+ page_runner.run()
1016
+
1017
+ return page_runner
1018
+
1019
+ # --- Routes ---
1020
+ def _setup_routes(self):
1021
+ """Setup FastAPI routes"""
1022
+ @self.fastapi.middleware("http")
1023
+ async def mw(request: Request, call_next):
1024
+ # Native mode security: Block web browser access
1025
+ if self.native_token is not None:
1026
+ token_from_request = request.query_params.get("_native_token")
1027
+ token_from_cookie = request.cookies.get("_native_token")
1028
+ user_agent = request.headers.get("user-agent", "")
1029
+
1030
+ # Debug logging for security check
1031
+ self.debug_print(f"[NATIVE SECURITY CHECK]")
1032
+ self.debug_print(f" Token from request: {token_from_request[:20] if token_from_request else None}...")
1033
+ self.debug_print(f" Token from cookie: {token_from_cookie[:20] if token_from_cookie else None}...")
1034
+ self.debug_print(f" Expected token: {self.native_token[:20]}...")
1035
+ self.debug_print(f" User-Agent: {user_agent}")
1036
+
1037
+ # Verify token
1038
+ is_valid_token = (token_from_request == self.native_token or token_from_cookie == self.native_token)
1039
+
1040
+ # Block if token is invalid
1041
+ if not is_valid_token:
1042
+ from fastapi.responses import HTMLResponse
1043
+ self.debug_print(f" [X] ACCESS DENIED - Invalid or missing token")
1044
+ return HTMLResponse(
1045
+ content="""
1046
+ <html>
1047
+ <head><title>Access Denied</title></head>
1048
+ <body style="font-family: system-ui; padding: 2rem; text-align: center;">
1049
+ <h1>[LOCK] Access Denied</h1>
1050
+ <p>This application is running in <strong>native desktop mode</strong>.</p>
1051
+ <p>Web browser access is disabled for security reasons.</p>
1052
+ <hr style="margin: 2rem auto; width: 50%;">
1053
+ <small>If you are the owner, please use the desktop application.</small>
1054
+ </body>
1055
+ </html>
1056
+ """,
1057
+ status_code=403
1058
+ )
1059
+ else:
1060
+ self.debug_print(f" [OK] ACCESS GRANTED - Valid token")
1061
+
1062
+ # Session ID: get from cookie (all tabs share same session)
1063
+ sid = request.cookies.get("ss_sid") or str(uuid.uuid4())
1064
+
1065
+ t = session_ctx.set(sid)
1066
+ response = await call_next(request)
1067
+ session_ctx.reset(t)
1068
+
1069
+ # Set cookie
1070
+ is_https = request.url.scheme == "https"
1071
+ response.set_cookie(
1072
+ "ss_sid",
1073
+ sid,
1074
+ httponly=True,
1075
+ secure=is_https,
1076
+ samesite="lax"
1077
+ )
1078
+
1079
+ # Set native token cookie
1080
+ if self.native_token and not request.cookies.get("_native_token"):
1081
+ response.set_cookie(
1082
+ "_native_token",
1083
+ self.native_token,
1084
+ httponly=True,
1085
+ secure=is_https,
1086
+ samesite="strict"
1087
+ )
1088
+
1089
+ return response
1090
+
1091
+ @self.fastapi.get("/")
1092
+ async def index(request: Request):
1093
+ # Note: _theme_state, _selection_state, _animation_state and their updaters
1094
+ # are already initialized in __init__, no need to re-initialize here
1095
+
1096
+ main_c, sidebar_c = self._render_all()
1097
+ store = get_session_store()
1098
+ t = store['theme']
1099
+
1100
+ sidebar_style = "" if (sidebar_c or self.static_sidebar_order) else "display: none;"
1101
+ main_class = "" if (sidebar_c or self.static_sidebar_order) else "sidebar-collapsed"
1102
+
1103
+ # Generate CSRF token
1104
+ # Get sid from context (set by middleware) instead of cookies (not set yet on first visit)
1105
+ try:
1106
+ sid = session_ctx.get()
1107
+ except LookupError:
1108
+ sid = request.cookies.get("ss_sid")
1109
+
1110
+ csrf_token = self._generate_csrf_token(sid) if sid and self.csrf_enabled else ""
1111
+ csrf_script = f'<script>window._csrf_token = "{csrf_token}";</script>' if csrf_token else ""
1112
+
1113
+ if self.debug_mode:
1114
+ print(f"[DEBUG] Session ID: {sid[:8] if sid else 'None'}...")
1115
+ print(f"[DEBUG] CSRF enabled: {self.csrf_enabled}")
1116
+ print(f"[DEBUG] CSRF token generated: {bool(csrf_token)}")
1117
+
1118
+ # Debug flag injection
1119
+ debug_script = f'<script>window._debug_mode = {str(self.debug_mode).lower()};</script>'
1120
+
1121
+ html = HTML_TEMPLATE.replace("%CONTENT%", main_c).replace("%SIDEBAR_CONTENT%", sidebar_c).replace("%SIDEBAR_STYLE%", sidebar_style).replace("%MAIN_CLASS%", main_class).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)
1122
+ return HTMLResponse(html)
1123
+
1124
+ @self.fastapi.post("/action/{cid}")
1125
+ async def action(request: Request, cid: str):
1126
+ # Session ID: get from cookie
1127
+ sid = request.cookies.get("ss_sid")
1128
+
1129
+ # CSRF verification
1130
+ if self.csrf_enabled:
1131
+ f = await request.form()
1132
+ csrf_token = f.get("_csrf_token") or request.headers.get("X-CSRF-Token")
1133
+
1134
+ if not csrf_token or not self._verify_csrf_token(sid, csrf_token):
1135
+ from fastapi.responses import JSONResponse
1136
+ return JSONResponse(
1137
+ {"error": "Invalid CSRF token"},
1138
+ status_code=403
1139
+ )
1140
+ else:
1141
+ f = await request.form()
1142
+
1143
+ v = f.get("value")
1144
+ store = get_session_store()
1145
+ act = store['actions'].get(cid) or self.static_actions.get(cid)
1146
+ if act:
1147
+ if not callable(act):
1148
+ # Debug: print what we got instead
1149
+ self.debug_print(f"ERROR: Action for {cid} is not callable. Got: {type(act)} = {repr(act)}")
1150
+ return HTMLResponse("")
1151
+
1152
+ store['eval_queue'] = []
1153
+ act(v) if v is not None else act()
1154
+
1155
+ dirty = self._get_dirty_rendered()
1156
+
1157
+ # Separate clicked component from other updates
1158
+ clicked_component = None
1159
+ other_dirty = []
1160
+ for c in dirty:
1161
+ if c.id == cid:
1162
+ clicked_component = c
1163
+ else:
1164
+ other_dirty.append(c)
1165
+
1166
+ # Re-render clicked component if not dirty
1167
+ if clicked_component is None:
1168
+ builder = store['builders'].get(cid) or self.static_builders.get(cid)
1169
+ if builder:
1170
+ clicked_component = builder()
1171
+
1172
+ # Build response: clicked component HTML + OOB for others
1173
+ response_html = clicked_component.render() if clicked_component else ""
1174
+ response_html += self.lite_engine.wrap_oob(other_dirty)
1175
+
1176
+ # Process Toasts
1177
+ toasts = store.get('toasts', [])
1178
+ if toasts:
1179
+ import html as html_lib
1180
+ toasts_json = json.dumps(toasts)
1181
+ toasts_escaped = html_lib.escape(toasts_json)
1182
+
1183
+ toast_injector = f'''<div id="toast-injector" hx-swap-oob="true" data-toasts="{toasts_escaped}">
1184
+ <script>
1185
+ (function() {{
1186
+ var container = document.getElementById('toast-injector');
1187
+ if (!container) return;
1188
+ var toastsAttr = container.getAttribute('data-toasts');
1189
+ if (!toastsAttr) return;
1190
+ var toasts = JSON.parse(toastsAttr);
1191
+ toasts.forEach(function(t) {{
1192
+ if (typeof createToast === 'function') {{
1193
+ createToast(t.message, t.variant, t.icon);
1194
+ }}
1195
+ }});
1196
+ container.removeAttribute('data-toasts');
1197
+ }})();
1198
+ </script>
1199
+ </div>'''
1200
+ response_html += toast_injector
1201
+ store['toasts'] = []
1202
+
1203
+ # Process Effects (Balloons, Snow)
1204
+ effects = store.get('effects', [])
1205
+ if effects:
1206
+ effects_json = json.dumps(effects)
1207
+ effect_injector = f'''<div id="effects-injector" hx-swap-oob="true" data-effects='{effects_json}'>
1208
+ <script>
1209
+ (function() {{
1210
+ const container = document.getElementById('effects-injector');
1211
+ if (!container) return;
1212
+ const effects = JSON.parse(container.getAttribute('data-effects'));
1213
+ effects.forEach(e => {{
1214
+ if (e === 'balloons') createBalloons();
1215
+ if (e === 'snow') createSnow();
1216
+ }});
1217
+ container.removeAttribute('data-effects');
1218
+ }})();
1219
+ </script>
1220
+ </div>'''
1221
+ response_html += effect_injector
1222
+ store['effects'] = []
1223
+
1224
+ return HTMLResponse(response_html)
1225
+ return HTMLResponse("")
1226
+
1227
+ @self.fastapi.websocket("/ws")
1228
+ async def ws(ws: WebSocket):
1229
+ await ws.accept()
1230
+
1231
+ # Session ID: get from cookie (all tabs share same session)
1232
+ sid = ws.cookies.get("ss_sid") or str(uuid.uuid4())
1233
+
1234
+ self.debug_print(f"[WEBSOCKET] Session: {sid[:8]}...")
1235
+
1236
+ # Set session context (outside while loop - very important!)
1237
+ t = session_ctx.set(sid)
1238
+ self.ws_engine.sockets[sid] = ws
1239
+
1240
+ # Message processing function
1241
+ async def process_message(data):
1242
+ if data.get('type') != 'click':
1243
+ return
1244
+
1245
+ # Debug WebSocket data
1246
+ self.debug_print(f"[WEBSOCKET ACTION] CID: {data.get('id')}")
1247
+ self.debug_print(f" Native mode: {self.native_token is not None}")
1248
+ self.debug_print(f" CSRF enabled: {self.csrf_enabled}")
1249
+ self.debug_print(f" Native token in payload: {data.get('_native_token')[:20] if data.get('_native_token') else None}...")
1250
+
1251
+ # Native mode verification (high priority)
1252
+ if self.native_token is not None:
1253
+ native_token = data.get('_native_token')
1254
+ if native_token != self.native_token:
1255
+ self.debug_print(f" [X] Native token mismatch!")
1256
+ await ws.send_json({"type": "error", "message": "Invalid native token"})
1257
+ return
1258
+ else:
1259
+ self.debug_print(f" [OK] Native token valid - Skipping CSRF check")
1260
+ else:
1261
+ # CSRF verification for WebSocket (non-native only)
1262
+ if self.csrf_enabled:
1263
+ csrf_token = data.get('_csrf_token')
1264
+ if not csrf_token or not self._verify_csrf_token(sid, csrf_token):
1265
+ self.debug_print(f" [X] CSRF token invalid")
1266
+ await ws.send_json({"type": "error", "message": "Invalid CSRF token"})
1267
+ return
1268
+ else:
1269
+ self.debug_print(f" [OK] CSRF token valid")
1270
+
1271
+ cid, v = data.get('id'), data.get('value')
1272
+ store = get_session_store()
1273
+ act = store['actions'].get(cid) or self.static_actions.get(cid)
1274
+
1275
+ self.debug_print(f" Action found: {act is not None}")
1276
+
1277
+ # Detect if this is a navigation action (nav menu click)
1278
+ is_navigation = cid.startswith('nav_menu')
1279
+
1280
+ if act:
1281
+ store['eval_queue'] = []
1282
+ self.debug_print(f" Executing action for CID: {cid} (navigation={is_navigation})...")
1283
+ act(v) if v is not None else act()
1284
+ self.debug_print(f" Action executed")
1285
+
1286
+ for code in store.get('eval_queue', []):
1287
+ await self.ws_engine.push_eval(sid, code)
1288
+ store['eval_queue'] = []
1289
+
1290
+ dirty = self._get_dirty_rendered()
1291
+ self.debug_print(f" Dirty components: {len(dirty)} ({[c.id for c in dirty]})")
1292
+
1293
+ # Send all dirty components via WebSocket
1294
+ # Pass is_navigation flag to enable/disable smooth transitions
1295
+ if dirty:
1296
+ self.debug_print(f" Sending {len(dirty)} updates via WebSocket (navigation={is_navigation})...")
1297
+ await self.ws_engine.push_updates(sid, dirty, is_navigation=is_navigation)
1298
+ self.debug_print(f" [OK] Updates sent successfully")
1299
+ else:
1300
+ self.debug_print(f" [!] No dirty components found - nothing to update")
1301
+
1302
+ try:
1303
+ # Message processing loop
1304
+ while True:
1305
+ data = await ws.receive_json()
1306
+ await process_message(data)
1307
+ except WebSocketDisconnect:
1308
+ if sid and sid in self.ws_engine.sockets:
1309
+ del self.ws_engine.sockets[sid]
1310
+ self.debug_print(f"[WEBSOCKET] Disconnected: {sid[:8]}...")
1311
+ finally:
1312
+ if t is not None:
1313
+ session_ctx.reset(t)
1314
+
1315
+ def _run_web_reload(self, args):
1316
+ """Run with hot reload in web mode (process restart)"""
1317
+ self.debug_print(f"[HOT RELOAD] Watching {os.getcwd()}...")
1318
+
1319
+ iteration = 0
1320
+ while True:
1321
+ iteration += 1
1322
+ # Prepare environment
1323
+ env = os.environ.copy()
1324
+ env["VIOLIT_WORKER"] = "1"
1325
+
1326
+ # Start worker
1327
+ self.debug_print(f"\n[Web Reload] Starting server (iteration {iteration})...", flush=True)
1328
+ p = subprocess.Popen([sys.executable] + sys.argv, env=env)
1329
+
1330
+ # Watch for changes
1331
+ watcher = FileWatcher(debug_mode=self.debug_mode)
1332
+ intentional_restart = False
1333
+
1334
+ try:
1335
+ while p.poll() is None:
1336
+ if watcher.check():
1337
+ self.debug_print("\n[Web Reload] 🔄 Reloading server...", flush=True)
1338
+ intentional_restart = True
1339
+ p.terminate()
1340
+ try:
1341
+ p.wait(timeout=2)
1342
+ self.debug_print("[Web Reload] Server stopped gracefully", flush=True)
1343
+ except subprocess.TimeoutExpired:
1344
+ self.debug_print("[Web Reload] WARNING: Force killing server...", flush=True)
1345
+ p.kill()
1346
+ p.wait()
1347
+ break
1348
+ time.sleep(0.5)
1349
+ except KeyboardInterrupt:
1350
+ p.terminate()
1351
+ sys.exit(0)
1352
+
1353
+ # If it was an intentional restart, wait a bit so browser can detect server is down
1354
+ if intentional_restart:
1355
+ time.sleep(1.5) # Give browser time to detect server is down (increased for reliability)
1356
+ continue
1357
+
1358
+ # If process exited unexpectedly (crashed), wait for file change
1359
+ if p.returncode is not None:
1360
+ self.debug_print("[Web Reload] WARNING: Server exited unexpectedly. Waiting for file changes...", flush=True)
1361
+ while not watcher.check():
1362
+ time.sleep(0.5)
1363
+ self.debug_print("[Web Reload] Reloading after crash...", flush=True)
1364
+
1365
+ def _run_native_reload(self, args):
1366
+ """Run with hot reload in desktop mode"""
1367
+ # Generate security token for native mode
1368
+ self.native_token = secrets.token_urlsafe(32)
1369
+ self.is_native_mode = True
1370
+
1371
+ self.debug_print(f"[HOT RELOAD] Desktop mode - Watching {os.getcwd()}...")
1372
+
1373
+ # Shared state for the server process
1374
+ server_process = [None]
1375
+ should_exit = [False]
1376
+
1377
+ def server_manager():
1378
+ iteration = 0
1379
+ while not should_exit[0]:
1380
+ iteration += 1
1381
+ env = os.environ.copy()
1382
+ env["VIOLIT_WORKER"] = "1"
1383
+ env["VIOLIT_SERVER_ONLY"] = "1"
1384
+ env["VIOLIT_NATIVE_TOKEN"] = self.native_token
1385
+ env["VIOLIT_NATIVE_MODE"] = "1"
1386
+
1387
+ # Start server
1388
+ self.debug_print(f"\n[Server Manager] Starting server (iteration {iteration})...", flush=True)
1389
+ server_process[0] = subprocess.Popen(
1390
+ [sys.executable] + sys.argv,
1391
+ env=env,
1392
+ stdout=subprocess.PIPE if iteration > 1 else None,
1393
+ stderr=subprocess.STDOUT if iteration > 1 else None
1394
+ )
1395
+
1396
+ # Give server time to start
1397
+ time.sleep(0.3)
1398
+
1399
+ watcher = FileWatcher(debug_mode=self.debug_mode)
1400
+
1401
+ # Watch loop
1402
+ intentional_restart = False
1403
+ while server_process[0].poll() is None and not should_exit[0]:
1404
+ if watcher.check():
1405
+ self.debug_print("\n[Server Manager] 🔄 Reloading server...", flush=True)
1406
+ intentional_restart = True
1407
+ server_process[0].terminate()
1408
+ try:
1409
+ server_process[0].wait(timeout=2)
1410
+ self.debug_print("[Server Manager] ✓ Server stopped gracefully", flush=True)
1411
+ except subprocess.TimeoutExpired:
1412
+ self.debug_print("[Server Manager] WARNING: Force killing server...", flush=True)
1413
+ server_process[0].kill()
1414
+ server_process[0].wait()
1415
+ break
1416
+ time.sleep(0.5)
1417
+
1418
+ # If it was an intentional restart, reload webview and continue
1419
+ if intentional_restart:
1420
+ # Wait for server to be ready
1421
+ time.sleep(0.5)
1422
+ # Reload webview
1423
+ try:
1424
+ if webview.windows:
1425
+ webview.windows[0].load_url(f"http://127.0.0.1:{args.port}?_native_token={self.native_token}")
1426
+ self.debug_print("[Server Manager] \u2713 Webview reloaded", flush=True)
1427
+ except Exception as e:
1428
+ self.debug_print(f"[Server Manager] \u26a0 Webview reload failed: {e}", flush=True)
1429
+ continue
1430
+
1431
+ # If exited unexpectedly (crashed), wait for file change
1432
+ if server_process[0].poll() is not None and not should_exit[0]:
1433
+ self.debug_print("[Server Manager] WARNING: Server exited unexpectedly. Waiting for file changes...", flush=True)
1434
+ while not watcher.check() and not should_exit[0]:
1435
+ time.sleep(0.5)
1436
+
1437
+ # Start server manager thread
1438
+ t = threading.Thread(target=server_manager, daemon=True)
1439
+ t.start()
1440
+
1441
+ # Patch webview to use custom icon (Windows)
1442
+ self._patch_webview_icon()
1443
+
1444
+ # Start WebView (Main Thread)
1445
+ win_args = {
1446
+ 'text_select': True,
1447
+ 'width': self.width,
1448
+ 'height': self.height,
1449
+ 'on_top': self.on_top
1450
+ }
1451
+
1452
+ # Pass icon to start (for non-WinForms backends)
1453
+ start_args = {}
1454
+ sig_start = inspect.signature(webview.start)
1455
+ if 'icon' in sig_start.parameters and self.app_icon:
1456
+ start_args['icon'] = self.app_icon
1457
+
1458
+ webview.create_window(self.app_title, f"http://127.0.0.1:{args.port}?_native_token={self.native_token}", **win_args)
1459
+ webview.start(**start_args)
1460
+
1461
+ # Cleanup
1462
+ should_exit[0] = True
1463
+ if server_process[0]:
1464
+ try:
1465
+ server_process[0].terminate()
1466
+ except:
1467
+ pass
1468
+ sys.exit(0)
1469
+
1470
+ # Broadcasting Methods (WebSocket-based real-time sync)
1471
+ def broadcast_eval(self, js_code: str, exclude_current: bool = False):
1472
+ self.broadcaster.eval_all(js_code, exclude_current=exclude_current)
1473
+
1474
+ def broadcast_reload(self, exclude_current: bool = False):
1475
+ self.broadcaster.reload_all(exclude_current=exclude_current)
1476
+
1477
+ def broadcast_dom_update(self, container_id: str, html: str,
1478
+ position: str = 'prepend', animate: bool = True,
1479
+ exclude_current: bool = False):
1480
+ self.broadcaster.broadcast_dom_update(
1481
+ container_id, html, position, animate, exclude_current
1482
+ )
1483
+
1484
+ def broadcast_event(self, event_name: str, data: dict,
1485
+ exclude_current: bool = False):
1486
+ self.broadcaster.broadcast_event(event_name, data, exclude_current)
1487
+
1488
+ def broadcast_dom_remove(self, selector: str = None,
1489
+ element_id: str = None,
1490
+ data_attribute: tuple = None,
1491
+ animate: bool = True,
1492
+ animation_type: str = 'fade-right',
1493
+ duration: int = 500,
1494
+ exclude_current: bool = False):
1495
+ """Remove DOM element from all clients with animation
1496
+
1497
+ Example:
1498
+ # Remove by ID
1499
+ app.broadcast_dom_remove(element_id='my-element')
1500
+
1501
+ # Remove by CSS selector
1502
+ app.broadcast_dom_remove(selector='.old-posts')
1503
+ """
1504
+ self.broadcaster.broadcast_dom_remove(
1505
+ selector=selector,
1506
+ element_id=element_id,
1507
+ data_attribute=data_attribute,
1508
+ animate=animate,
1509
+ animation_type=animation_type,
1510
+ duration=duration,
1511
+ exclude_current=exclude_current
1512
+ )
1513
+
1514
+ def run(self):
1515
+ """Run the application"""
1516
+ p = argparse.ArgumentParser()
1517
+ p.add_argument("--native", action="store_true")
1518
+ p.add_argument("--nosplash", action="store_true", help="Disable splash screen")
1519
+ p.add_argument("--reload", action="store_true", help="Enable hot reload")
1520
+ p.add_argument("--lite", action="store_true", help="Use Lite mode (HTMX)")
1521
+ p.add_argument("--debug", action="store_true", help="Enable developer tools (native mode)")
1522
+ p.add_argument("--port", type=int, default=8000)
1523
+ args, _ = p.parse_known_args()
1524
+
1525
+ # [Logging Filter] Reduce noise by filtering out polling requests
1526
+ try:
1527
+ import logging
1528
+ class PollingFilter(logging.Filter):
1529
+ def filter(self, record: logging.LogRecord) -> bool:
1530
+ return "/?_t=" not in record.getMessage()
1531
+
1532
+ # Apply filter to uvicorn.access logger
1533
+ logging.getLogger("uvicorn.access").addFilter(PollingFilter())
1534
+ except Exception:
1535
+ pass # Ignore if logging setup fails
1536
+
1537
+ if args.lite:
1538
+ self.mode = "lite"
1539
+ # Also create lite engine if not already created
1540
+ if self.lite_engine is None:
1541
+ from .engine import LiteEngine
1542
+ self.lite_engine = LiteEngine()
1543
+
1544
+ # Handle internal env var to force "Server Only" mode (for native reload)
1545
+ if os.environ.get("VIOLIT_SERVER_ONLY"):
1546
+ args.native = False
1547
+
1548
+ # Hot Reload Manager Logic
1549
+ if args.reload and not os.environ.get("VIOLIT_WORKER"):
1550
+ if args.native:
1551
+ self._run_native_reload(args)
1552
+ else:
1553
+ self._run_web_reload(args)
1554
+ return
1555
+
1556
+ self.show_splash = not args.nosplash
1557
+ if self.show_splash:
1558
+ self._splash_html = """
1559
+ <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;">
1560
+ <sl-spinner style="font-size: 3rem; --indicator-color: var(--sl-primary); margin-bottom: 1rem;"></sl-spinner>
1561
+ <div style="font-size:1.5rem;font-weight:600;color:var(--sl-text);" class="gradient-text">Loading...</div>
1562
+ </div>
1563
+ <script>
1564
+ window.addEventListener('load', ()=>{
1565
+ setTimeout(()=>{
1566
+ const s=document.getElementById('splash');
1567
+ if(s){
1568
+ s.style.opacity=0;
1569
+ setTimeout(()=>s.remove(), 400);
1570
+ }
1571
+ }, 800);
1572
+ });
1573
+ </script>
1574
+ """
1575
+
1576
+ if args.native:
1577
+ # Generate security token for native mode
1578
+ self.native_token = secrets.token_urlsafe(32)
1579
+ self.is_native_mode = True
1580
+
1581
+ # Disable CSRF in native mode (local app security)
1582
+ self.csrf_enabled = False
1583
+ print("[SECURITY] CSRF protection disabled (native mode)")
1584
+
1585
+ # Use a shared flag to signal server shutdown
1586
+ server_shutdown = threading.Event()
1587
+
1588
+ def srv():
1589
+ # Run uvicorn in a way we can control or just let it die with daemon
1590
+ # Since we use daemon=True, it should die when main thread dies.
1591
+ # However, sometimes keeping the main thread alive for webview.start()
1592
+ # might cause issues if not cleaned up properly.
1593
+ # We'll stick to daemon=True but force exit after webview.start returns.
1594
+ uvicorn.run(self.fastapi, host="127.0.0.1", port=args.port, log_level="warning")
1595
+
1596
+ t = threading.Thread(target=srv, daemon=True)
1597
+ t.start()
1598
+
1599
+ time.sleep(1)
1600
+
1601
+ # Patch webview to use custom icon (Windows)
1602
+ self._patch_webview_icon()
1603
+
1604
+ # Start WebView - This blocks until window is closed
1605
+ win_args = {
1606
+ 'text_select': True,
1607
+ 'width': self.width,
1608
+ 'height': self.height,
1609
+ 'on_top': self.on_top
1610
+ }
1611
+
1612
+ # Pass icon and debug mode to start (for non-WinForms backends)
1613
+ start_args = {}
1614
+ sig_start = inspect.signature(webview.start)
1615
+
1616
+ # Enable developer tools (when --debug flag is used)
1617
+ if args.debug:
1618
+ start_args['debug'] = True
1619
+ print("🔍 Debug mode enabled: Press F12 or Ctrl+Shift+I to open developer tools")
1620
+
1621
+ if 'icon' in sig_start.parameters and self.app_icon:
1622
+ start_args['icon'] = self.app_icon
1623
+
1624
+ # Add native token to URL for initial access
1625
+ webview.create_window(self.app_title, f"http://127.0.0.1:{args.port}?_native_token={self.native_token}", **win_args)
1626
+ webview.start(**start_args)
1627
+
1628
+ # Force exit after window closes to kill the uvicorn thread immediately
1629
+ print("App closed. Exiting...")
1630
+ os._exit(0)
1631
+ else:
1632
+ uvicorn.run(self.fastapi, host="0.0.0.0", port=args.port)
1633
+
1634
+
1635
+ HTML_TEMPLATE = """
1636
+ <!DOCTYPE html>
1637
+ <html class="%THEME_CLASS%">
1638
+ <head>
1639
+ <meta charset="UTF-8">
1640
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1641
+ <meta name="htmx-config" content='{"defaultSwapDelay":0,"defaultSettleDelay":0}'>
1642
+ <title>%TITLE%</title>
1643
+ %CSRF_SCRIPT%
1644
+ %DEBUG_SCRIPT%
1645
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/themes/light.css" />
1646
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/themes/dark.css" />
1647
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.12.0/cdn/shoelace-autoloader.js"></script>
1648
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
1649
+ <script src="https://cdn.jsdelivr.net/npm/ag-grid-community@31.0.0/dist/ag-grid-community.min.js"></script>
1650
+ <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
1651
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
1652
+ <style>
1653
+ :root {
1654
+ %CSS_VARS%
1655
+ --sidebar-width: 300px;
1656
+ }
1657
+ sl-alert { --sl-color-primary-500: var(--sl-primary); --sl-color-primary-600: var(--sl-primary); }
1658
+ sl-alert::part(base) { border: 1px solid var(--sl-border); }
1659
+
1660
+ sl-button {
1661
+ --sl-color-primary-500: var(--sl-primary);
1662
+ --sl-color-primary-600: color-mix(in srgb, var(--sl-primary), black 10%);
1663
+ }
1664
+ 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; }
1665
+
1666
+ /* Soft Animation Mode - Only for sidebar and page transitions */
1667
+ body.anim-soft #sidebar { transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s ease, opacity 0.3s ease; }
1668
+ body.anim-soft .page-container { animation: fade-in 0.3s ease-out; }
1669
+
1670
+ /* Hard Animation Mode */
1671
+ body.anim-hard *, body.anim-hard ::part(base) { transition: none !important; animation: none !important; }
1672
+
1673
+ #root { display: flex; width: 100%; min-height: 100vh; }
1674
+ #sidebar {
1675
+ position: fixed;
1676
+ top: 0;
1677
+ left: 0;
1678
+ width: var(--sidebar-width);
1679
+ height: 100vh;
1680
+ background: var(--sl-bg-card);
1681
+ border-right: 1px solid var(--sl-border);
1682
+ padding: 2rem 1rem;
1683
+ display: flex;
1684
+ flex-direction: column;
1685
+ gap: 1rem;
1686
+ overflow-y: auto;
1687
+ overflow-x: hidden;
1688
+ white-space: nowrap;
1689
+ z-index: 1100;
1690
+ }
1691
+ #sidebar.collapsed { width: 0; padding: 2rem 0; border-right: none; opacity: 0; }
1692
+
1693
+ #main {
1694
+ flex: 1;
1695
+ margin-left: var(--sidebar-width);
1696
+ display: flex;
1697
+ flex-direction: column;
1698
+ align-items: center;
1699
+ padding: 0 1.5rem 3rem 2.5rem;
1700
+ transition: margin-left 0.3s ease, padding 0.3s ease;
1701
+ }
1702
+ #main.sidebar-collapsed { margin-left: 0; }
1703
+ /* Chat input container positioning - respects sidebar */
1704
+ .chat-input-container { left: var(--sidebar-width) !important; transition: left 0.3s ease; }
1705
+ #sidebar.collapsed ~ #main .chat-input-container,
1706
+ #main.sidebar-collapsed .chat-input-container { left: 0 !important; }
1707
+
1708
+ #header { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; padding: 1rem 0; display: flex; align-items: center; }
1709
+ #app { width: 100%; max-width: %CONTAINER_MAX_WIDTH%; display: flex; flex-direction: column; gap: 1.5rem; }
1710
+
1711
+ .fragment { display: flex; flex-direction: column; gap: 1.25rem; width: 100%; }
1712
+ .page-container { display: flex; flex-direction: column; gap: 1rem; width: 100%; }
1713
+ .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; }
1714
+
1715
+ /* Widget spacing - natural breathing room */
1716
+ .page-container > div { margin-bottom: 0.5rem; }
1717
+
1718
+ /* Headings need more space above to separate sections */
1719
+ h1, h2, h3 { font-weight: 600; margin: 0; }
1720
+ h1 { font-size: 2.25rem; line-height: 1.2; margin-bottom: 1rem; }
1721
+ h2 { font-size: 1.5rem; line-height: 1.3; margin-top: 1.5rem; margin-bottom: 0.75rem; }
1722
+ h3 { font-size: 1.25rem; line-height: 1.4; margin-top: 1.25rem; margin-bottom: 0.5rem; }
1723
+ .page-container > h1:first-child, .page-container > h2:first-child, .page-container > h3:first-child,
1724
+ h1:first-child, h2:first-child, h3:first-child { margin-top: 0; }
1725
+
1726
+ /* Shoelace component spacing */
1727
+ sl-input, sl-select, sl-textarea, sl-range, sl-checkbox, sl-switch, sl-radio-group, sl-color-picker {
1728
+ display: block;
1729
+ margin-bottom: 1rem;
1730
+ }
1731
+ sl-alert {
1732
+ display: block;
1733
+ margin-bottom: 1.25rem;
1734
+ }
1735
+ sl-button {
1736
+ margin-top: 0.25rem;
1737
+ margin-bottom: 0.5rem;
1738
+ }
1739
+ sl-divider, .divider {
1740
+ --color: var(--sl-border);
1741
+ margin: 1.5rem 0;
1742
+ width: 100%;
1743
+ }
1744
+
1745
+ /* Column layouts */
1746
+ .columns { display: flex; gap: 1rem; width: 100%; margin-bottom: 0.5rem; }
1747
+ .column { flex: 1; display: flex; flex-direction: column; gap: 0.75rem; }
1748
+
1749
+ /* List container - predefined layout for reactive lists */
1750
+ .violit-list-container {
1751
+ display: flex;
1752
+ flex-direction: column;
1753
+ gap: 1rem;
1754
+ width: 100%;
1755
+ }
1756
+ .violit-list-container > * {
1757
+ width: 100%;
1758
+ }
1759
+ .violit-list-container sl-card {
1760
+ width: 100%;
1761
+ }
1762
+
1763
+ .gradient-text { background: linear-gradient(to right, var(--sl-primary), var(--sl-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
1764
+ .text-muted { color: var(--sl-text-muted); }
1765
+ .metric-label { color: var(--sl-text-muted); font-size: 0.875rem; margin-bottom: 0.25rem; }
1766
+ .metric-value { font-size: 2rem; font-weight: 600; }
1767
+ .no-select { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; }
1768
+
1769
+ @keyframes fade-in {
1770
+ from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
1771
+ to { opacity: 1; transform: translateY(0); filter: blur(0); }
1772
+ }
1773
+
1774
+ /* Animations for Balloons and Snow */
1775
+ @keyframes float-up {
1776
+ 0% { transform: translateY(var(--start-y, 100vh)) rotate(0deg); opacity: 1; }
1777
+ 100% { transform: translateY(-20vh) rotate(360deg); opacity: 0; }
1778
+ }
1779
+ @keyframes fall-down {
1780
+ 0% { transform: translateY(-10vh) rotate(0deg); opacity: 0; }
1781
+ 10% { opacity: 1; }
1782
+ 90% { opacity: 1; }
1783
+ 100% { transform: translateY(110vh) rotate(360deg); opacity: 0; }
1784
+ }
1785
+ .balloon, .snowflake {
1786
+ position: fixed;
1787
+ z-index: 9999;
1788
+ pointer-events: none;
1789
+ font-size: 2rem;
1790
+ user-select: none;
1791
+ }
1792
+ .balloon { animation: float-up var(--duration) linear forwards; }
1793
+ .snowflake { animation: fall-down var(--duration) linear forwards; }
1794
+ </style>
1795
+ <script>
1796
+ const mode = "%MODE%";
1797
+
1798
+ // Debug logging helper
1799
+ const debugLog = (...args) => {
1800
+ if (window._debug_mode) {
1801
+ console.log(...args);
1802
+ }
1803
+ };
1804
+
1805
+ // [LOCK] HTMX에 CSRF 토큰 자동 추가 (Lite Mode)
1806
+ if (mode === 'lite' && window._csrf_token) {
1807
+ document.addEventListener('DOMContentLoaded', function() {
1808
+ document.body.addEventListener('htmx:configRequest', function(evt) {
1809
+ evt.detail.parameters['_csrf_token'] = window._csrf_token;
1810
+ });
1811
+ });
1812
+ }
1813
+
1814
+ // Helper to clean up Plotly instances before removing elements
1815
+ function purgePlotly(container) {
1816
+ if (!window.Plotly) return;
1817
+ if (container.classList && container.classList.contains('js-plotly-plot')) {
1818
+ Plotly.purge(container);
1819
+ }
1820
+ if (container.querySelectorAll) {
1821
+ container.querySelectorAll('.js-plotly-plot').forEach(p => Plotly.purge(p));
1822
+ }
1823
+ }
1824
+
1825
+ if (mode === 'ws') {
1826
+ // [FIX] Pre-define sendAction with queue to handle clicks before WebSocket connects
1827
+ // Use window properties for debugging access
1828
+ window._wsReady = false;
1829
+ window._actionQueue = [];
1830
+ window._ws = null;
1831
+
1832
+ // Define sendAction IMMEDIATELY (before WebSocket connection)
1833
+ window.sendAction = (cid, val) => {
1834
+ debugLog(`[sendAction] Called with cid=${cid}, val=${val}`);
1835
+
1836
+ const payload = {
1837
+ type: 'click',
1838
+ id: cid,
1839
+ value: val
1840
+ };
1841
+
1842
+ // CSRF 토큰 추가
1843
+ if (window._csrf_token) {
1844
+ payload._csrf_token = window._csrf_token;
1845
+ }
1846
+
1847
+ // [SECURE] Native 토큰 추가 (pywebview에서 필요)
1848
+ const urlParams = new URLSearchParams(window.location.search);
1849
+ const nativeToken = urlParams.get('_native_token');
1850
+ if (nativeToken) {
1851
+ payload._native_token = nativeToken;
1852
+ }
1853
+
1854
+ // Check if this is a navigation menu click (nav_menu_X)
1855
+ if (cid.startsWith('nav_menu')) {
1856
+ // Update URL hash to reflect current page
1857
+ // val is "page_reactive-logic", we make it #reactive-logic
1858
+ const pageName = val.replace('page_', '');
1859
+ window.location.hash = pageName;
1860
+ debugLog(`🔗 Updated Hash: #${pageName}`);
1861
+ }
1862
+
1863
+ // Queue action if WebSocket not ready, otherwise send immediately
1864
+ if (!window._wsReady || !window._ws) {
1865
+ debugLog(`⏳ WebSocket not ready (wsReady=${window._wsReady}, ws=${!!window._ws}), queueing action: ${cid}`);
1866
+ window._actionQueue.push(payload);
1867
+ } else {
1868
+ debugLog(`✅ Sending action to server: ${cid}`);
1869
+ window._ws.send(JSON.stringify(payload));
1870
+ }
1871
+ };
1872
+
1873
+ // Now connect WebSocket
1874
+ window._ws = new WebSocket((location.protocol === 'https:' ? 'wss:' : 'ws:') + "//" + location.host + "/ws");
1875
+
1876
+ // Auto-reconnect/reload logic
1877
+ window._ws.onclose = () => {
1878
+ window._wsReady = false;
1879
+ debugLog("🔌 Connection lost. Auto-reloading...");
1880
+
1881
+ const checkServer = () => {
1882
+ fetch(location.href)
1883
+ .then(r => {
1884
+ if(r.ok) {
1885
+ debugLog("✓ Server back online. Reloading...");
1886
+ window.location.reload();
1887
+ } else {
1888
+ setTimeout(checkServer, 300);
1889
+ }
1890
+ })
1891
+ .catch(() => setTimeout(checkServer, 300));
1892
+ };
1893
+ setTimeout(checkServer, 300);
1894
+ };
1895
+
1896
+ // CRITICAL: Restore from hash ONLY after WebSocket is connected
1897
+ window._ws.onopen = () => {
1898
+ debugLog("✅ [WebSocket] Connected successfully!");
1899
+ window._wsReady = true;
1900
+
1901
+ // Process queued actions
1902
+ if (window._actionQueue.length > 0) {
1903
+ debugLog(`📤 Processing ${window._actionQueue.length} queued action(s)...`);
1904
+ window._actionQueue.forEach(payload => {
1905
+ window._ws.send(JSON.stringify(payload));
1906
+ });
1907
+ window._actionQueue.length = 0; // Clear queue
1908
+ }
1909
+
1910
+ // Restore from hash after processing queue
1911
+ setTimeout(restoreFromHash, 100);
1912
+ };
1913
+
1914
+ // Handle WebSocket errors
1915
+ window._ws.onerror = (error) => {
1916
+ window._wsReady = false;
1917
+ debugLog("❌ WebSocket error:", error);
1918
+ };
1919
+
1920
+ window._ws.onmessage = (e) => {
1921
+ debugLog("[WebSocket] Message received");
1922
+ const msg = JSON.parse(e.data);
1923
+ if(msg.type === 'update') {
1924
+ // Check if this is a navigation update (page transition)
1925
+ // Server sends isNavigation flag based on action type
1926
+ const isNavigation = msg.isNavigation === true;
1927
+
1928
+ // Helper function to apply updates
1929
+ const applyUpdates = (items) => {
1930
+ items.forEach(item => {
1931
+ const el = document.getElementById(item.id);
1932
+
1933
+ // Focus Guard: Skip update if element is focused input to prevent interrupting typing
1934
+ if (document.activeElement && el) {
1935
+ const isSelfOrChild = document.activeElement.id === item.id || el.contains(document.activeElement);
1936
+ const isShadowChild = document.activeElement.closest && document.activeElement.closest(`#${item.id}`);
1937
+
1938
+ if (isSelfOrChild || isShadowChild) {
1939
+ // Check if it's actually an input that needs protection
1940
+ const tag = document.activeElement.tagName.toLowerCase();
1941
+ const isInput = tag === 'input' || tag === 'textarea' || tag.startsWith('sl-input') || tag.startsWith('sl-textarea');
1942
+
1943
+ // If it's an input, block update. If it's a button (nav menu), ALLOW update.
1944
+ if (isInput) {
1945
+ return;
1946
+ }
1947
+ }
1948
+ }
1949
+
1950
+ if(el) {
1951
+ // Smart update for specific widget types to preserve animations/instances
1952
+ const widgetType = item.id.split('_')[0];
1953
+ let smartUpdated = false;
1954
+
1955
+ // Checkbox/Toggle: Update checked property only (preserve animation)
1956
+ if (widgetType === 'checkbox' || widgetType === 'toggle') {
1957
+ // Parse new HTML to extract checked state
1958
+ const temp = document.createElement('div');
1959
+ temp.innerHTML = item.html;
1960
+ const newCheckbox = temp.querySelector('sl-checkbox, sl-switch');
1961
+
1962
+ if (newCheckbox) {
1963
+ // Find the actual checkbox element (may be direct or nested)
1964
+ const checkboxEl = el.tagName && (el.tagName.toLowerCase() === 'sl-checkbox' || el.tagName.toLowerCase() === 'sl-switch')
1965
+ ? el
1966
+ : el.querySelector('sl-checkbox, sl-switch');
1967
+
1968
+ if (checkboxEl) {
1969
+ const shouldBeChecked = newCheckbox.hasAttribute('checked');
1970
+ // Only update if different to avoid interrupting user interaction
1971
+ if (checkboxEl.checked !== shouldBeChecked) {
1972
+ checkboxEl.checked = shouldBeChecked;
1973
+ }
1974
+ smartUpdated = true;
1975
+ }
1976
+ }
1977
+ }
1978
+
1979
+ // Data Editor: Update AG Grid data only
1980
+ if (widgetType === 'data' && item.id.includes('editor')) {
1981
+ // item.id is like "data_editor_xxx_wrapper", extract base cid
1982
+ const baseCid = item.id.replace('_wrapper', '');
1983
+ const gridApi = window['gridApi_' + baseCid];
1984
+ if (gridApi) {
1985
+ // Extract rowData from new HTML
1986
+ const match = item.html.match(/rowData:\s*(\[.*?\])/s);
1987
+ if (match) {
1988
+ try {
1989
+ const newData = JSON.parse(match[1]);
1990
+ gridApi.setRowData(newData);
1991
+ smartUpdated = true;
1992
+ } catch (e) {
1993
+ console.error('Failed to parse AG Grid data:', e);
1994
+ }
1995
+ }
1996
+ }
1997
+ }
1998
+
1999
+ // Default: Full DOM replacement
2000
+ if (!smartUpdated) {
2001
+ purgePlotly(el);
2002
+ el.outerHTML = item.html;
2003
+
2004
+ // Execute scripts
2005
+ const temp = document.createElement('div');
2006
+ temp.innerHTML = item.html;
2007
+ temp.querySelectorAll('script').forEach(s => {
2008
+ const script = document.createElement('script');
2009
+ script.textContent = s.textContent;
2010
+ document.body.appendChild(script);
2011
+ script.remove();
2012
+ });
2013
+ }
2014
+ }
2015
+ });
2016
+ };
2017
+
2018
+ // Apply updates: use View Transitions ONLY for navigation
2019
+ if (isNavigation && document.body.classList.contains('anim-soft') && document.startViewTransition) {
2020
+ // Navigation: smooth page transition
2021
+ document.startViewTransition(() => applyUpdates(msg.payload));
2022
+ } else {
2023
+ // Regular updates: apply immediately without animation for snappy response
2024
+ applyUpdates(msg.payload);
2025
+ }
2026
+ } else if (msg.type === 'eval') {
2027
+ const func = new Function(msg.code);
2028
+ func();
2029
+ }
2030
+ };
2031
+ } else {
2032
+ // Lite Mode (HTMX) specifics
2033
+ document.addEventListener('DOMContentLoaded', () => {
2034
+ document.body.addEventListener('htmx:beforeSwap', function(evt) {
2035
+ if (evt.detail.target) {
2036
+ purgePlotly(evt.detail.target);
2037
+ }
2038
+ });
2039
+
2040
+ // Hot reload support for lite mode: poll server health
2041
+ let serverAlive = true;
2042
+ const checkServerHealth = () => {
2043
+ // Add timestamp to prevent caching
2044
+ const pollUrl = location.href.split('#')[0] + (location.href.indexOf('?') === -1 ? '?' : '&') + '_t=' + Date.now();
2045
+
2046
+ fetch(pollUrl, { cache: 'no-store' })
2047
+ .then(r => {
2048
+ if (r.ok) {
2049
+ if (!serverAlive) {
2050
+ debugLog("✓ Server back online. Reloading...");
2051
+ document.body.style.opacity = '1'; // Restore opacity
2052
+ window.location.reload();
2053
+ }
2054
+ serverAlive = true;
2055
+ // Ensure opacity is 1 if server is alive
2056
+ document.body.style.opacity = '1';
2057
+ document.body.style.pointerEvents = 'auto';
2058
+ } else {
2059
+ throw new Error('Server error');
2060
+ }
2061
+ })
2062
+ .catch(() => {
2063
+ if (serverAlive) {
2064
+ debugLog("🔌 Server down. Waiting for restart...");
2065
+ // Dim the page to indicate connection lost
2066
+ document.body.style.transition = 'opacity 0.5s';
2067
+ document.body.style.opacity = '0.5';
2068
+ document.body.style.pointerEvents = 'none';
2069
+ }
2070
+ serverAlive = false;
2071
+ });
2072
+ };
2073
+ setInterval(checkServerHealth, 2000);
2074
+ });
2075
+ }
2076
+
2077
+ function toggleSidebar() {
2078
+ const sb = document.getElementById('sidebar');
2079
+ const main = document.getElementById('main');
2080
+ const chatInput = document.querySelector('.chat-input-container');
2081
+ sb.classList.toggle('collapsed');
2082
+ main.classList.toggle('sidebar-collapsed');
2083
+ // Also adjust chat input container if present
2084
+ if (chatInput) {
2085
+ chatInput.style.left = sb.classList.contains('collapsed') ? '0' : '300px';
2086
+ }
2087
+ }
2088
+
2089
+ function createToast(message, variant = 'primary', icon = 'info-circle') {
2090
+ const variantColors = { primary: '#0ea5e9', success: '#10b981', warning: '#f59e0b', danger: '#ef4444' };
2091
+ const toast = document.createElement('div');
2092
+ // Use CSS variables directly so theme changes are reflected automatically
2093
+ 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;`;
2094
+ 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>`;
2095
+ document.body.appendChild(toast);
2096
+ requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateX(0)'; });
2097
+ setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(400px)'; setTimeout(() => toast.remove(), 300); }, 3300);
2098
+ }
2099
+ function createBalloons() {
2100
+ const emojis = ['🎈', '🎈', '🎈', '✨', '🎈', '🎈'];
2101
+ for (let i = 0; i < 60; i++) {
2102
+ const b = document.createElement('div');
2103
+ b.className = 'balloon';
2104
+ b.textContent = emojis[Math.floor(Math.random() * emojis.length)];
2105
+ b.style.left = Math.random() * 100 + 'vw';
2106
+ const startY = 10;
2107
+ b.style.setProperty('--start-y', startY + 'vh');
2108
+ const duration = 3 + Math.random() * 3;
2109
+ b.style.setProperty('--duration', duration + 's');
2110
+ b.style.animationDelay = Math.random() * 0.2 + 's';
2111
+ document.body.appendChild(b);
2112
+ setTimeout(() => b.remove(), (duration + 1) * 1000);
2113
+ }
2114
+ }
2115
+ function createSnow() {
2116
+ const emojis = ['❄️', '❅', '❆', '❄️'];
2117
+ for (let i = 0; i < 50; i++) {
2118
+ const s = document.createElement('div');
2119
+ s.className = 'snowflake';
2120
+ s.textContent = emojis[Math.floor(Math.random() * emojis.length)];
2121
+ s.style.left = Math.random() * 100 + 'vw';
2122
+ const duration = 4 + Math.random() * 4;
2123
+ s.style.setProperty('--duration', duration + 's');
2124
+ s.style.animationDelay = Math.random() * 1.0 + 's';
2125
+ document.body.appendChild(s);
2126
+ setTimeout(() => s.remove(), (duration + 5) * 1000);
2127
+ }
2128
+ }
2129
+
2130
+ // Restore state from URL Hash (or force Home if no hash)
2131
+ function restoreFromHash() {
2132
+ // 🔄 URL 해시 디코딩 (한글 등 인코딩된 문자 처리)
2133
+ let hash = window.location.hash.substring(1); // Remove #
2134
+ try {
2135
+ hash = decodeURIComponent(hash);
2136
+ } catch (e) {
2137
+ debugLog('Hash decode error:', e);
2138
+ }
2139
+
2140
+ // If no hash, force navigation to Home to reset server state
2141
+ if (!hash || hash === 'home' || hash === '홈') {
2142
+ debugLog('🏠 No hash - forcing Home page');
2143
+ const tryClickHome = (attempts = 0) => {
2144
+ if (attempts >= 20) return;
2145
+ const navButtons = document.querySelectorAll('#sidebar sl-button');
2146
+ if (navButtons.length > 0) {
2147
+ const homeBtn = navButtons[0]; // First button is Home
2148
+ if (homeBtn.getAttribute('variant') !== 'primary') {
2149
+ homeBtn.click();
2150
+ debugLog('🏠 Clicked Home button');
2151
+ }
2152
+ } else {
2153
+ setTimeout(() => tryClickHome(attempts + 1), 100);
2154
+ }
2155
+ };
2156
+ tryClickHome();
2157
+ return;
2158
+ }
2159
+
2160
+ const targetKey = 'page_' + hash;
2161
+ debugLog(`📍 Restoring from Hash: "${hash}" (key: ${targetKey})`);
2162
+
2163
+ const tryRestore = (attempts = 0) => {
2164
+ // Stop after 5 seconds
2165
+ if (attempts >= 50) {
2166
+ console.warn(`⚠ Failed to restore hash "${hash}"`);
2167
+ return;
2168
+ }
2169
+
2170
+ const navButtons = document.querySelectorAll('#sidebar sl-button');
2171
+ if (navButtons.length === 0) {
2172
+ setTimeout(() => tryRestore(attempts + 1), 100);
2173
+ return;
2174
+ }
2175
+
2176
+ for (let btn of navButtons) {
2177
+ const onclick = btn.getAttribute('onclick') || '';
2178
+ const hxVals = btn.getAttribute('hx-vals') || '';
2179
+
2180
+ // Match either onclick (WS mode) or hx-vals (Lite mode)
2181
+ if (onclick.includes(targetKey) || hxVals.includes(targetKey)) {
2182
+ debugLog(`✓ Found target button for hash "${hash}". Clicking...`);
2183
+
2184
+ // Check if already active to avoid redundant clicks
2185
+ if (btn.getAttribute('variant') === 'primary') {
2186
+ debugLog(' - Already active, skipping click.');
2187
+ return;
2188
+ }
2189
+
2190
+ btn.click();
2191
+ return;
2192
+ }
2193
+ }
2194
+
2195
+ // Keep retrying in case the specific button hasn't rendered yet (unlikely if container exists)
2196
+ setTimeout(() => tryRestore(attempts + 1), 100);
2197
+ };
2198
+
2199
+ tryRestore();
2200
+ }
2201
+
2202
+ // Note: For ws mode, restoreFromHash is called from ws.onopen
2203
+ // For lite mode, call it on load:
2204
+ if (mode !== 'ws') {
2205
+ if (document.readyState === 'loading') {
2206
+ document.addEventListener('DOMContentLoaded', () => setTimeout(restoreFromHash, 200));
2207
+ } else {
2208
+ setTimeout(restoreFromHash, 200);
2209
+ }
2210
+ }
2211
+ </script>
2212
+ </head>
2213
+ <body>
2214
+ %SPLASH%
2215
+ <div id="root">
2216
+ <div id="sidebar" style="%SIDEBAR_STYLE%">
2217
+ %SIDEBAR_CONTENT%
2218
+ </div>
2219
+ <div id="main" class="%MAIN_CLASS%">
2220
+ <div id="header">
2221
+ <sl-icon-button name="list" style="font-size: 1.5rem; color: var(--sl-text);" onclick="toggleSidebar()"></sl-icon-button>
2222
+ </div>
2223
+ <div id="app">%CONTENT%</div>
2224
+ </div>
2225
+ </div>
2226
+ <div id="toast-injector" style="display:none;"></div>
2227
+ </body>
2228
+ </html>
2229
+ """