Coverage for integrations / agent_engine / liquid_ui_service.py: 28.6%
800 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HART OS LiquidUI Service — Glass Desktop Shell.
4The desktop IS HART. When you login to HART OS, LiquidUI renders the entire
5desktop experience as a fullscreen frosted-glass shell (like explorer.exe):
7 - Top bar with clock, notifications, agent status, tray
8 - Start menu with all HART panels, apps, files, services, power
9 - Floating glass panels — each Nunba page is a draggable/resizable window
10 - Agent pill — ambient AI input always floating ("Hey HART, read my mails?")
11 - System panels — hardware, security, events, network (rendered natively)
13When a model is available:
14 - Dashboard explains WHY the GPU is busy, not just the percentage
15 - Voice says "your marketing agent finished" instead of beeping
16 - Agent helps customize the desktop ("make fonts bigger", "switch theme")
18When no model is available, graceful fallback:
19 LLM available -> generative UI (best experience)
20 No LLM -> Nunba static panels (React SPA iframes)
21 No GUI -> terminal dashboard (textual TUI)
22 Edge/headless -> Conky metrics only
24Multi-modal output:
25 Screen -> WebKit2 (GTK), fullscreen glass shell
26 Voice -> TTS via Model Bus -> PipeWire -> speaker
27 Terminal -> Rich TUI (textual library)
28 Haptic -> Vibration patterns (phone, via Android bridge)
29"""
30import json
31import logging
32import os
33import subprocess
34import threading
35import time
36from typing import Any, Dict, List, Optional
38logger = logging.getLogger('hevolve.liquid_ui')
40# ═══════════════════════════════════════════════════════════════
41# UI Component Schema (A2UI protocol)
42# ═══════════════════════════════════════════════════════════════
44COMPONENT_TYPES = {
45 'card': {'props': ['title', 'content', 'icon', 'actions']},
46 'list': {'props': ['items', 'ordered', 'interactive']},
47 'form': {'props': ['fields', 'submit_label', 'action']},
48 'chart': {'props': ['type', 'data', 'labels', 'title']},
49 'progress': {'props': ['value', 'max', 'label', 'color']},
50 'notification': {'props': ['title', 'message', 'severity', 'actions']},
51 'approval': {'props': ['agent_id', 'action', 'description', 'options']},
52 'code': {'props': ['language', 'content', 'filename']},
53 'markdown': {'props': ['content']},
54 'media': {'props': ['type', 'src', 'alt', 'controls']},
55 'metric': {'props': ['label', 'value', 'unit', 'trend', 'explanation']},
56 'layout': {'props': ['type', 'children', 'gap']},
57 # ── Ecommerce / Agent Action Live Fragments ──
58 'product_card': {'props': ['name', 'price', 'image', 'rating', 'description',
59 'buy_action', 'compare_action']},
60 'cart': {'props': ['items', 'total', 'currency', 'checkout_action']},
61 'checkout': {'props': ['items', 'total', 'payment_methods', 'shipping_options',
62 'confirm_action']},
63 'payment_status': {'props': ['status', 'amount', 'method', 'transaction_id']},
64 'order_tracking': {'props': ['order_id', 'status', 'steps', 'eta']},
65 'comparison': {'props': ['apps', 'features', 'winner']},
66 'agent_action': {'props': ['agent_id', 'action_type', 'description',
67 'status', 'result', 'timestamp']},
68 'navigate': {'props': ['target', 'params', 'transition']},
69 # ── External-room copilot (UNIF-G5) ──
70 # Live transcript + decisions + action items for an external Discord
71 # audio room / Teams meet / WhatsApp group voice / Reddit voice room
72 # joined via UNIF-G2 Join_External_Room. Idempotent overwrite — backend
73 # emits the FULL state on every transcript chunk; frontend replaces.
74 'meet_copilot': {'props': ['call_id', 'platform', 'room_id', 'state',
75 'transcript_lines', 'decisions',
76 'action_items', 'participants',
77 'agent_role']},
78}
80# ═══════════════════════════════════════════════════════════════
81# Context Engine
82# ═══════════════════════════════════════════════════════════════
85class ContextEngine:
86 """Aggregates context signals for UI generation."""
88 def __init__(self, backend_port: int = 6777, model_bus_port: int = 6790):
89 self.backend_port = backend_port
90 self.model_bus_port = model_bus_port
91 self._cache: Dict[str, Any] = {}
92 self._lock = threading.Lock()
94 def get_context(self) -> Dict[str, Any]:
95 """Aggregate all context signals."""
96 context = {
97 'timestamp': time.time(),
98 'device': self._get_device_context(),
99 'models': self._get_model_context(),
100 'agents': self._get_agent_context(),
101 'system': self._get_system_context(),
102 }
103 with self._lock:
104 self._cache = context
105 return context
107 def _get_device_context(self) -> dict:
108 data_dir = os.environ.get('HEVOLVE_DATA_DIR', '/var/lib/hart')
109 context = {
110 'variant': 'unknown',
111 'tier': 'unknown',
112 'hostname': os.uname().nodename if hasattr(os, 'uname') else 'unknown',
113 }
114 try:
115 variant_file = '/etc/hart/variant'
116 if os.path.exists(variant_file):
117 with open(variant_file) as f:
118 context['variant'] = f.read().strip()
119 except Exception:
120 pass
121 try:
122 tier_file = os.path.join(data_dir, 'capability_tier')
123 if os.path.exists(tier_file):
124 with open(tier_file) as f:
125 context['tier'] = f.read().strip()
126 except Exception:
127 pass
128 import datetime
129 now = datetime.datetime.now()
130 context['hour'] = now.hour
131 context['time_of_day'] = (
132 'morning' if 5 <= now.hour < 12 else
133 'afternoon' if 12 <= now.hour < 17 else
134 'evening' if 17 <= now.hour < 22 else 'night'
135 )
136 context['day_of_week'] = now.strftime('%A')
137 return context
139 def _get_model_context(self) -> dict:
140 from core.http_pool import pooled_get
141 try:
142 resp = pooled_get(
143 f'http://localhost:{self.model_bus_port}/v1/models', timeout=3)
144 if resp.status_code == 200:
145 data = resp.json()
146 return {'available': True, 'models': data.get('models', []),
147 'count': len(data.get('models', []))}
148 except Exception:
149 pass
150 return {'available': False, 'models': [], 'count': 0}
152 def _get_agent_context(self) -> dict:
153 from core.http_pool import pooled_get
154 try:
155 resp = pooled_get(
156 f'http://localhost:{self.backend_port}/api/social/dashboard/agents',
157 timeout=3)
158 if resp.status_code == 200:
159 data = resp.json()
160 agents = data.get('agents', [])
161 return {
162 'running': len([a for a in agents if a.get('status') == 'running']),
163 'total': len(agents), 'agents': agents[:5],
164 }
165 except Exception:
166 pass
167 return {'running': 0, 'total': 0, 'agents': []}
169 def _get_system_context(self) -> dict:
170 context = {}
171 try:
172 with open('/proc/loadavg') as f:
173 parts = f.read().split()
174 context['load_1m'] = float(parts[0])
175 context['load_5m'] = float(parts[1])
176 except Exception:
177 pass
178 try:
179 with open('/proc/meminfo') as f:
180 mem = {}
181 for line in f:
182 key, val = line.split(':')
183 mem[key.strip()] = int(val.strip().split()[0])
184 total = mem.get('MemTotal', 1)
185 available = mem.get('MemAvailable', 0)
186 context['memory_used_percent'] = round(
187 (1 - available / total) * 100, 1)
188 except Exception:
189 pass
190 try:
191 with open('/proc/uptime') as f:
192 context['uptime_hours'] = round(
193 float(f.read().split()[0]) / 3600, 1)
194 except Exception:
195 pass
196 return context
199# ═══════════════════════════════════════════════════════════════
200# LiquidUI Service — Glass Desktop Shell
201# ═══════════════════════════════════════════════════════════════
204class LiquidUIService:
205 """Glass desktop shell — the OS desktop itself."""
207 def __init__(
208 self,
209 port: int = 6800,
210 renderer: str = 'webkit',
211 theme: str = 'auto',
212 voice_enabled: bool = True,
213 haptic_enabled: bool = False,
214 context_refresh_ms: int = 2000,
215 a2ui_enabled: bool = True,
216 model_bus_port: int = 6790,
217 backend_port: int = 6777,
218 ):
219 self.port = port
220 self.renderer = renderer
221 self.theme = theme
222 self.voice_enabled = voice_enabled
223 self.haptic_enabled = haptic_enabled
224 self.context_refresh_ms = context_refresh_ms
225 self.a2ui_enabled = a2ui_enabled
226 self.model_bus_port = model_bus_port
227 self.backend_port = backend_port
229 self.context_engine = ContextEngine(backend_port, model_bus_port)
230 self._agent_components: Dict[str, List[dict]] = {}
231 self._lock = threading.Lock()
232 self._running = False
233 self._model_available = False
235 # Session state (panel positions restored on login)
236 self._data_dir = os.environ.get(
237 'HEVOLVE_DATA_DIR', os.environ.get(
238 'HART_DATA_DIR',
239 os.path.join(os.path.dirname(os.path.dirname(
240 os.path.dirname(os.path.abspath(__file__)))),
241 'agent_data')))
243 logger.info(
244 "LiquidUIService initialized: port=%d, renderer=%s, "
245 "voice=%s, haptic=%s", port, renderer, voice_enabled, haptic_enabled)
247 # ─── UI Generation (preserved) ────────────────────────────
249 def generate_ui(self, context: Optional[dict] = None) -> Dict[str, Any]:
250 """Generate adaptive UI layout based on current context."""
251 if context is None:
252 context = self.context_engine.get_context()
253 if self._model_available:
254 return self._generate_ai_ui(context)
255 return self._generate_static_ui(context)
257 def _generate_ai_ui(self, context: dict) -> dict:
258 """Generate UI via LLM (when model is available)."""
259 from core.http_pool import pooled_post
260 prompt = self._build_ui_prompt(context)
261 try:
262 resp = pooled_post(
263 f'http://localhost:{self.model_bus_port}/v1/chat',
264 json={'prompt': prompt, 'max_tokens': 1024}, timeout=15)
265 if resp.status_code == 200:
266 response = resp.json().get('response', '')
267 try:
268 json_str = response
269 if '```json' in json_str:
270 json_str = json_str.split('```json')[1].split('```')[0]
271 elif '```' in json_str:
272 json_str = json_str.split('```')[1].split('```')[0]
273 components = json.loads(json_str)
274 return {
275 'source': 'ai',
276 'components': components if isinstance(components, list) else [components],
277 'context_summary': self._summarize_context(context),
278 }
279 except (json.JSONDecodeError, IndexError):
280 return {
281 'source': 'ai_text',
282 'components': [{'type': 'markdown', 'content': response}],
283 'context_summary': self._summarize_context(context),
284 }
285 except Exception as e:
286 logger.warning("AI UI generation failed: %s", e)
287 return self._generate_static_ui(context)
289 def _generate_static_ui(self, context: dict) -> dict:
290 """Generate static dashboard UI (no LLM needed)."""
291 components = []
292 system = context.get('system', {})
293 components.append({
294 'type': 'card', 'title': 'System Status', 'content': '',
295 'children': [
296 {'type': 'metric', 'label': 'CPU Load',
297 'value': system.get('load_1m', 0), 'unit': '', 'trend': 'stable'},
298 {'type': 'metric', 'label': 'Memory',
299 'value': system.get('memory_used_percent', 0), 'unit': '%'},
300 {'type': 'metric', 'label': 'Uptime',
301 'value': system.get('uptime_hours', 0), 'unit': 'hours'},
302 ],
303 })
304 agents = context.get('agents', {})
305 if agents.get('total', 0) > 0:
306 agent_items = [
307 f"{a.get('name', '?')}: {a.get('status', '?')}"
308 for a in agents.get('agents', [])
309 ]
310 components.append({
311 'type': 'card',
312 'title': f"Agents ({agents.get('running', 0)} running)",
313 'children': [{'type': 'list', 'items': agent_items}],
314 })
315 models = context.get('models', {})
316 if models.get('available'):
317 model_names = [m.get('type', '?') for m in models.get('models', [])]
318 components.append({
319 'type': 'card',
320 'title': f"AI Models ({models.get('count', 0)})",
321 'content': ', '.join(model_names) or 'None loaded',
322 })
323 with self._lock:
324 for _aid, comps in self._agent_components.items():
325 components.extend(comps)
326 return {
327 'source': 'static', 'components': components,
328 'context_summary': self._summarize_context(context),
329 }
331 def _build_ui_prompt(self, context: dict) -> str:
332 device = context.get('device', {})
333 models = context.get('models', {})
334 agents = context.get('agents', {})
335 system = context.get('system', {})
336 return (
337 "Generate a JSON array of UI components for a HART OS dashboard.\n\n"
338 f"Context:\n"
339 f"- Device: {device.get('variant', '?')} variant, {device.get('tier', '?')} tier\n"
340 f"- Time: {device.get('time_of_day', '?')} ({device.get('day_of_week', '')})\n"
341 f"- System: CPU {system.get('load_1m', 'N/A')}, "
342 f"memory {system.get('memory_used_percent', 'N/A')}%, "
343 f"uptime {system.get('uptime_hours', 'N/A')}h\n"
344 f"- Models: {models.get('count', 0)} available\n"
345 f"- Agents: {agents.get('running', 0)}/{agents.get('total', 0)}\n\n"
346 "Return ONLY a JSON array. Valid types: card, metric, notification, "
347 "list, progress, markdown. Max 5 components. Be concise and insightful."
348 )
350 def _summarize_context(self, context: dict) -> str:
351 device = context.get('device', {})
352 models = context.get('models', {})
353 agents = context.get('agents', {})
354 return (
355 f"{device.get('variant', '?')} | {device.get('time_of_day', '?')} | "
356 f"{models.get('count', 0)} models | {agents.get('running', 0)} agents"
357 )
359 # ─── Agent UI Protocol (A2UI) — preserved ─────────────────
361 def agent_ui_update(self, agent_id: str, component: dict) -> bool:
362 """Push a UI component from an agent to all connected frontends.
364 Delivery paths (all best-effort, agent_ui_update never fails):
365 1. In-memory store → polled by SSE stream → Nunba LiquidUI (web)
366 2. EventBus → WAMP bridge → Android/iOS React Native via Crossbar
367 3. EventBus → any other subscriber (desktop, CLI dashboard)
368 """
369 if not self.a2ui_enabled:
370 return False
371 comp_type = component.get('type', '')
372 if comp_type not in COMPONENT_TYPES:
373 logger.warning("Invalid A2UI component type: %s", comp_type)
374 return False
376 import time as _time
377 component['_ts'] = _time.time()
378 component['_agent_id'] = agent_id
380 # 1. Store for SSE polling (Nunba web LiquidUI)
381 with self._lock:
382 if agent_id not in self._agent_components:
383 self._agent_components[agent_id] = []
384 self._agent_components[agent_id].append(component)
385 if len(self._agent_components[agent_id]) > 5:
386 self._agent_components[agent_id] = \
387 self._agent_components[agent_id][-5:]
389 # 2. Push to EventBus → WAMP → Android/iOS/Desktop
390 # The WAMP bridge (core/platform/events.py) auto-publishes to
391 # com.hartos.event.agent.ui.update which Android subscribes to
392 # via AutobahnConnectionManager. Android renders as floating
393 # overlay on top of AbstractChatActivity.
394 try:
395 from core.platform.events import emit_event
396 emit_event('agent.ui.update', {
397 'agent_id': agent_id,
398 'component': component,
399 })
400 except Exception:
401 pass # EventBus emission is best-effort
403 logger.info("A2UI: agent %s pushed %s component", agent_id, comp_type)
404 return True
406 def agent_request_approval(
407 self, agent_id: str, action: str, description: str
408 ) -> dict:
409 component = {
410 'type': 'approval', 'agent_id': agent_id, 'action': action,
411 'description': description,
412 'options': ['Approve', 'Deny', 'Ask me later'],
413 'timestamp': time.time(),
414 }
415 self.agent_ui_update(agent_id, component)
416 return {'status': 'approval_requested', 'component': component}
418 # ─── Voice I/O — preserved ────────────────────────────────
420 def handle_voice_input(self, audio_path: str) -> dict:
421 if not self.voice_enabled:
422 return {'error': 'Voice not enabled'}
423 from core.http_pool import pooled_post
424 try:
425 with open(audio_path, 'rb') as f:
426 resp = pooled_post(
427 f'http://localhost:{self.model_bus_port}/v1/stt',
428 files={'audio': f}, timeout=30)
429 if resp.status_code == 200:
430 text = resp.json().get('text', '')
431 if text:
432 return self._process_voice_command(text)
433 except Exception as e:
434 logger.warning("Voice input failed: %s", e)
435 return {'error': 'Voice recognition failed'}
437 def _process_voice_command(self, text: str) -> dict:
438 from core.http_pool import pooled_post
439 try:
440 resp = pooled_post(
441 f'http://localhost:{self.model_bus_port}/v1/chat',
442 json={
443 'prompt': f'User said: "{text}". What action should the '
444 f'OS take? Respond with JSON: '
445 f'{{"action": "...", "params": {{}}}}',
446 }, timeout=15)
447 if resp.status_code == 200:
448 return {'text': text, 'response': resp.json().get('response', ''),
449 'source': 'voice'}
450 except Exception:
451 pass
452 return {'text': text, 'response': 'Could not process', 'source': 'voice'}
454 # ─── Glass Desktop Shell Render ───────────────────────────
456 def render_desktop_shell(self) -> str:
457 """Render the complete glass desktop shell HTML.
459 Auto-detects hardware tier and injects performance mode:
460 - Potato/Observer: no blur, no animations, lazy iframes, reduced polling
461 - Lite: reduced blur, fast animations
462 - Standard+: full glass experience
463 """
464 try:
465 from integrations.agent_engine.theme_service import ThemeService
466 css_vars = ThemeService.get_css_variables()
467 theme = ThemeService.get_active_theme()
468 except Exception:
469 css_vars = ':root { --hart-background: #0F0E17; --hart-accent: #6C63FF; --hart-active: #00e676; --hart-text: #e0e0e0; --hart-glass-bg: rgba(15,14,23,0.65); --hart-glass-border: rgba(108,99,255,0.15); --hart-muted: #78909c; --hart-surface: #1a1a2e; --hart-blur: 20px; --hart-saturation: 180%; --hart-radius: 16px; --hart-panel-opacity: 0.65; --hart-topbar-height: 40px; --hart-icon-size: 20px; --hart-titlebar-height: 32px; --hart-font-family: "JetBrains Mono"; --hart-font-size: 13px; --hart-heading-size: 18px; --hart-font-weight: 400; --hart-heading-weight: 600; --hart-anim-speed: 200ms; --hart-error: #FF6B6B; --hart-caution: #ffab40; --hart-heading: #6C63FF; --hart-surface-hover: #252540; }'
470 theme = {}
472 # Performance tier detection
473 perf = theme.get('performance', {})
474 is_potato = perf.get('disable_blur', False)
476 wallpaper = theme.get('wallpaper', {})
477 wp_css = wallpaper.get('value', 'linear-gradient(135deg,#0F0E17 0%,#1a1a2e 50%,#16213e 100%)')
478 if wallpaper.get('type') == 'solid':
479 wp_css = wallpaper['value']
481 # Import panel manifest
482 try:
483 from integrations.agent_engine.shell_manifest import (
484 PANEL_MANIFEST, DYNAMIC_PANELS, SYSTEM_PANELS, PANEL_GROUPS)
485 manifest_json = json.dumps(PANEL_MANIFEST)
486 system_json = json.dumps(SYSTEM_PANELS)
487 groups_json = json.dumps(PANEL_GROUPS)
488 except Exception:
489 manifest_json = '{}'
490 system_json = '{}'
491 groups_json = '[]'
493 # CSS animations — defined outside f-string to avoid brace conflicts
494 _CSS_SLIDE_IN = '@keyframes slideInRight{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}'
495 _CSS_FADE_OUT = '@keyframes fadeOutToast{to{opacity:0;transform:translateX(30px)}}'
496 _CSS_PULSE = '@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}'
497 _CSS_ANIMATIONS = (
498 '@keyframes fadeIn{from{opacity:0;transform:scale(0.95) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}}'
499 ' .panel{animation:fadeIn var(--hart-anim-speed) ease-out}'
500 ' .panel.closing{opacity:0;transform:scale(0.95);transition:opacity 0.2s,transform 0.2s}'
501 ' .panel.minimizing{opacity:0;transform:scale(0.8) translateY(20px);transition:opacity 0.15s,transform 0.15s}'
502 ' .start-menu{transform:translateY(20px);opacity:0;transition:transform 0.2s ease-out,opacity 0.15s ease-out}'
503 ' .start-menu.open{transform:translateY(0);opacity:1}'
504 )
505 _CSS_NO_ANIMATIONS = '/* animations disabled for performance */ .panel{animation:none}'
507 # ── HART Design System (Material Design 3 inspired) ──
508 _CSS_DESIGN_SYSTEM = '''
509/* ═══ HART Design System ═══ */
510/* Content-first · Purposeful motion · 4dp grid */
512:root {
513 /* Typography tokens */
514 --ds-font-body: "Inter", -apple-system, "Segoe UI", Roboto, sans-serif;
515 --ds-font-mono: "JetBrains Mono", "Fira Code", monospace;
517 /* Spacing scale (4dp grid) */
518 --ds-space-0:0px; --ds-space-px:1px;
519 --ds-space-1:4px; --ds-space-2:8px; --ds-space-3:12px; --ds-space-4:16px;
520 --ds-space-5:20px; --ds-space-6:24px; --ds-space-8:32px; --ds-space-10:40px;
521 --ds-space-12:48px; --ds-space-16:64px;
523 /* Elevation (Material 3 dark-theme shadows) */
524 --ds-elevation-0: none;
525 --ds-elevation-1: 0 1px 3px 1px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.3);
526 --ds-elevation-2: 0 2px 6px 2px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.3);
527 --ds-elevation-3: 0 4px 8px 3px rgba(0,0,0,0.15), 0 1px 3px rgba(0,0,0,0.3);
528 --ds-elevation-4: 0 6px 10px 4px rgba(0,0,0,0.15), 0 2px 3px rgba(0,0,0,0.3);
529 --ds-elevation-5: 0 8px 12px 6px rgba(0,0,0,0.15), 0 4px 4px rgba(0,0,0,0.3);
531 /* Motion */
532 --ds-duration-short: 100ms; --ds-duration-medium: 200ms;
533 --ds-duration-long: 350ms; --ds-duration-extra-long: 500ms;
534 --ds-ease-standard: cubic-bezier(0.2, 0, 0, 1);
535 --ds-ease-decelerate: cubic-bezier(0, 0, 0, 1);
536 --ds-ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
537 --ds-ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
539 /* Surface tones (elevation tint on dark) */
540 --ds-surface-dim: rgba(15,14,23,0.85);
541 --ds-surface-1: rgba(255,255,255,0.05);
542 --ds-surface-2: rgba(255,255,255,0.08);
543 --ds-surface-3: rgba(255,255,255,0.11);
544 --ds-surface-4: rgba(255,255,255,0.12);
545 --ds-surface-5: rgba(255,255,255,0.14);
547 /* State layers */
548 --ds-state-hover: rgba(255,255,255,0.08);
549 --ds-state-focus: rgba(255,255,255,0.12);
550 --ds-state-pressed: rgba(255,255,255,0.16);
551 --ds-state-dragged: rgba(255,255,255,0.16);
553 /* Border radius scale */
554 --ds-radius-xs:4px; --ds-radius-sm:8px; --ds-radius-md:12px;
555 --ds-radius-lg:16px; --ds-radius-xl:24px; --ds-radius-full:9999px;
557 /* Icon sizes */
558 --ds-icon-xs:16px; --ds-icon-sm:20px; --ds-icon-md:24px;
559 --ds-icon-lg:32px; --ds-icon-xl:48px;
560}
562/* ── Body font override: Inter for body, JetBrains Mono for code ── */
563html, body { font-family: var(--ds-font-body); line-height: 1.5 }
565/* ── Type Scale ── */
566.ds-display-lg{font-size:57px;line-height:64px;font-weight:400;letter-spacing:-0.25px}
567.ds-display-md{font-size:45px;line-height:52px;font-weight:400}
568.ds-display-sm{font-size:36px;line-height:44px;font-weight:400}
569.ds-headline-lg{font-size:32px;line-height:40px;font-weight:600}
570.ds-headline-md{font-size:28px;line-height:36px;font-weight:600}
571.ds-headline-sm{font-size:24px;line-height:32px;font-weight:600}
572.ds-title-lg{font-size:22px;line-height:28px;font-weight:500}
573.ds-title-md{font-size:16px;line-height:24px;font-weight:500;letter-spacing:0.15px}
574.ds-title-sm{font-size:14px;line-height:20px;font-weight:500;letter-spacing:0.1px}
575.ds-body-lg{font-size:16px;line-height:24px;font-weight:400;letter-spacing:0.5px}
576.ds-body-md{font-size:14px;line-height:20px;font-weight:400;letter-spacing:0.25px}
577.ds-body-sm{font-size:12px;line-height:16px;font-weight:400;letter-spacing:0.4px}
578.ds-label-lg{font-size:14px;line-height:20px;font-weight:500;letter-spacing:0.1px}
579.ds-label-md{font-size:12px;line-height:16px;font-weight:500;letter-spacing:0.5px}
580.ds-label-sm{font-size:11px;line-height:16px;font-weight:500;letter-spacing:0.5px}
581.ds-mono{font-family:var(--ds-font-mono)}
583/* ── Elevation ── */
584.ds-elevation-0{box-shadow:var(--ds-elevation-0)}
585.ds-elevation-1{box-shadow:var(--ds-elevation-1)}
586.ds-elevation-2{box-shadow:var(--ds-elevation-2)}
587.ds-elevation-3{box-shadow:var(--ds-elevation-3)}
588.ds-elevation-4{box-shadow:var(--ds-elevation-4)}
589.ds-elevation-5{box-shadow:var(--ds-elevation-5)}
591/* ── Button ── */
592.ds-btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--ds-space-2);
593 padding:10px var(--ds-space-6);border-radius:var(--ds-radius-full);
594 font-family:var(--ds-font-body);font-size:14px;font-weight:500;letter-spacing:0.1px;
595 line-height:20px;cursor:pointer;border:none;outline:none;position:relative;overflow:hidden;
596 transition:box-shadow var(--ds-duration-medium) var(--ds-ease-standard),
597 background var(--ds-duration-short) var(--ds-ease-standard),
598 filter var(--ds-duration-short) var(--ds-ease-standard);
599 user-select:none;-webkit-tap-highlight-color:transparent}
600.ds-btn:focus-visible{outline:2px solid var(--hart-accent);outline-offset:2px}
601.ds-btn:disabled,.ds-btn[disabled]{opacity:0.38;pointer-events:none}
602.ds-btn .mi{font-size:18px}
603.ds-btn-primary{background:var(--hart-accent);color:#fff}
604.ds-btn-primary:hover{box-shadow:var(--ds-elevation-1);filter:brightness(1.1)}
605.ds-btn-primary:active{filter:brightness(0.9)}
606.ds-btn-secondary{background:transparent;color:var(--hart-accent);border:1px solid var(--hart-glass-border)}
607.ds-btn-secondary:hover{background:var(--ds-state-hover)}
608.ds-btn-secondary:active{background:var(--ds-state-pressed)}
609.ds-btn-text{background:transparent;color:var(--hart-accent);padding:10px var(--ds-space-3)}
610.ds-btn-text:hover{background:var(--ds-state-hover)}
611.ds-btn-tonal{background:var(--ds-surface-3);color:var(--hart-accent)}
612.ds-btn-tonal:hover{box-shadow:var(--ds-elevation-1);background:var(--ds-surface-4)}
613.ds-btn-danger{background:var(--hart-error);color:#fff}
614.ds-btn-danger:hover{box-shadow:var(--ds-elevation-1);filter:brightness(1.1)}
615.ds-btn-icon{padding:var(--ds-space-2);border-radius:var(--ds-radius-full);
616 min-width:40px;min-height:40px}
617.ds-btn-sm{padding:6px var(--ds-space-4);font-size:12px;line-height:16px}
619/* Ripple */
620.ds-ripple{position:absolute;border-radius:50%;background:rgba(255,255,255,0.2);
621 transform:scale(0);animation:ds-ripple-anim 500ms ease-out forwards;pointer-events:none}
622@keyframes ds-ripple-anim{to{transform:scale(2.5);opacity:0}}
624/* ── Input ── */
625.ds-input-wrap{position:relative;display:flex;flex-direction:column;gap:var(--ds-space-1)}
626.ds-input{width:100%;padding:var(--ds-space-3) var(--ds-space-4);
627 border-radius:var(--ds-radius-sm);border:1px solid var(--hart-glass-border);
628 background:var(--ds-surface-1);color:var(--hart-text);
629 font-family:var(--ds-font-body);font-size:14px;line-height:20px;outline:none;
630 transition:border-color var(--ds-duration-medium) var(--ds-ease-standard),
631 box-shadow var(--ds-duration-medium) var(--ds-ease-standard)}
632.ds-input:focus{border-color:var(--hart-accent);box-shadow:0 0 0 2px rgba(108,99,255,0.2)}
633.ds-input::placeholder{color:var(--hart-muted)}
634.ds-input-label{font-size:12px;font-weight:500;letter-spacing:0.5px;
635 color:var(--hart-muted);text-transform:uppercase}
636.ds-input-error{border-color:var(--hart-error)}
637.ds-input-error:focus{box-shadow:0 0 0 2px rgba(255,107,107,0.2)}
638.ds-input-help{font-size:12px;color:var(--hart-muted);margin-top:var(--ds-space-1)}
640/* ── Select ── */
641.ds-select{width:100%;padding:var(--ds-space-3) var(--ds-space-4);padding-right:var(--ds-space-8);
642 border-radius:var(--ds-radius-sm);border:1px solid var(--hart-glass-border);
643 background:var(--ds-surface-1);color:var(--hart-text);font-family:var(--ds-font-body);
644 font-size:14px;outline:none;appearance:none;cursor:pointer;
645 background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='%2378909c'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
646 background-repeat:no-repeat;background-position:right 8px center;
647 transition:border-color var(--ds-duration-medium) var(--ds-ease-standard)}
648.ds-select:focus{border-color:var(--hart-accent)}
649.ds-select option{background:var(--hart-surface);color:var(--hart-text)}
651/* ── Slider ── */
652.ds-slider{-webkit-appearance:none;appearance:none;width:100%;height:4px;
653 background:var(--ds-surface-3);border-radius:var(--ds-radius-full);outline:none;
654 transition:background var(--ds-duration-medium)}
655.ds-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:20px;height:20px;
656 border-radius:50%;background:var(--hart-accent);cursor:pointer;box-shadow:var(--ds-elevation-1);
657 transition:box-shadow var(--ds-duration-short) var(--ds-ease-standard),
658 transform var(--ds-duration-short) var(--ds-ease-spring)}
659.ds-slider::-webkit-slider-thumb:hover{box-shadow:var(--ds-elevation-2);transform:scale(1.15)}
660.ds-slider::-webkit-slider-thumb:active{box-shadow:var(--ds-elevation-3);transform:scale(1.25)}
661.ds-slider::-moz-range-thumb{width:20px;height:20px;border-radius:50%;
662 background:var(--hart-accent);cursor:pointer;border:none;box-shadow:var(--ds-elevation-1)}
664/* ── Card ── */
665.ds-card{background:var(--hart-surface);border-radius:var(--ds-radius-md);
666 padding:var(--ds-space-4);border:1px solid var(--hart-glass-border);
667 transition:box-shadow var(--ds-duration-medium) var(--ds-ease-standard),
668 transform var(--ds-duration-medium) var(--ds-ease-standard)}
669.ds-card-elevated{box-shadow:var(--ds-elevation-1)}
670.ds-card-interactive{cursor:pointer}
671.ds-card-interactive:hover{box-shadow:var(--ds-elevation-2);transform:translateY(-1px)}
672.ds-card-interactive:active{transform:translateY(0);box-shadow:var(--ds-elevation-1)}
674/* ── Status Chip ── */
675.ds-chip{display:inline-flex;align-items:center;gap:var(--ds-space-1);
676 padding:var(--ds-space-1) var(--ds-space-3);border-radius:var(--ds-radius-full);
677 font-size:12px;font-weight:500;letter-spacing:0.5px;line-height:16px;
678 border:1px solid var(--hart-glass-border);background:var(--ds-surface-1)}
679.ds-chip-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
680.ds-chip-success .ds-chip-dot{background:var(--hart-active)}
681.ds-chip-warning .ds-chip-dot{background:var(--hart-caution)}
682.ds-chip-error .ds-chip-dot{background:var(--hart-error)}
684/* ── Progress Bar ── */
685.ds-progress{height:6px;background:var(--ds-surface-3);border-radius:var(--ds-radius-full);overflow:hidden}
686.ds-progress-fill{height:100%;border-radius:var(--ds-radius-full);
687 transition:width var(--ds-duration-long) var(--ds-ease-decelerate)}
689/* ── Skeleton Loader ── */
690.ds-skeleton{background:linear-gradient(90deg,var(--ds-surface-2) 25%,var(--ds-surface-4) 50%,var(--ds-surface-2) 75%);
691 background-size:200% 100%;border-radius:var(--ds-radius-sm);
692 animation:ds-shimmer 1.5s ease-in-out infinite}
693.ds-skeleton-text{height:14px;margin-bottom:var(--ds-space-2);border-radius:var(--ds-radius-xs)}
694.ds-skeleton-title{height:22px;width:50%;margin-bottom:var(--ds-space-3)}
695.ds-skeleton-circle{border-radius:50%}
696.ds-skeleton-bar{height:6px;border-radius:var(--ds-radius-full)}
697.ds-skeleton-card{height:64px;border-radius:var(--ds-radius-md);margin-bottom:var(--ds-space-2)}
698@keyframes ds-shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
700/* ── Modal ── */
701.ds-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;
702 align-items:center;justify-content:center;background:rgba(0,0,0,0.6);
703 opacity:0;visibility:hidden;
704 transition:opacity var(--ds-duration-medium) var(--ds-ease-standard),visibility var(--ds-duration-medium)}
705.ds-modal-overlay.ds-open{opacity:1;visibility:visible}
706.ds-modal{background:var(--hart-glass-bg);border:1px solid var(--hart-glass-border);
707 border-radius:var(--ds-radius-lg);padding:var(--ds-space-6);
708 max-width:480px;width:calc(100% - var(--ds-space-8));box-shadow:var(--ds-elevation-5);
709 backdrop-filter:blur(20px) saturate(180%);-webkit-backdrop-filter:blur(20px) saturate(180%);
710 transform:scale(0.92) translateY(20px);opacity:0;
711 transition:transform var(--ds-duration-long) var(--ds-ease-spring),
712 opacity var(--ds-duration-medium) var(--ds-ease-decelerate)}
713.ds-modal-overlay.ds-open .ds-modal{transform:scale(1) translateY(0);opacity:1}
714.ds-modal-title{font-size:22px;line-height:28px;font-weight:500;margin-bottom:var(--ds-space-4)}
715.ds-modal-body{font-size:14px;line-height:20px;color:var(--hart-muted);margin-bottom:var(--ds-space-6)}
716.ds-modal-actions{display:flex;justify-content:flex-end;gap:var(--ds-space-2)}
718/* ── Toast (upgraded) ── */
719.ds-toast{display:flex;align-items:flex-start;gap:var(--ds-space-3);padding:var(--ds-space-4);
720 border-radius:var(--ds-radius-md);background:var(--hart-glass-bg);
721 border:1px solid var(--hart-glass-border);box-shadow:var(--ds-elevation-3);
722 max-width:380px;pointer-events:auto;cursor:pointer;position:relative;overflow:hidden;
723 backdrop-filter:blur(16px) saturate(150%);-webkit-backdrop-filter:blur(16px) saturate(150%);
724 animation:ds-toast-in var(--ds-duration-long) var(--ds-ease-spring)}
725.ds-toast-icon{font-size:20px;flex-shrink:0;margin-top:1px}
726.ds-toast-content{flex:1;min-width:0}
727.ds-toast-title{font-size:14px;font-weight:500;line-height:20px}
728.ds-toast-message{font-size:12px;line-height:16px;color:var(--hart-muted);margin-top:2px}
729.ds-toast-progress{position:absolute;bottom:0;left:0;height:2px;background:var(--hart-accent);
730 animation:ds-toast-countdown 5s linear forwards}
731.ds-toast-exit{animation:ds-toast-out var(--ds-duration-medium) var(--ds-ease-accelerate) forwards}
732@keyframes ds-toast-in{from{transform:translateX(100%) scale(0.95);opacity:0}to{transform:translateX(0) scale(1);opacity:1}}
733@keyframes ds-toast-out{to{transform:translateX(30px);opacity:0}}
734@keyframes ds-toast-countdown{from{width:100%}to{width:0%}}
736/* ── Panel Content Layout ── */
737.ds-panel-grid{display:grid;gap:var(--ds-space-3)}
738.ds-panel-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--ds-space-2)}
739.ds-panel-title{font-size:22px;line-height:28px;font-weight:500;color:var(--hart-heading)}
740.ds-panel-subtitle{font-size:14px;color:var(--hart-muted)}
741.ds-section-label{font-size:11px;font-weight:600;text-transform:uppercase;
742 letter-spacing:1.5px;color:var(--hart-muted);padding:var(--ds-space-2) 0}
744/* ── List Item ── */
745.ds-list-item{display:flex;align-items:center;gap:var(--ds-space-3);
746 padding:var(--ds-space-3);border-radius:var(--ds-radius-sm);background:var(--hart-surface);
747 transition:background var(--ds-duration-short) var(--ds-ease-standard),
748 transform var(--ds-duration-short) var(--ds-ease-standard)}
749.ds-list-item-interactive{cursor:pointer}
750.ds-list-item-interactive:hover{background:var(--hart-surface-hover);transform:translateY(-1px)}
751.ds-list-item-icon{font-size:var(--ds-icon-sm);flex-shrink:0}
752.ds-list-item-content{flex:1;min-width:0}
753.ds-list-item-primary{font-size:14px;line-height:20px}
754.ds-list-item-secondary{font-size:12px;line-height:16px;color:var(--hart-muted)}
755.ds-list-item-trailing{font-size:12px;flex-shrink:0}
757/* ── Metric Display ── */
758.ds-metric{text-align:center;padding:var(--ds-space-4)}
759.ds-metric-value{font-size:32px;font-weight:600;line-height:40px}
760.ds-metric-label{font-size:12px;color:var(--hart-muted);margin-top:var(--ds-space-1)}
761.ds-metric-icon{font-size:var(--ds-icon-xl);margin-bottom:var(--ds-space-2)}
763/* ── Dot / Divider ── */
764.ds-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
765.ds-divider{border:none;border-top:1px solid var(--hart-glass-border);margin:var(--ds-space-3) 0}
767/* ── Flex utilities ── */
768.ds-flex{display:flex}.ds-flex-col{flex-direction:column}
769.ds-flex-center{align-items:center;justify-content:center}
770.ds-flex-between{justify-content:space-between}.ds-flex-wrap{flex-wrap:wrap}
771.ds-gap-1{gap:var(--ds-space-1)}.ds-gap-2{gap:var(--ds-space-2)}
772.ds-gap-3{gap:var(--ds-space-3)}.ds-gap-4{gap:var(--ds-space-4)}
773.ds-flex-1{flex:1;min-width:0}
775/* ── Color utilities ── */
776.ds-text-accent{color:var(--hart-accent)}.ds-text-active{color:var(--hart-active)}
777.ds-text-error{color:var(--hart-error)}.ds-text-caution{color:var(--hart-caution)}
778.ds-text-muted{color:var(--hart-muted)}.ds-text-heading{color:var(--hart-heading)}
780/* ── Animations: fade-in, stagger ── */
781.ds-fade-in{animation:ds-content-enter var(--ds-duration-medium) var(--ds-ease-decelerate)}
782@keyframes ds-content-enter{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
783.ds-stagger>*{animation:ds-content-enter var(--ds-duration-medium) var(--ds-ease-decelerate) both}
784.ds-stagger>*:nth-child(1){animation-delay:0ms}
785.ds-stagger>*:nth-child(2){animation-delay:30ms}
786.ds-stagger>*:nth-child(3){animation-delay:40ms}
787.ds-stagger>*:nth-child(4){animation-delay:50ms}
788.ds-stagger>*:nth-child(5){animation-delay:60ms}
789.ds-stagger>*:nth-child(6){animation-delay:70ms}
790.ds-stagger>*:nth-child(7){animation-delay:80ms}
791.ds-stagger>*:nth-child(8){animation-delay:90ms}
792.ds-stagger>*:nth-child(n+9){animation-delay:100ms}
794/* ── Reduced motion ── */
795@media(prefers-reduced-motion:reduce){
796 *,*::before,*::after{animation-duration:0.01ms!important;
797 animation-iteration-count:1!important;transition-duration:0.01ms!important}
798}
799'''
801 return f'''<!DOCTYPE html>
802<html lang="en"><head>
803<meta charset="utf-8">
804<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
805<title>HART OS</title>
806<link rel="preconnect" href="https://fonts.googleapis.com">
807<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet">
808<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
809<style>
810{css_vars}
811*{{margin:0;padding:0;box-sizing:border-box}}
812::selection{{background:var(--hart-accent);color:#fff}}
813html,body{{width:100%;height:100%;overflow:hidden;font-family:var(--hart-font-family),monospace;
814 font-size:var(--hart-font-size);font-weight:var(--hart-font-weight);color:var(--hart-text)}}
816/* ── Wallpaper ── */
817.wallpaper{{position:fixed;inset:0;z-index:0;background:{wp_css}}}
819/* ── Glass mixin (perf-aware) ── */
820.glass{{background:var(--hart-glass-bg);
821 {'backdrop-filter:blur(var(--hart-blur)) saturate(var(--hart-saturation));-webkit-backdrop-filter:blur(var(--hart-blur)) saturate(var(--hart-saturation));' if not is_potato else '/* blur disabled for performance */'}
822 border:1px solid var(--hart-glass-border);border-radius:var(--hart-radius)}}
824/* ── Top Bar ── */
825.top-bar{{position:fixed;top:0;left:0;right:0;height:var(--hart-topbar-height);z-index:1000;
826 display:flex;align-items:center;padding:0 12px;gap:8px;border-radius:0;
827 border-bottom:1px solid var(--hart-glass-border);border-top:0}}
828.top-bar .start-btn{{display:flex;align-items:center;gap:6px;padding:4px 12px;
829 border-radius:8px;cursor:pointer;transition:background var(--hart-anim-speed);
830 font-weight:var(--hart-heading-weight);font-size:13px;user-select:none}}
831.top-bar .start-btn:hover{{background:var(--hart-surface-hover,rgba(255,255,255,0.08))}}
832.top-bar .start-btn .mi{{font-size:20px;color:var(--hart-accent)}}
833.top-bar-center{{flex:1;display:flex;align-items:center;gap:6px;padding:0 12px;
834 font-size:12px;color:var(--hart-muted);overflow:hidden}}
835.top-bar-center .agent-chip{{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;
836 border-radius:10px;background:var(--hart-surface,rgba(255,255,255,0.05));font-size:11px}}
837.top-bar-center .agent-chip .dot{{width:6px;height:6px;border-radius:50%;background:var(--hart-active)}}
838.top-bar-right{{display:flex;align-items:center;gap:8px}}
839.top-bar-right .tray-btn{{width:32px;height:32px;display:flex;align-items:center;justify-content:center;
840 border-radius:8px;cursor:pointer;transition:background var(--hart-anim-speed);position:relative}}
841.top-bar-right .tray-btn:hover{{background:var(--hart-surface-hover,rgba(255,255,255,0.08))}}
842.top-bar-right .tray-btn .mi{{font-size:var(--hart-icon-size);color:var(--hart-muted)}}
843.top-bar-right .clock{{font-size:12px;font-weight:500;padding:0 8px}}
844.badge{{position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--hart-error)}}
846/* ── Panel Container ── */
847.panel-container{{position:fixed;top:var(--hart-topbar-height);left:0;right:0;
848 bottom:44px;z-index:100;pointer-events:none}}
849.panel-container>*{{pointer-events:auto}}
851/* ── Glass Panel (floating window) ── */
852.panel{{position:absolute;display:flex;flex-direction:column;min-width:320px;min-height:240px;
853 {'box-shadow:0 8px 32px rgba(0,0,0,0.4);' if not is_potato else 'box-shadow:0 2px 8px rgba(0,0,0,0.3);'}overflow:hidden;{'transition:box-shadow var(--hart-anim-speed)' if not is_potato else 'transition:none'}}}
854.panel.focused{{{'box-shadow:0 12px 48px rgba(0,0,0,0.5);' if not is_potato else 'box-shadow:0 3px 12px rgba(0,0,0,0.4);'}z-index:999}}
855.panel-titlebar{{height:var(--hart-titlebar-height);display:flex;align-items:center;padding:0 8px;
856 gap:6px;cursor:grab;user-select:none;flex-shrink:0;border-bottom:1px solid var(--hart-glass-border)}}
857.panel-titlebar:active{{cursor:grabbing}}
858.panel-titlebar .mi{{font-size:16px;color:var(--hart-accent);flex-shrink:0}}
859.panel-titlebar .title{{flex:1;font-size:12px;font-weight:500;overflow:hidden;
860 text-overflow:ellipsis;white-space:nowrap}}
861.panel-titlebar .ctrl{{display:flex;gap:2px}}
862.panel-titlebar .ctrl span{{width:24px;height:24px;display:flex;align-items:center;justify-content:center;
863 border-radius:6px;cursor:pointer;font-size:14px;transition:background var(--hart-anim-speed)}}
864.panel-titlebar .ctrl span:hover{{background:rgba(255,255,255,0.1)}}
865.panel-titlebar .ctrl .close:hover{{background:var(--hart-error)}}
866.panel-body{{flex:1;overflow:hidden;position:relative}}
867.panel-body iframe{{width:100%;height:100%;border:none;background:transparent}}
868.panel-body .native-content{{padding:16px;overflow-y:auto;height:100%;font-size:13px}}
869.panel-resize{{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize}}
871/* ── Start Menu ── */
872.start-menu{{position:fixed;bottom:calc(var(--hart-topbar-height));left:8px;
873 width:720px;max-height:calc(100vh - var(--hart-topbar-height) - 24px);
874 z-index:2000;padding:16px;display:none;flex-direction:column;overflow:hidden}}
875.start-menu.open{{display:flex}}
876.start-search{{width:100%;padding:8px 12px;border-radius:10px;border:1px solid var(--hart-glass-border);
877 background:var(--hart-surface,rgba(255,255,255,0.05));color:var(--hart-text);
878 font-family:var(--hart-font-family);font-size:13px;outline:none;margin-bottom:12px}}
879.start-search:focus{{border-color:var(--hart-accent)}}
880.start-scroll{{flex:1;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;
881 scrollbar-color:var(--hart-muted) transparent}}
882.start-group{{margin-bottom:12px}}
883.start-group-label{{font-size:10px;text-transform:uppercase;letter-spacing:1.5px;
884 color:var(--hart-muted);padding:4px 4px 6px;font-weight:600}}
885.start-grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:4px}}
886.start-item{{display:flex;flex-direction:column;align-items:center;padding:10px 4px;
887 border-radius:10px;cursor:pointer;transition:background var(--hart-anim-speed);
888 text-align:center;gap:4px;user-select:none}}
889.start-item:hover{{background:var(--hart-surface-hover,rgba(255,255,255,0.08))}}
890.start-item .mi{{font-size:24px;color:var(--hart-accent)}}
891.start-item .label{{font-size:11px;line-height:1.2;opacity:0.85}}
892.start-divider{{border-top:1px solid var(--hart-glass-border);margin:8px 0}}
893.start-footer{{display:flex;justify-content:center;gap:16px;padding-top:8px;border-top:1px solid var(--hart-glass-border)}}
894.start-footer .power-btn{{display:flex;align-items:center;gap:4px;padding:6px 12px;
895 border-radius:8px;cursor:pointer;font-size:12px;transition:background var(--hart-anim-speed)}}
896.start-footer .power-btn:hover{{background:var(--hart-surface-hover,rgba(255,255,255,0.08))}}
897.start-footer .power-btn .mi{{font-size:16px}}
899/* ── Agent Pill (collapsed floating bubble) ── */
900.agent-pill{{position:fixed;bottom:56px;right:16px;z-index:1500;display:flex;
901 align-items:center;gap:8px;padding:8px 14px;cursor:pointer;
902 transition:all var(--hart-anim-speed);max-width:360px}}
903.agent-pill:hover{{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,0.3)}}
904.agent-pill.expanded{{max-width:400px;padding:12px}}
905.agent-pill.hidden{{display:none}}
906.agent-pill .mi{{font-size:20px;color:var(--hart-accent);flex-shrink:0}}
907.agent-pill input{{flex:1;background:transparent;border:none;color:var(--hart-text);
908 font-family:var(--hart-font-family);font-size:13px;outline:none;min-width:0}}
909.agent-pill input::placeholder{{color:var(--hart-muted)}}
910.agent-response{{font-size:12px;color:var(--hart-muted);padding-top:6px;
911 border-top:1px solid var(--hart-glass-border);display:none;width:100%}}
912.agent-response.visible{{display:block}}
914/* ── Floating Assistant Chat Panel ── */
915.assistant-chat{{position:fixed;bottom:56px;right:16px;z-index:1600;
916 width:380px;height:520px;display:none;flex-direction:column;
917 border-radius:var(--hart-radius-lg,16px);overflow:hidden;
918 resize:both;min-width:320px;min-height:400px;max-width:600px;max-height:80vh}}
919.assistant-chat.open{{display:flex}}
920.assistant-chat .ac-header{{display:flex;align-items:center;gap:8px;
921 padding:10px 14px;cursor:grab;user-select:none;
922 border-bottom:1px solid var(--hart-glass-border);flex-shrink:0}}
923.assistant-chat .ac-header:active{{cursor:grabbing}}
924.assistant-chat .ac-title{{flex:1;font-size:13px;font-weight:500}}
925.assistant-chat .ac-btn{{background:none;border:none;color:var(--hart-muted);
926 cursor:pointer;padding:2px;font-size:18px}}
927.assistant-chat .ac-btn:hover{{color:var(--hart-text)}}
928.assistant-chat .ac-caps{{display:flex;gap:6px;padding:8px 14px;overflow-x:auto;
929 flex-shrink:0;border-bottom:1px solid var(--hart-glass-border)}}
930.assistant-chat .ac-cap{{display:flex;align-items:center;gap:4px;
931 padding:4px 10px;border-radius:12px;font-size:11px;white-space:nowrap;
932 background:var(--hart-glass-bg);border:1px solid var(--hart-glass-border);
933 cursor:pointer;transition:background 120ms}}
934.assistant-chat .ac-cap:hover{{background:var(--hart-surface-hover,rgba(255,255,255,0.1))}}
935.assistant-chat .ac-cap.active{{background:var(--hart-accent);color:#fff;border-color:var(--hart-accent)}}
936.assistant-chat .ac-cap .mi{{font-size:14px}}
937.assistant-chat .ac-messages{{flex:1;overflow-y:auto;padding:12px 14px;
938 display:flex;flex-direction:column;gap:8px}}
939.assistant-chat .ac-msg{{max-width:85%;padding:8px 12px;border-radius:12px;
940 font-size:13px;line-height:1.4;word-break:break-word}}
941.assistant-chat .ac-msg.user{{align-self:flex-end;
942 background:var(--hart-accent);color:#fff;border-bottom-right-radius:4px}}
943.assistant-chat .ac-msg.assistant{{align-self:flex-start;
944 background:var(--hart-glass-bg);border:1px solid var(--hart-glass-border);
945 border-bottom-left-radius:4px}}
946.assistant-chat .ac-msg.typing{{opacity:0.6;font-style:italic}}
947.assistant-chat .ac-input-row{{display:flex;align-items:center;gap:6px;
948 padding:8px 10px;border-top:1px solid var(--hart-glass-border);flex-shrink:0}}
949.assistant-chat .ac-input{{flex:1;background:transparent;border:1px solid var(--hart-glass-border);
950 border-radius:20px;padding:8px 14px;color:var(--hart-text);
951 font-family:var(--hart-font-family);font-size:13px;outline:none;resize:none}}
952.assistant-chat .ac-input:focus{{border-color:var(--hart-accent)}}
953.assistant-chat .ac-input::placeholder{{color:var(--hart-muted)}}
954.assistant-chat .ac-send{{background:var(--hart-accent);border:none;
955 width:32px;height:32px;border-radius:50%;display:flex;align-items:center;
956 justify-content:center;cursor:pointer;flex-shrink:0;transition:opacity 120ms}}
957.assistant-chat .ac-send:hover{{opacity:0.85}}
958.assistant-chat .ac-send .mi{{font-size:16px;color:#fff}}
960/* ── Context Menu ── */
961.ctx-menu{{position:fixed;z-index:3000;min-width:180px;padding:4px;
962 box-shadow:0 8px 24px rgba(0,0,0,0.5);font-size:12px}}
963.ctx-menu-item{{display:flex;align-items:center;gap:8px;padding:6px 10px;
964 border-radius:6px;cursor:pointer;transition:background 100ms}}
965.ctx-menu-item:hover{{background:var(--hart-surface-hover,rgba(255,255,255,0.1))}}
966.ctx-menu-item .mi{{font-size:16px;color:var(--hart-muted)}}
967.ctx-menu-sep{{border-top:1px solid var(--hart-glass-border);margin:4px 0}}
969/* ── Lock Screen ── */
970.lock-screen{{position:fixed;inset:0;z-index:9999;display:none;align-items:center;
971 justify-content:center;flex-direction:column;gap:16px;
972 background:rgba(0,0,0,{'0.7);backdrop-filter:blur(40px)' if not is_potato else '0.9)'}}}
973.lock-screen.active{{display:flex}}
974.lock-clock{{font-size:64px;font-weight:300}}
975.lock-date{{font-size:16px;color:var(--hart-muted)}}
976.lock-input{{padding:10px 16px;border-radius:12px;border:1px solid var(--hart-glass-border);
977 background:var(--hart-glass-bg);color:var(--hart-text);font-size:14px;
978 font-family:var(--hart-font-family);outline:none;width:280px;text-align:center}}
979.lock-status{{font-size:12px;color:var(--hart-muted)}}
981/* ── Scrollbar ── */
982::-webkit-scrollbar{{width:6px}}
983::-webkit-scrollbar-track{{background:transparent}}
984::-webkit-scrollbar-thumb{{background:var(--hart-muted);border-radius:3px}}
985::-webkit-scrollbar-thumb:hover{{background:var(--hart-accent)}}
987/* ── Taskbar ── */
988.taskbar{{position:fixed;bottom:0;left:0;right:0;height:44px;z-index:8000;
989 display:flex;gap:2px;padding:0 8px;align-items:center;border-radius:0;
990 border-top:1px solid var(--hart-glass-border)}}
991.taskbar-chip{{height:34px;padding:0 12px;display:flex;align-items:center;gap:4px;
992 border-radius:8px;cursor:pointer;{'transition:background 0.15s;' if not is_potato else 'transition:none;'}
993 font-size:12px;user-select:none;border:1px solid transparent}}
994.taskbar-chip:hover{{background:var(--hart-surface-hover,rgba(255,255,255,0.08))}}
995.taskbar-chip.active{{border-bottom:2px solid var(--hart-accent);
996 background:var(--hart-surface,rgba(255,255,255,0.05))}}
997.taskbar-chip .mi{{font-size:16px;color:var(--hart-accent)}}
998.taskbar-chip .chip-label{{max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
1000/* ── Notification Toasts ── */
1001.toast-container{{position:fixed;top:calc(var(--hart-topbar-height) + 12px);right:16px;
1002 display:flex;flex-direction:column;gap:8px;z-index:9500;pointer-events:none}}
1003.toast{{padding:12px 16px;border-radius:12px;pointer-events:auto;cursor:pointer;
1004 max-width:340px;font-size:12px;{'animation:slideInRight 0.3s ease-out,fadeOutToast 0.3s ease-in 4.7s forwards' if not is_potato else ''}}}
1005.toast:hover{{opacity:1!important}}
1006{_CSS_SLIDE_IN if not is_potato else ''}
1007{_CSS_FADE_OUT if not is_potato else ''}
1009/* ── Voice Recording ── */
1010.mic-btn{{cursor:pointer}}
1011.mic-btn.recording{{color:var(--hart-error)!important;{'animation:pulse 1s infinite' if not is_potato else ''}}}
1012{_CSS_PULSE if not is_potato else ''}
1014/* ── Animations ── */
1015{_CSS_ANIMATIONS if not is_potato else _CSS_NO_ANIMATIONS}
1016{_CSS_DESIGN_SYSTEM}
1017</style>
1018</head>
1019<body>
1020<div class="wallpaper"></div>
1022<!-- Top Bar -->
1023<div class="top-bar glass">
1024 <div class="start-btn" onclick="toggleStartMenu()" title="Start Menu (Super)">
1025 <span class="mi material-icons-round">hexagon</span>
1026 <span>HART</span>
1027 </div>
1028 <div class="top-bar-center" id="agent-status"></div>
1029 <div class="top-bar-right">
1030 <div class="tray-btn" onclick="openPanel('notifications')" title="Notifications">
1031 <span class="mi material-icons-round">notifications</span>
1032 <div class="badge" id="notif-badge" style="display:none"></div>
1033 </div>
1034 <div class="tray-btn" onclick="openPanel('appearance')" title="Appearance">
1035 <span class="mi material-icons-round">palette</span>
1036 </div>
1037 <div class="tray-btn" onclick="openPanel('security')" title="Security">
1038 <span class="mi material-icons-round">shield</span>
1039 </div>
1040 <span class="clock" id="clock"></span>
1041 </div>
1042</div>
1044<!-- Panel Container -->
1045<div class="panel-container" id="panels"></div>
1047<!-- Agent Pill (click to expand floating chat) -->
1048<div class="agent-pill glass" id="agent-pill" onclick="toggleAssistantChat()">
1049 <span class="mi material-icons-round" style="color:var(--hart-accent)">chat_bubble</span>
1050 <input id="agent-input" placeholder="Ask HART..." onclick="event.stopPropagation();toggleAssistantChat()" onkeydown="if(event.key==='Enter'){{event.stopPropagation();toggleAssistantChat();setTimeout(function(){{var i=document.getElementById('ac-input');if(i){{i.value=document.getElementById('agent-input').value;document.getElementById('agent-input').value=''}}}},100)}}">
1051 <div class="agent-response" id="agent-resp"></div>
1052</div>
1054<!-- Floating Assistant Chat Panel -->
1055<div class="assistant-chat glass" id="assistant-chat">
1056 <div class="ac-header" id="ac-drag-handle">
1057 <span class="mi material-icons-round" style="font-size:20px;color:var(--hart-accent)">chat_bubble</span>
1058 <span class="ac-title">HART Assistant</span>
1059 <button class="ac-btn mi material-icons-round" onclick="minimizeAssistant()" title="Minimize">remove</button>
1060 <button class="ac-btn mi material-icons-round" onclick="toggleAssistantChat()" title="Close">close</button>
1061 </div>
1062 <div class="ac-caps" id="ac-caps"></div>
1063 <div class="ac-messages" id="ac-messages">
1064 <div class="ac-msg assistant">Hi! I can help with anything — chat, code, agents, vision, voice, remote desktop, and 3,200+ OpenClaw skills. What would you like to do?</div>
1065 </div>
1066 <div class="ac-input-row">
1067 <span class="mi material-icons-round ac-btn" onclick="acVoiceInput()" title="Voice input" style="font-size:20px">mic</span>
1068 <input class="ac-input" id="ac-input" placeholder="Ask anything..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){{event.preventDefault();acSend()}}">
1069 <button class="ac-send" onclick="acSend()"><span class="mi material-icons-round">send</span></button>
1070 </div>
1071</div>
1073<!-- Start Menu -->
1074<div class="start-menu glass" id="start-menu">
1075 <input class="start-search" id="start-search" placeholder="Search..." oninput="filterStart(this.value)">
1076 <div class="start-scroll" id="start-scroll"></div>
1077 <div class="start-footer">
1078 <div class="power-btn" onclick="shellAction('lock')"><span class="mi material-icons-round">lock</span>Lock</div>
1079 <div class="power-btn" onclick="shellAction('suspend')"><span class="mi material-icons-round">dark_mode</span>Sleep</div>
1080 <div class="power-btn" onclick="shellAction('restart')"><span class="mi material-icons-round">refresh</span>Restart</div>
1081 <div class="power-btn" onclick="shellAction('shutdown')"><span class="mi material-icons-round">power_settings_new</span>Shut Down</div>
1082 </div>
1083</div>
1085<!-- Lock Screen -->
1086<div class="lock-screen" id="lock-screen">
1087 <div class="lock-clock" id="lock-clock"></div>
1088 <div class="lock-date" id="lock-date"></div>
1089 <input class="lock-input" type="password" placeholder="Password" id="lock-pw"
1090 onkeydown="if(event.key==='Enter')unlock()">
1091 <div class="lock-status" id="lock-status"></div>
1092</div>
1094<!-- Taskbar (open panels as chips) -->
1095<div class="taskbar glass" id="taskbar"></div>
1097<!-- Toast Notifications -->
1098<div class="toast-container" id="toast-container"></div>
1100<!-- Context Menu -->
1101<div class="ctx-menu glass" id="ctx-menu" style="display:none"></div>
1103<script>
1104// ═══ Configuration ═══
1105const BACKEND = 'http://localhost:{self.backend_port}';
1106const SHELL = 'http://localhost:{self.port}';
1107const MANIFEST = {manifest_json};
1108const SYSTEM_PANELS = {system_json};
1109const GROUPS = {groups_json};
1110const NUNBA_BASE = '/app/#';
1112// ═══ Performance Config (auto-detected from theme) ═══
1113const PERF = {{
1114 potato: {'true' if is_potato else 'false'},
1115 clockMs: {perf.get('clock_interval_ms', 1000)},
1116 agentStatusMs: {perf.get('agent_status_interval_ms', 5000)},
1117 maxPanels: {perf.get('max_open_panels', 20)},
1118 destroyMinimized: {'true' if perf.get('destroy_minimized_iframes') else 'false'},
1119 lazyIframes: {'true' if perf.get('lazy_load_iframes') else 'false'},
1120}};
1122// ═══ State ═══
1123let panels = {{}};
1124let panelZ = 100;
1125let startOpen = false;
1126let focusedPanel = null;
1128// ═══════════════════════════════════════════════
1129// HART Design System — Component Library
1130// ═══════════════════════════════════════════════
1132// ── Ripple Effect ──
1133function dsRipple(e) {{
1134 if(PERF.potato) return;
1135 const el = e.currentTarget;
1136 const rect = el.getBoundingClientRect();
1137 const ripple = document.createElement('span');
1138 ripple.className = 'ds-ripple';
1139 const size = Math.max(rect.width, rect.height) * 2;
1140 ripple.style.width = ripple.style.height = size + 'px';
1141 ripple.style.left = (e.clientX - rect.left - size/2) + 'px';
1142 ripple.style.top = (e.clientY - rect.top - size/2) + 'px';
1143 el.appendChild(ripple);
1144 ripple.addEventListener('animationend', function(){{ ripple.remove(); }});
1145}}
1147// ── Button Component ──
1148function dsBtn(label, opts) {{
1149 opts = opts || {{}};
1150 const variant = opts.variant || 'primary';
1151 const icon = opts.icon || '';
1152 const cls = opts.cls || '';
1153 const disabled = opts.disabled ? ' disabled' : '';
1154 const onclick = opts.onclick || '';
1155 return '<button class="ds-btn ds-btn-'+variant+' '+cls+'"'+disabled+
1156 ' onclick="dsRipple(event);'+(onclick.replace(/"/g,'"'))+'">' +
1157 (icon ? '<span class="mi material-icons-round">'+icon+'</span>' : '') +
1158 '<span>'+label+'</span></button>';
1159}}
1161// ── Input Component ──
1162function dsInput(opts) {{
1163 opts = opts || {{}};
1164 const type = opts.type || 'text';
1165 const id = opts.id || '';
1166 const label = opts.label || '';
1167 const placeholder = opts.placeholder || '';
1168 const value = opts.value || '';
1169 const oninput = opts.oninput || '';
1170 const onkeydown = opts.onkeydown || '';
1171 const cls = opts.error ? 'ds-input ds-input-error' : 'ds-input';
1172 let html = '<div class="ds-input-wrap">';
1173 if(label) html += '<label class="ds-input-label"'+(id?' for="'+id+'"':'')+'>'+label+'</label>';
1174 html += '<input class="'+cls+'" type="'+type+'"'+(id?' id="'+id+'"':'')+
1175 ' placeholder="'+placeholder+'" value="'+value+'"'+
1176 (oninput?' oninput="'+oninput.replace(/"/g,'"')+'"':'') +
1177 (onkeydown?' onkeydown="'+onkeydown.replace(/"/g,'"')+'"':'') + '>';
1178 if(opts.help) html += '<div class="ds-input-help">'+opts.help+'</div>';
1179 if(opts.errorText) html += '<div class="ds-input-help" style="color:var(--hart-error)">'+opts.errorText+'</div>';
1180 html += '</div>';
1181 return html;
1182}}
1184// ── Select Component ──
1185function dsSelect(opts) {{
1186 opts = opts || {{}};
1187 const id = opts.id || '';
1188 const label = opts.label || '';
1189 const options = opts.options || [];
1190 const onchange = opts.onchange || '';
1191 let html = '<div class="ds-input-wrap">';
1192 if(label) html += '<label class="ds-input-label">'+label+'</label>';
1193 html += '<select class="ds-select"'+(id?' id="'+id+'"':'')+
1194 (onchange?' onchange="'+onchange.replace(/"/g,'"')+'"':'')+'>';
1195 options.forEach(function(o){{
1196 const sel = o.selected ? ' selected' : '';
1197 html += '<option value="'+o.value+'"'+sel+'>'+o.label+'</option>';
1198 }});
1199 html += '</select></div>';
1200 return html;
1201}}
1203// ── Slider Component ──
1204function dsSlider(opts) {{
1205 opts = opts || {{}};
1206 const id = opts.id || '';
1207 const min = opts.min !== undefined ? opts.min : 0;
1208 const max = opts.max !== undefined ? opts.max : 100;
1209 const value = opts.value !== undefined ? opts.value : 50;
1210 const label = opts.label || '';
1211 const unit = opts.unit || '';
1212 const oninput = opts.oninput || '';
1213 let html = '<div class="ds-flex ds-gap-3" style="align-items:center">';
1214 if(label) html += '<span class="ds-label-sm ds-text-muted" style="min-width:80px">'+label+'</span>';
1215 html += '<input type="range" class="ds-slider" min="'+min+'" max="'+max+'" value="'+value+'"'+
1216 (id?' id="'+id+'"':'')+
1217 ' oninput="'+
1218 (oninput?oninput.replace(/"/g,'"')+';':'')+
1219 (id?'document.getElementById(\\''+id+'-val\\').textContent=this.value+\\''+unit+'\\';':'')+
1220 '">';
1221 if(id) html += '<span class="ds-label-md" id="'+id+'-val" style="min-width:40px;text-align:right">'+value+unit+'</span>';
1222 html += '</div>';
1223 return html;
1224}}
1226// ── Skeleton Loader ──
1227function dsSkeleton(type, count) {{
1228 count = count || 3;
1229 if(type === 'panel') {{
1230 return '<div class="ds-panel-grid">' +
1231 '<div class="ds-skeleton ds-skeleton-title"></div>' +
1232 Array.from({{length:count}}).map(function(){{return '<div class="ds-skeleton ds-skeleton-card"></div>';}}).join('') +
1233 '</div>';
1234 }}
1235 if(type === 'list') {{
1236 return Array.from({{length:count}}).map(function(){{
1237 return '<div class="ds-flex ds-gap-3" style="align-items:center;margin-bottom:8px">' +
1238 '<div class="ds-skeleton ds-skeleton-circle" style="width:32px;height:32px"></div>' +
1239 '<div style="flex:1"><div class="ds-skeleton ds-skeleton-text" style="width:70%"></div>' +
1240 '<div class="ds-skeleton ds-skeleton-text" style="width:40%"></div></div></div>';
1241 }}).join('');
1242 }}
1243 return Array.from({{length:count}}).map(function(){{return '<div class="ds-skeleton ds-skeleton-text"></div>';}}).join('');
1244}}
1246// ── Status Row (design system) ──
1247function dsStatusRow(icon, label, value, color, opts) {{
1248 opts = opts || {{}};
1249 const sublabel = opts.sublabel || '';
1250 const trailing = opts.trailing || '';
1251 return '<div class="ds-list-item'+(opts.interactive?' ds-list-item-interactive':'')+'">'+
1252 '<span class="mi material-icons-round ds-list-item-icon" style="color:'+color+'">'+icon+'</span>'+
1253 '<div class="ds-list-item-content">'+
1254 '<div class="ds-list-item-primary">'+label+'</div>'+
1255 (sublabel?'<div class="ds-list-item-secondary">'+sublabel+'</div>':'')+
1256 '</div>'+
1257 '<span class="ds-list-item-trailing" style="color:'+color+'">'+value+'</span>'+
1258 (trailing?trailing:'')+
1259 '</div>';
1260}}
1262// ── Metric Bar (design system) ──
1263function dsMetricBar(label, pct, unit, sub) {{
1264 const color = pct>80?'var(--hart-error)':pct>60?'var(--hart-caution)':'var(--hart-active)';
1265 const colorClass = pct>80?'ds-progress-error':pct>60?'ds-progress-warning':'ds-progress-active';
1266 return '<div style="margin-bottom:var(--ds-space-2)">' +
1267 '<div class="ds-flex ds-flex-between" style="margin-bottom:var(--ds-space-1)">'+
1268 '<span class="ds-body-md">'+label+'</span>'+
1269 '<span class="ds-label-lg" style="font-weight:600">'+pct+unit+'</span></div>'+
1270 '<div class="ds-progress"><div class="ds-progress-fill '+colorClass+'" style="width:'+pct+'%"></div></div>'+
1271 (sub?'<div class="ds-label-sm ds-text-muted" style="margin-top:var(--ds-space-1)">'+sub+'</div>':'')+
1272 '</div>';
1273}}
1275// ── Card Component ──
1276function dsCard(content, opts) {{
1277 opts = opts || {{}};
1278 const cls = 'ds-card'+(opts.elevated?' ds-card-elevated':'')+(opts.interactive?' ds-card-interactive':'');
1279 const onclick = opts.onclick || '';
1280 return '<div class="'+cls+'"'+(onclick?' onclick="dsRipple(event);'+onclick.replace(/"/g,'"')+'"':'')+'>'+content+'</div>';
1281}}
1283// ── Modal System ──
1284let _dsModalOverlay = null;
1285function dsModal(opts) {{
1286 opts = opts || {{}};
1287 // Remove existing modal
1288 if(_dsModalOverlay) {{ _dsModalOverlay.remove(); _dsModalOverlay = null; }}
1290 const overlay = document.createElement('div');
1291 overlay.className = 'ds-modal-overlay';
1292 overlay.innerHTML = '<div class="ds-modal">'+
1293 '<div class="ds-modal-title">'+(opts.title||'')+'</div>'+
1294 '<div class="ds-modal-body" id="ds-modal-body">'+(opts.body||'')+'</div>'+
1295 '<div class="ds-modal-actions" id="ds-modal-actions"></div></div>';
1297 document.body.appendChild(overlay);
1298 _dsModalOverlay = overlay;
1300 // Close on overlay click (not modal body)
1301 overlay.addEventListener('click', function(e){{
1302 if(e.target === overlay) dsModalClose();
1303 }});
1305 // Close on ESC
1306 const escHandler = function(e) {{
1307 if(e.key === 'Escape') {{ dsModalClose(); document.removeEventListener('keydown', escHandler); }}
1308 }};
1309 document.addEventListener('keydown', escHandler);
1311 // Add action buttons
1312 const actions = document.getElementById('ds-modal-actions');
1313 if(opts.actions) {{
1314 opts.actions.forEach(function(a) {{
1315 const btn = document.createElement('button');
1316 btn.className = 'ds-btn ds-btn-'+(a.variant||'text');
1317 btn.textContent = a.label;
1318 btn.onclick = function(e){{ dsRipple(e); if(a.action) a.action(); }};
1319 actions.appendChild(btn);
1320 }});
1321 }}
1323 // Trigger open animation (next frame)
1324 requestAnimationFrame(function(){{
1325 requestAnimationFrame(function(){{ overlay.classList.add('ds-open'); }});
1326 }});
1328 // Focus trap: focus first input or first button
1329 setTimeout(function(){{
1330 const target = overlay.querySelector('input,select,textarea') || overlay.querySelector('.ds-btn');
1331 if(target) target.focus();
1332 }}, 100);
1334 return overlay;
1335}}
1337function dsModalClose() {{
1338 if(!_dsModalOverlay) return;
1339 _dsModalOverlay.classList.remove('ds-open');
1340 const el = _dsModalOverlay;
1341 setTimeout(function(){{ el.remove(); }}, 250);
1342 _dsModalOverlay = null;
1343}}
1345// ── Prompt Modal (replaces window.prompt) ──
1346function dsPrompt(title, message, opts) {{
1347 opts = opts || {{}};
1348 const inputType = opts.type || 'text';
1349 const placeholder = opts.placeholder || '';
1350 const defaultValue = opts.defaultValue || '';
1352 return new Promise(function(resolve) {{
1353 const modal = dsModal({{
1354 title: title,
1355 body: '<div class="ds-body-md ds-text-muted" style="margin-bottom:var(--ds-space-4)">'+(message||'')+'</div>'+
1356 '<input class="ds-input" type="'+inputType+'" id="ds-prompt-input" placeholder="'+placeholder+'" value="'+defaultValue+'"'+
1357 ' onkeydown="if(event.key===\\'Enter\\')document.getElementById(\\'ds-prompt-ok\\').click()">',
1358 actions: [
1359 {{ label: 'Cancel', variant: 'text', action: function(){{ dsModalClose(); resolve(null); }} }},
1360 {{ label: opts.okLabel||'OK', variant: 'primary', action: function(){{
1361 const val = document.getElementById('ds-prompt-input').value;
1362 dsModalClose(); resolve(val);
1363 }} }}
1364 ]
1365 }});
1366 // Add id for enter-key handling
1367 setTimeout(function(){{
1368 const btns = modal.querySelectorAll('.ds-btn-primary');
1369 if(btns.length) btns[btns.length-1].id = 'ds-prompt-ok';
1370 }}, 50);
1371 }});
1372}}
1374// ── Confirm Modal (replaces window.confirm) ──
1375function dsConfirm(title, message, opts) {{
1376 opts = opts || {{}};
1377 return new Promise(function(resolve) {{
1378 dsModal({{
1379 title: title,
1380 body: message,
1381 actions: [
1382 {{ label: opts.cancelLabel||'Cancel', variant: 'text', action: function(){{ dsModalClose(); resolve(false); }} }},
1383 {{ label: opts.okLabel||'Confirm', variant: opts.danger?'danger':'primary',
1384 action: function(){{ dsModalClose(); resolve(true); }} }}
1385 ]
1386 }});
1387 }});
1388}}
1390// ── Alert Modal (replaces window.alert) ──
1391function dsAlert(title, message, severity) {{
1392 const icons = {{info:'info',success:'check_circle',warning:'warning',error:'error'}};
1393 const colors = {{info:'var(--hart-accent)',success:'var(--hart-active)',warning:'var(--hart-caution)',error:'var(--hart-error)'}};
1394 const icon = icons[severity||'info']||'info';
1395 const color = colors[severity||'info']||colors.info;
1396 return new Promise(function(resolve) {{
1397 dsModal({{
1398 title: '<span class="mi material-icons-round" style="font-size:24px;color:'+color+';vertical-align:middle;margin-right:8px">'+icon+'</span>'+title,
1399 body: message,
1400 actions: [{{ label: 'OK', variant: 'primary', action: function(){{ dsModalClose(); resolve(); }} }}]
1401 }});
1402 }});
1403}}
1405// ═══ Toast Notifications (upgraded) ═══
1406function showToast(title, message, severity) {{
1407 severity = severity || 'info';
1408 const container = document.getElementById('toast-container');
1409 if(!container) return;
1410 const icons = {{info:'info',warning:'warning',error:'error',success:'check_circle'}};
1411 const colors = {{info:'var(--hart-accent)',warning:'var(--hart-caution)',error:'var(--hart-error)',success:'var(--hart-active)'}};
1412 const icon = icons[severity]||icons.info;
1413 const color = colors[severity]||colors.info;
1414 const toast = document.createElement('div');
1415 toast.className = PERF.potato ? 'toast glass' : 'ds-toast';
1416 if(PERF.potato) {{
1417 toast.style.borderLeft = '3px solid '+color;
1418 toast.innerHTML = '<div style="font-weight:600;margin-bottom:2px;color:'+color+'">'+title+'</div>'+
1419 '<div style="color:var(--hart-text)">'+message+'</div>';
1420 }} else {{
1421 toast.innerHTML = '<span class="mi material-icons-round ds-toast-icon" style="color:'+color+'">'+icon+'</span>'+
1422 '<div class="ds-toast-content"><div class="ds-toast-title">'+title+'</div>'+
1423 '<div class="ds-toast-message">'+message+'</div></div>'+
1424 '<div class="ds-toast-progress" style="background:'+color+'"></div>';
1425 }}
1426 toast.onclick = function(){{
1427 if(!PERF.potato) toast.classList.add('ds-toast-exit');
1428 setTimeout(function(){{ toast.remove(); }}, PERF.potato?0:200);
1429 }};
1430 container.appendChild(toast);
1431 setTimeout(function(){{
1432 if(toast.parentNode) {{
1433 if(!PERF.potato) {{ toast.classList.add('ds-toast-exit'); setTimeout(function(){{ toast.remove(); }},200); }}
1434 else toast.remove();
1435 }}
1436 }}, 5000);
1437}}
1439// ═══ Taskbar ═══
1440function updateTaskbar() {{
1441 const bar = document.getElementById('taskbar');
1442 if(!bar) return;
1443 bar.innerHTML = Object.entries(panels).map(function([id,p]) {{
1444 const info = MANIFEST[id] || SYSTEM_PANELS[id] || {{}};
1445 const active = id===focusedPanel ? 'active' : '';
1446 const icon = info.icon || 'web_asset';
1447 const title = info.title || id;
1448 return '<div class="taskbar-chip glass '+active+'" onclick="bringToFront(\''+id+'\')" title="'+title+'">' +
1449 '<span class="mi material-icons-round">'+icon+'</span>' +
1450 '<span class="chip-label">'+title+'</span></div>';
1451 }}).join('');
1452}}
1454// ═══ Panel Snap ═══
1455function snapPanel(id, side) {{
1456 const p = panels[id];
1457 if(!p) return;
1458 const topH = 40;
1459 const taskH = 44;
1460 if(!PERF.potato) p.el.style.transition = 'all 0.2s ease-out';
1461 if(side==='left') {{
1462 p.el.style.left='0';p.el.style.top=topH+'px';
1463 p.el.style.width='50vw';p.el.style.height='calc(100vh - '+(topH+taskH)+'px)';
1464 }} else {{
1465 p.el.style.left='50vw';p.el.style.top=topH+'px';
1466 p.el.style.width='50vw';p.el.style.height='calc(100vh - '+(topH+taskH)+'px)';
1467 }}
1468 p.el.style.borderRadius='0';
1469 p.max=false;
1470 setTimeout(function(){{p.el.style.transition='';}},250);
1471}}
1473// ═══ Clock ═══
1474function tickClock() {{
1475 const now = new Date();
1476 const t = now.toLocaleTimeString([], {{hour:'2-digit',minute:'2-digit'}});
1477 const d = now.toLocaleDateString([], {{weekday:'long',month:'long',day:'numeric'}});
1478 const el = document.getElementById('clock');
1479 if(el) el.textContent = t;
1480 const lc = document.getElementById('lock-clock');
1481 if(lc) lc.textContent = t;
1482 const ld = document.getElementById('lock-date');
1483 if(ld) ld.textContent = d;
1484}}
1485setInterval(tickClock, PERF.clockMs);
1486tickClock();
1488// ═══ Agent Status (top bar) ═══
1489function refreshAgentStatus() {{
1490 fetch(BACKEND+'/api/social/dashboard/agents',{{signal:AbortSignal.timeout(3000)}})
1491 .then(r=>r.json()).then(data=>{{
1492 const bar = document.getElementById('agent-status');
1493 const agents = (data.agents||[]).filter(a=>a.status==='running');
1494 if(agents.length===0){{bar.innerHTML='<span style="opacity:0.5">No agents running</span>';return;}}
1495 bar.innerHTML = agents.slice(0,4).map(a=>
1496 '<span class="agent-chip"><span class="dot"></span>'+
1497 (a.name||a.goal_type||'agent').substring(0,16)+'</span>'
1498 ).join('');
1499 }}).catch(()=>{{}});
1500}}
1501setInterval(refreshAgentStatus, PERF.agentStatusMs);
1502refreshAgentStatus();
1504// ═══ Start Menu ═══
1505function buildStartMenu() {{
1506 const scroll = document.getElementById('start-scroll');
1507 let html = '';
1508 GROUPS.forEach(group => {{
1509 const items = Object.entries(MANIFEST).filter(([_,v])=>v.group===group);
1510 if(!items.length) return;
1511 html += '<div class="start-group"><div class="start-group-label">'+group+'</div><div class="start-grid">';
1512 items.forEach(([id,p])=>{{
1513 html += '<div class="start-item" data-id="'+id+'" data-title="'+p.title+'" onclick="openPanel(\''+id+'\')">';
1514 html += '<span class="mi material-icons-round">'+(p.icon||'apps')+'</span>';
1515 html += '<span class="label">'+p.title+'</span></div>';
1516 }});
1517 html += '</div></div>';
1518 }});
1519 // System panels
1520 const sysItems = Object.entries(SYSTEM_PANELS);
1521 if(sysItems.length) {{
1522 html += '<div class="start-group"><div class="start-group-label">System</div><div class="start-grid">';
1523 sysItems.forEach(([id,p])=>{{
1524 html += '<div class="start-item" data-id="'+id+'" data-title="'+p.title+'" onclick="openPanel(\''+id+'\')">';
1525 html += '<span class="mi material-icons-round">'+(p.icon||'settings')+'</span>';
1526 html += '<span class="label">'+p.title+'</span></div>';
1527 }});
1528 html += '</div></div>';
1529 }}
1530 scroll.innerHTML = html;
1531}}
1532buildStartMenu();
1534function toggleStartMenu() {{
1535 const m = document.getElementById('start-menu');
1536 startOpen = !startOpen;
1537 m.classList.toggle('open', startOpen);
1538 if(startOpen) document.getElementById('start-search').focus();
1539}}
1541function filterStart(q) {{
1542 const items = document.querySelectorAll('.start-item');
1543 const lq = q.toLowerCase();
1544 items.forEach(el => {{
1545 const title = (el.dataset.title||'').toLowerCase();
1546 el.style.display = title.includes(lq) ? '' : 'none';
1547 }});
1548}}
1550// ═══ Panel Manager ═══
1551function openPanel(id, opts) {{
1552 opts = opts || {{}};
1553 // If panel already open, bring to front
1554 if(panels[id]) {{
1555 bringToFront(id);
1556 return;
1557 }}
1558 // Potato mode: limit open panels to save memory
1559 if(PERF.potato && PERF.maxPanels > 0) {{
1560 const openCount = Object.keys(panels).length;
1561 if(openCount >= PERF.maxPanels) {{
1562 // Close oldest non-focused panel
1563 const oldest = Object.keys(panels).find(k=>k!==focusedPanel);
1564 if(oldest) closePanel(oldest);
1565 }}
1566 }}
1567 const def = MANIFEST[id] || SYSTEM_PANELS[id] || {{}};
1568 const sz = def.default_size || [700,500];
1569 const isSystem = !!SYSTEM_PANELS[id];
1571 // Position: cascade from center
1572 const cx = window.innerWidth/2, cy = window.innerHeight/2;
1573 const count = Object.keys(panels).length;
1574 const x = Math.max(20, cx - sz[0]/2 + count*30);
1575 const y = Math.max(50, cy - sz[1]/2 + count*30);
1577 const panel = document.createElement('div');
1578 panel.className = 'panel glass';
1579 panel.id = 'panel-'+id;
1580 panel.style.cssText = 'left:'+x+'px;top:'+y+'px;width:'+sz[0]+'px;height:'+sz[1]+'px;z-index:'+(++panelZ);
1582 const title = opts.title || def.title || id;
1583 const icon = def.icon || 'web_asset';
1585 panel.innerHTML = '<div class="panel-titlebar" onmousedown="startDrag(event,\''+id+'\')"'+
1586 ' ondblclick="toggleMax(\''+id+'\')">'+
1587 '<span class="mi material-icons-round">'+icon+'</span>'+
1588 '<span class="title">'+title+'</span>'+
1589 '<div class="ctrl">'+
1590 '<span title="Minimize" onclick="minimizePanel(\''+id+'\')"><span class="mi material-icons-round" style="font-size:14px">minimize</span></span>'+
1591 '<span title="Maximize" onclick="toggleMax(\''+id+'\')"><span class="mi material-icons-round" style="font-size:14px">crop_square</span></span>'+
1592 '<span class="close" title="Close" onclick="closePanel(\''+id+'\')"><span class="mi material-icons-round" style="font-size:14px">close</span></span>'+
1593 '</div></div>'+
1594 '<div class="panel-body" id="panel-body-'+id+'"></div>'+
1595 '<div class="panel-resize" onmousedown="startResize(event,\''+id+'\')"></div>';
1597 document.getElementById('panels').appendChild(panel);
1598 panel.addEventListener('mousedown', ()=>bringToFront(id));
1600 // Load content (potato: defer iframes until visible)
1601 const body = document.getElementById('panel-body-'+id);
1602 if(isSystem) {{
1603 loadSystemPanel(id, body);
1604 }} else if(def.route) {{
1605 if(PERF.lazyIframes) {{
1606 // Potato: placeholder until focused, then load iframe
1607 body.innerHTML = '<div class="native-content" style="display:flex;align-items:center;justify-content:center;height:100%"><span class="mi material-icons-round" style="font-size:48px;color:var(--hart-muted);cursor:pointer" onclick="loadIframe(\''+id+'\',\''+def.route+'\')">touch_app</span></div>';
1608 body.dataset.route = def.route;
1609 body.dataset.loaded = '0';
1610 }} else {{
1611 body.innerHTML = '<iframe src="'+NUNBA_BASE+def.route+'" loading="lazy"></iframe>';
1612 }}
1613 }} else {{
1614 body.innerHTML = '<div class="native-content">Panel: '+id+'</div>';
1615 }}
1617 panels[id] = {{el:panel, x, y, w:sz[0], h:sz[1], max:false, min:false}};
1618 bringToFront(id);
1619 updateTaskbar();
1620 if(startOpen) toggleStartMenu();
1621}}
1623function closePanel(id) {{
1624 const p = panels[id];
1625 if(!p) return;
1626 if(!PERF.potato) {{
1627 p.el.classList.add('closing');
1628 setTimeout(function(){{ p.el.remove(); delete panels[id]; updateTaskbar(); }}, 200);
1629 }} else {{
1630 p.el.remove(); delete panels[id]; updateTaskbar();
1631 }}
1632 if(focusedPanel===id) focusedPanel=null;
1633}}
1635function minimizePanel(id) {{
1636 const p = panels[id];
1637 if(!p) return;
1638 if(!PERF.potato) {{
1639 p.el.classList.add('minimizing');
1640 setTimeout(function(){{ p.el.style.display='none'; p.el.classList.remove('minimizing'); }}, 150);
1641 }} else {{
1642 p.el.style.display = 'none';
1643 }}
1644 p.min = true;
1645 updateTaskbar();
1646 // Potato: destroy iframe to free memory, will reload on restore
1647 if(PERF.destroyMinimized) {{
1648 const body = document.getElementById('panel-body-'+id);
1649 const iframe = body && body.querySelector('iframe');
1650 if(iframe) {{
1651 body.dataset.route = body.dataset.route || iframe.src.replace(NUNBA_BASE,'');
1652 iframe.remove();
1653 body.dataset.loaded = '0';
1654 }}
1655 }}
1656}}
1658// Lazy iframe loader (potato mode)
1659function loadIframe(id, route) {{
1660 const body = document.getElementById('panel-body-'+id);
1661 if(body && body.dataset.loaded !== '1') {{
1662 body.innerHTML = '<iframe src="'+NUNBA_BASE+route+'" loading="lazy"></iframe>';
1663 body.dataset.loaded = '1';
1664 }}
1665}}
1667function toggleMax(id) {{
1668 const p = panels[id];
1669 if(!p) return;
1670 if(p.max) {{
1671 p.el.style.left = p.x+'px'; p.el.style.top = p.y+'px';
1672 p.el.style.width = p.w+'px'; p.el.style.height = p.h+'px';
1673 p.el.style.borderRadius = '';
1674 p.max = false;
1675 }} else {{
1676 p.el.style.left = '0'; p.el.style.top = '0';
1677 p.el.style.width = '100vw'; p.el.style.height = 'calc(100vh - var(--hart-topbar-height) - 44px)';
1678 p.el.style.borderRadius = '0';
1679 p.max = true;
1680 }}
1681}}
1683function bringToFront(id) {{
1684 const p = panels[id];
1685 if(!p) return;
1686 if(p.min) {{
1687 p.el.style.display=''; p.min=false;
1688 // Potato: reload iframe if it was destroyed on minimize
1689 if(PERF.destroyMinimized) {{
1690 const body = document.getElementById('panel-body-'+id);
1691 if(body && body.dataset.route && body.dataset.loaded === '0') {{
1692 loadIframe(id, body.dataset.route);
1693 }}
1694 }}
1695 }}
1696 p.el.style.zIndex = ++panelZ;
1697 Object.keys(panels).forEach(k=>panels[k].el.classList.toggle('focused',k===id));
1698 focusedPanel = id;
1699 updateTaskbar();
1700}}
1702// ═══ Drag & Resize ═══
1703let dragState = null;
1704function startDrag(e, id) {{
1705 if(e.button!==0) return;
1706 const p = panels[id];
1707 if(!p||p.max) return;
1708 dragState = {{id, mode:'move', sx:e.clientX, sy:e.clientY, ox:p.el.offsetLeft, oy:p.el.offsetTop}};
1709 e.preventDefault();
1710}}
1711function startResize(e, id) {{
1712 if(e.button!==0) return;
1713 const p = panels[id];
1714 if(!p) return;
1715 dragState = {{id, mode:'resize', sx:e.clientX, sy:e.clientY, ow:p.el.offsetWidth, oh:p.el.offsetHeight}};
1716 e.preventDefault();
1717}}
1718document.addEventListener('mousemove', e=>{{
1719 if(!dragState) return;
1720 const dx = e.clientX - dragState.sx, dy = e.clientY - dragState.sy;
1721 const p = panels[dragState.id];
1722 if(!p) return;
1723 if(dragState.mode==='move') {{
1724 const nx = dragState.ox+dx, ny = dragState.oy+dy;
1725 p.el.style.left = nx+'px'; p.el.style.top = ny+'px';
1726 p.x = nx; p.y = ny;
1727 }} else {{
1728 const nw = Math.max(320, dragState.ow+dx), nh = Math.max(240, dragState.oh+dy);
1729 p.el.style.width = nw+'px'; p.el.style.height = nh+'px';
1730 p.w = nw; p.h = nh;
1731 }}
1732}});
1733document.addEventListener('mouseup', ()=>{{ dragState=null; }});
1735// ═══ System Panels (design system) ═══
1736function loadSystemPanel(id, body) {{
1737 const apis = (SYSTEM_PANELS[id]||{{}}).apis || [];
1738 // Show skeleton loader while fetching
1739 body.innerHTML = '<div class="native-content" id="sys-'+id+'">'+dsSkeleton('panel',3)+'</div>';
1740 const container = document.getElementById('sys-'+id);
1742 if(id==='hw_monitor') loadHardwareMonitor(container, apis);
1743 else if(id==='security') loadSecurityCenter(container, apis);
1744 else if(id==='network') loadNetworkPanel(container, apis);
1745 else if(id==='event_log') loadEventLog(container, apis);
1746 else if(id==='drivers') loadDriversPanel(container);
1747 else if(id==='audio') loadAudioPanel(container);
1748 else if(id==='bluetooth') loadBluetoothPanel(container);
1749 else if(id==='power') loadPowerPanel(container);
1750 else if(id==='display') loadDisplayPanel(container);
1751 else if(id==='remote_desktop') loadRemoteDesktopPanel(container, apis);
1752 else if(id==='hart_identity') loadHartIdentityPanel(container, apis);
1753 else if(id==='self_build') loadSelfBuildPanel(container, apis);
1754 else if(id==='task_manager') loadTaskManagerPanel(container);
1755 else if(id==='storage_manager') loadStoragePanel(container);
1756 else if(id==='startup_apps') loadStartupAppsPanel(container);
1757 else if(id==='bluetooth_manager') loadBluetoothManagerPanel(container);
1758 else if(id==='print_manager') loadPrintManagerPanel(container);
1759 else if(id==='media_library') loadMediaLibraryPanel(container);
1760 else if(id==='file_manager') loadFileManagerPanel(container);
1761 else if(id==='terminal') loadTerminalPanel(container);
1762 else if(id==='user_accounts') loadUserAccountsPanel(container);
1763 else if(id==='notification_center') loadNotificationCenterPanel(container);
1764 else if(id==='updates') loadUpdatesPanel(container);
1765 else if(id==='backup_restore') loadBackupRestorePanel(container);
1766 else if(id==='devices') loadDevicesPanel(container);
1767 else if(id==='i18n') loadI18nPanel(container);
1768 else if(id==='accessibility') loadAccessibilityPanel(container);
1769 else if(id==='screenshot') loadScreenshotPanel(container);
1770 else if(id==='firewall') loadFirewallPanel(container);
1771 else if(id==='default_apps') loadDefaultAppsPanel(container);
1772 else if(id==='font_manager') loadFontManagerPanel(container);
1773 else if(id==='sound_manager') loadSoundManagerPanel(container);
1774 else if(id==='clipboard_manager') loadClipboardPanel(container);
1775 else if(id==='datetime') loadDateTimePanel(container);
1776 else if(id==='wallpaper_manager') loadWallpaperPanel(container);
1777 else if(id==='input_methods') loadInputMethodsPanel(container);
1778 else if(id==='nightlight') loadNightLightPanel(container);
1779 else if(id==='workspaces') loadWorkspacesPanel(container);
1780 else if(id==='calculator') loadCalculatorPanel(container);
1781 else if(id==='image_viewer') loadImageViewerPanel(container);
1782 else if(id==='notes_app') loadNotesAppPanel(container);
1783 else if(id==='app_store') loadAppStorePanel(container);
1784 else if(id==='app_permissions') loadAppPermissionsPanel(container);
1785 else if(id==='battery_monitor') loadBatteryMonitorPanel(container);
1786 else if(id==='wifi_manager') loadWiFiManagerPanel(container);
1787 else if(id==='vpn_manager') loadVPNManagerPanel(container);
1788 else if(id==='trash_bin') loadTrashBinPanel(container);
1789 else if(id==='webcam_viewer') loadWebcamViewerPanel(container);
1790 else if(id==='scanner') loadScannerPanel(container);
1791 else if(id==='weather_widget') loadWeatherPanel(container);
1792 else if(id==='keyboard_shortcuts') loadKeyboardShortcutsPanel(container);
1793 else container.innerHTML = '<div class="ds-body-md ds-text-muted">Panel: '+id+'</div>';
1794}}
1796// Backward compat wrappers (used in old code references)
1797function metricBar(l,p,u,s) {{ return dsMetricBar(l,p,u,s); }}
1798function statusRow(i,l,v,c) {{ return dsStatusRow(i,l,v,c); }}
1800function loadHardwareMonitor(el, apis) {{
1801 Promise.all(apis.map(u=>fetch(BACKEND+u,{{signal:AbortSignal.timeout(3000)}}).then(r=>r.json()).catch(()=>({{}}))))
1802 .then(([sys,caps])=>{{
1803 const cpu=sys.cpu_percent||0, ram_used=sys.ram_used_gb||0, ram_total=sys.ram_total_gb||0;
1804 const disk_used=sys.disk_used_gb||0, disk_total=sys.disk_total_gb||0;
1805 const tier=caps.tier_name||sys.tier||'unknown', uptime=sys.uptime||'';
1806 el.innerHTML = '<div class="ds-panel-grid ds-fade-in">'+
1807 '<div class="ds-panel-header">'+
1808 '<span class="ds-panel-title">Hardware</span>'+
1809 '<span class="ds-chip"><span class="ds-chip-dot" style="background:var(--hart-accent)"></span>'+tier+'</span>'+
1810 '</div>'+
1811 dsMetricBar('CPU', cpu, '%')+
1812 dsMetricBar('RAM', ram_total>0?Math.round(ram_used/ram_total*100):0, '%', ram_used.toFixed(1)+' / '+ram_total.toFixed(1)+' GB')+
1813 dsMetricBar('Disk', disk_total>0?Math.round(disk_used/disk_total*100):0, '%', disk_used.toFixed(0)+' / '+disk_total.toFixed(0)+' GB')+
1814 '<div class="ds-label-sm ds-text-muted">Uptime: '+uptime+'</div>'+
1815 '</div>';
1816 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Hardware info unavailable</div>'; }});
1817}}
1819function loadSecurityCenter(el, apis) {{
1820 Promise.all(apis.map(u=>fetch(BACKEND+u,{{signal:AbortSignal.timeout(3000)}}).then(r=>r.json()).catch(()=>({{}}))))
1821 .then(([health,guardrail])=>{{
1822 const ghash = guardrail.guardrail_hash||'unknown';
1823 const wm = health.world_model||{{}};
1824 el.innerHTML = '<div class="ds-panel-grid ds-fade-in">'+
1825 '<div class="ds-panel-title">Security</div>'+
1826 '<div class="ds-stagger">'+
1827 dsStatusRow('shield', 'Guardrail Hash', ghash.substring(0,16)+'...', 'var(--hart-active)', {{sublabel:'Structural integrity verified'}})+
1828 dsStatusRow('verified_user', 'Integrity', health.status==='ok'?'Verified':'Check Required',
1829 health.status==='ok'?'var(--hart-active)':'var(--hart-caution)')+
1830 dsStatusRow('psychology', 'World Model', wm.status||'disconnected',
1831 wm.status==='healthy'?'var(--hart-active)':'var(--hart-muted)')+
1832 '</div></div>';
1833 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Security info unavailable</div>'; }});
1834}}
1836function wifiConnect(ssid) {{
1837 dsPrompt('Connect to WiFi', 'Enter password for <strong>'+ssid+'</strong><br><span class="ds-label-sm ds-text-muted">Leave empty for open networks</span>', {{
1838 type:'password', placeholder:'Password', okLabel:'Connect'
1839 }}).then(function(pwd){{
1840 if(pwd===null) return;
1841 const body = {{ssid: ssid}};
1842 if(pwd) body.password = pwd;
1843 showToast('WiFi', 'Connecting to '+ssid+'...', 'info');
1844 fetch(SHELL+'/api/shell/network/wifi/connect', {{
1845 method:'POST', headers:{{'Content-Type':'application/json'}},
1846 body:JSON.stringify(body), signal:AbortSignal.timeout(35000)
1847 }}).then(r=>r.json()).then(d=>{{
1848 if(d.success) {{ showToast('WiFi', 'Connected to '+ssid, 'success'); loadNetworkPanel(document.getElementById('sys-network'),
1849 (SYSTEM_PANELS['network']||{{}}).apis||[]); }}
1850 else dsAlert('Connection Failed', d.error||'Unknown error', 'error');
1851 }}).catch(e=>dsAlert('Connection Error', e.message, 'error'));
1852 }});
1853}}
1854function wifiDisconnect() {{
1855 dsConfirm('Disconnect WiFi', 'Are you sure you want to disconnect from WiFi?', {{okLabel:'Disconnect', danger:true}}).then(function(ok){{
1856 if(!ok) return;
1857 fetch(SHELL+'/api/shell/network/wifi/disconnect', {{
1858 method:'POST', headers:{{'Content-Type':'application/json'}},
1859 body:'{{}}', signal:AbortSignal.timeout(15000)
1860 }}).then(r=>r.json()).then(d=>{{
1861 if(d.success) {{ showToast('WiFi', 'Disconnected', 'info'); loadNetworkPanel(document.getElementById('sys-network'),
1862 (SYSTEM_PANELS['network']||{{}}).apis||[]); }}
1863 else dsAlert('Error', d.error||'Disconnect failed', 'error');
1864 }}).catch(e=>dsAlert('Error', e.message, 'error'));
1865 }});
1866}}
1868function loadNetworkPanel(el, apis) {{
1869 Promise.all([
1870 ...apis.map(u=>fetch(BACKEND+u,{{signal:AbortSignal.timeout(3000)}}).then(r=>r.json()).catch(()=>({{}}))),
1871 fetch(SHELL+'/api/shell/network/wifi',{{signal:AbortSignal.timeout(3000)}}).then(r=>r.json()).catch(()=>({{}})),
1872 fetch(SHELL+'/api/shell/network/status',{{signal:AbortSignal.timeout(3000)}}).then(r=>r.json()).catch(()=>({{}}))
1873 ]).then(results=>{{
1874 const topo = results[0]||{{}};
1875 const wifi = results[results.length-2]||{{}};
1876 const netStatus = results[results.length-1]||{{}};
1877 const nodes = topo.nodes||[];
1878 const connected = wifi.connected||{{}};
1879 const networks = wifi.networks||[];
1880 const gateway = netStatus.gateway||'';
1881 let wifiHtml = '';
1882 if(connected.ssid) {{
1883 wifiHtml = dsCard(
1884 '<div class="ds-flex ds-flex-center ds-flex-col ds-gap-2">'+
1885 '<span class="mi material-icons-round ds-text-active" style="font-size:28px">wifi</span>'+
1886 '<div class="ds-title-sm ds-text-active">'+connected.ssid+'</div>'+
1887 '<div class="ds-label-sm ds-text-muted">'+(connected.ip||'')+(gateway?' · GW '+gateway:'')+'</div>'+
1888 dsBtn('Disconnect', {{variant:'secondary', cls:'ds-btn-sm', onclick:"wifiDisconnect()"}})+
1889 '</div>', {{elevated:true}});
1890 }}
1891 let html = '<div class="ds-panel-grid ds-fade-in">';
1892 html += '<div class="ds-panel-title">Network</div>';
1893 html += '<div class="ds-flex ds-gap-3 ds-flex-wrap">';
1894 html += dsCard('<div class="ds-metric"><div class="ds-metric-value ds-text-accent">'+nodes.length+'</div><div class="ds-metric-label">Hive Peers</div></div>', {{elevated:true}});
1895 html += wifiHtml;
1896 html += '</div>';
1897 if(nodes.length>0) {{
1898 html += '<div class="ds-section-label">Connected Peers</div><div class="ds-stagger">';
1899 html += nodes.slice(0,6).map(n=>
1900 dsStatusRow('dns', (n.node_id||'').substring(0,12)+'...', n.status||'active',
1901 'var(--hart-active)', {{sublabel:n.ip||''}})
1902 ).join('');
1903 html += '</div>';
1904 }}
1905 if(networks.length>0) {{
1906 const available = networks.filter(n=>!n.active);
1907 if(available.length>0) {{
1908 html += '<div class="ds-section-label">Available WiFi Networks</div><div class="ds-stagger">';
1909 html += available.slice(0,6).map(n=>
1910 '<div class="ds-list-item ds-list-item-interactive" onclick="wifiConnect(\\''+n.ssid.replace(/'/g,"\\\\'")+'\\')">' +
1911 '<span class="mi material-icons-round ds-list-item-icon ds-text-accent">wifi</span>' +
1912 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+n.ssid+'</div>'+
1913 '<div class="ds-list-item-secondary">'+n.security+'</div></div>' +
1914 '<span class="ds-list-item-trailing ds-text-muted">'+n.signal+'%</span></div>'
1915 ).join('');
1916 html += '</div>';
1917 }}
1918 }}
1919 html += '</div>';
1920 el.innerHTML = html;
1921 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Network info unavailable</div>'; }});
1922}}
1924function loadHartIdentityPanel(el, apis) {{
1925 const profileUrl = apis[0] || '/api/onboarding/profile';
1926 const statusUrl = apis[1] || '/api/onboarding/status';
1927 fetch(SHELL+statusUrl,{{signal:AbortSignal.timeout(3000)}}).then(r=>r.json()).then(st=>{{
1928 if(!st.onboarded) {{
1929 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">My HART</div>'+
1930 '<div class="ds-flex ds-flex-center ds-flex-col ds-gap-3" style="padding:40px 0">'+
1931 '<span class="mi material-icons-round ds-text-muted" style="font-size:48px">person_outline</span>'+
1932 '<div class="ds-body-md ds-text-muted">You haven\\'t lit your HART yet.</div>'+
1933 dsBtn('Light Your HART',{{variant:'primary', onclick:"fetch(SHELL+'/api/onboarding/start',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{user_id:'1'}})}}).then(()=>showToast('Onboarding','Opening onboarding...','info')).catch(()=>{{}})"}})+'</div></div>';
1934 return;
1935 }}
1936 fetch(SHELL+profileUrl,{{signal:AbortSignal.timeout(3000)}}).then(r=>r.json()).then(p=>{{
1937 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">My HART</div>'+
1938 '<div class="ds-flex ds-flex-center ds-flex-col ds-gap-3" style="padding:24px 0">'+
1939 '<span class="mi material-icons-round ds-text-accent" style="font-size:56px">badge</span>'+
1940 '<div class="ds-display-sm ds-text-accent">'+(p.hart_name||p.name||'Unknown')+'</div>'+
1941 (p.hart_tag?'<div class="ds-title-sm ds-text-muted">'+p.hart_tag+'</div>':'')+
1942 '</div><div class="ds-stagger">'+
1943 (p.element?dsStatusRow('flare','Element',p.element,'var(--hart-accent)'):'')+
1944 (p.spirit?dsStatusRow('pets','Spirit',p.spirit,'var(--hart-active)'):'')+
1945 (p.passion?dsStatusRow('favorite','Passion',p.passion,'var(--hart-accent)'):'')+
1946 (p.escape?dsStatusRow('landscape','Escape',p.escape,'var(--hart-active)'):'')+
1947 (p.locale?dsStatusRow('language','Language',p.locale,'var(--hart-muted)'):'')+
1948 '</div></div>';
1949 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px">Could not load identity</div>'; }});
1950 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px">Identity service unavailable</div>'; }});
1951}}
1953function selfBuildInstall() {{
1954 dsPrompt('Install Package','Enter NixOS package name (e.g. <code>htop</code>, <code>nodejs_20</code>)',{{
1955 placeholder:'Package name', okLabel:'Stage Install'
1956 }}).then(function(pkg){{
1957 if(!pkg) return;
1958 showToast('Self-Build','Staging '+pkg+'...','info');
1959 fetch(SHELL+'/api/system/self-build/install',{{
1960 method:'POST', headers:{{'Content-Type':'application/json'}},
1961 body:JSON.stringify({{package:pkg}}), signal:AbortSignal.timeout(10000)
1962 }}).then(r=>r.json()).then(d=>{{
1963 if(d.success) {{ showToast('Self-Build','Staged: '+pkg,'success'); loadSelfBuildPanel(document.getElementById('sys-self_build'),
1964 (SYSTEM_PANELS['self_build']||{{}}).apis||[]); }}
1965 else dsAlert('Stage Failed', d.error||'Unknown error', 'error');
1966 }}).catch(e=>dsAlert('Error', e.message, 'error'));
1967 }});
1968}}
1969function selfBuildRemove(pkg) {{
1970 dsConfirm('Remove Package','Remove <strong>'+pkg+'</strong> from runtime config?',{{okLabel:'Remove',danger:true}}).then(function(ok){{
1971 if(!ok) return;
1972 fetch(SHELL+'/api/system/self-build/remove',{{
1973 method:'POST', headers:{{'Content-Type':'application/json'}},
1974 body:JSON.stringify({{package:pkg}}), signal:AbortSignal.timeout(10000)
1975 }}).then(r=>r.json()).then(d=>{{
1976 if(d.success) {{ showToast('Self-Build','Removed: '+pkg,'info'); loadSelfBuildPanel(document.getElementById('sys-self_build'),
1977 (SYSTEM_PANELS['self_build']||{{}}).apis||[]); }}
1978 else dsAlert('Remove Failed', d.error||'Unknown error', 'error');
1979 }}).catch(e=>dsAlert('Error', e.message, 'error'));
1980 }});
1981}}
1982function selfBuildTrigger(mode) {{
1983 dsConfirm('Trigger Build','Run <strong>'+mode+'</strong> build? This may take a few minutes.',{{okLabel:'Build'}}).then(function(ok){{
1984 if(!ok) return;
1985 showToast('Self-Build','Building ('+mode+')...','info');
1986 fetch(SHELL+'/api/system/self-build/trigger',{{
1987 method:'POST', headers:{{'Content-Type':'application/json'}},
1988 body:JSON.stringify({{mode:mode}}), signal:AbortSignal.timeout(600000)
1989 }}).then(r=>r.json()).then(d=>{{
1990 if(d.success) showToast('Self-Build','Build complete!','success');
1991 else dsAlert('Build Failed', d.error||d.stderr||'Unknown error', 'error');
1992 loadSelfBuildPanel(document.getElementById('sys-self_build'),
1993 (SYSTEM_PANELS['self_build']||{{}}).apis||[]);
1994 }}).catch(e=>dsAlert('Build Error', e.message, 'error'));
1995 }});
1996}}
1998function loadSelfBuildPanel(el, apis) {{
1999 const statusUrl = apis[0] || '/api/system/self-build/status';
2000 const pkgsUrl = apis[1] || '/api/system/self-build/packages';
2001 Promise.all([
2002 fetch(SHELL+statusUrl,{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}})),
2003 fetch(SHELL+pkgsUrl,{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}}))
2004 ]).then(([status,pkgData])=>{{
2005 const gen = status.generation||'?';
2006 const version = status.nixos_version||'unknown';
2007 const builds = status.recent_builds||[];
2008 const pkgs = pkgData.packages||[];
2009 let html = '<div class="ds-panel-grid ds-fade-in">';
2010 html += '<div class="ds-panel-header"><span class="ds-panel-title">Self-Build</span>'+
2011 '<span class="ds-chip"><span class="ds-chip-dot" style="background:var(--hart-active)"></span>Gen '+gen+'</span></div>';
2012 html += '<div class="ds-flex ds-gap-3 ds-flex-wrap">';
2013 html += dsCard('<div class="ds-metric"><div class="ds-metric-value ds-text-accent">'+version+'</div><div class="ds-metric-label">NixOS Version</div></div>',{{elevated:true}});
2014 html += dsCard('<div class="ds-metric"><div class="ds-metric-value ds-text-active">'+pkgs.length+'</div><div class="ds-metric-label">Runtime Packages</div></div>',{{elevated:true}});
2015 html += '</div>';
2016 html += '<div class="ds-flex ds-gap-2">'+
2017 dsBtn('Install Package',{{variant:'primary', cls:'ds-btn-sm', onclick:'selfBuildInstall()'}})+
2018 dsBtn('Dry Run',{{variant:'secondary', cls:'ds-btn-sm', onclick:"selfBuildTrigger('dry-run')"}})+
2019 dsBtn('Apply (Switch)',{{variant:'secondary', cls:'ds-btn-sm', onclick:"selfBuildTrigger('switch')"}})+
2020 '</div>';
2021 if(pkgs.length>0) {{
2022 html += '<div class="ds-section-label">Runtime Packages</div><div class="ds-stagger">';
2023 html += pkgs.map(p=>
2024 '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-accent">inventory_2</span>'+
2025 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+p+'</div></div>'+
2026 '<span class="ds-list-item-trailing" style="cursor:pointer" onclick="selfBuildRemove(\\''+p.replace(/'/g,"\\\\'")+'\\')">' +
2027 '<span class="mi material-icons-round ds-text-muted" style="font-size:18px">delete_outline</span></span></div>'
2028 ).join('');
2029 html += '</div>';
2030 }}
2031 if(builds.length>0) {{
2032 html += '<div class="ds-section-label">Recent Builds</div><div class="ds-stagger">';
2033 html += builds.slice(0,5).map(b=>
2034 dsStatusRow(b.success?'check_circle':'error', b.mode||'build',
2035 (b.timestamp||'').substring(0,19), b.success?'var(--hart-active)':'var(--hart-caution)')
2036 ).join('');
2037 html += '</div>';
2038 }}
2039 html += '</div>';
2040 el.innerHTML = html;
2041 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Self-Build unavailable</div>'; }});
2042}}
2044// ═══ Keyboard Shortcuts ═══
2045function loadKeyboardShortcutsPanel(el) {{
2046 fetch(SHELL+'/api/shell/shortcuts',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2047 const profile = data.profile||'windows';
2048 const profiles = data.available_profiles||['windows','mac'];
2049 const sc = data.shortcuts||{{}};
2050 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">Keyboard Shortcuts</span>'+
2051 '<div class="ds-flex ds-gap-2">';
2052 profiles.forEach(p=>{{
2053 html += dsBtn(p.charAt(0).toUpperCase()+p.slice(1),{{
2054 variant:p===profile?'primary':'secondary', cls:'ds-btn-sm',
2055 onclick:"fetch(SHELL+'/api/shell/shortcuts/profile',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{profile:'"+p+"'}})}}).then(()=>{{showToast('Shortcuts','Switched to "+p+"','success');loadKeyboardShortcutsPanel(document.getElementById('sys-keyboard_shortcuts'))}})"
2056 }});
2057 }});
2058 html += '</div></div>';
2059 const groups = {{
2060 'Window Management': ['close_window','minimize','maximize','snap_left','snap_right','switch_apps','switch_windows'],
2061 'Navigation': ['overview','app_grid','search','workspace_left','workspace_right','move_workspace_left','move_workspace_right'],
2062 'System': ['lock_screen','file_manager','terminal','browser','calculator','task_manager','screenshot','screenshot_window','screenshot_area'],
2063 'Editing': ['copy','paste','cut','undo','redo','select_all','save','find'],
2064 }};
2065 Object.keys(groups).forEach(group=>{{
2066 html += '<div class="ds-section-label">'+group+'</div><div class="ds-stagger">';
2067 groups[group].forEach(key=>{{
2068 if(!sc[key]) return;
2069 const label = key.replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
2070 html += '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-muted">keyboard</span>'+
2071 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+label+'</div></div>'+
2072 '<span class="ds-list-item-trailing"><code style="background:#1a1a1a;padding:2px 8px;border-radius:4px;font-size:12px;color:var(--hart-accent)">'+sc[key]+'</code></span></div>';
2073 }});
2074 html += '</div>';
2075 }});
2076 html += '</div>';
2077 el.innerHTML = html;
2078 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Keyboard shortcuts unavailable</div>'; }});
2079}}
2081// ═══ Task Manager ═══
2082function loadTaskManagerPanel(el) {{
2083 Promise.all([
2084 fetch(SHELL+'/api/shell/tasks/processes',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}})),
2085 fetch(SHELL+'/api/shell/tasks/resources',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}}))
2086 ]).then(([procData,res])=>{{
2087 const procs = procData.processes||[];
2088 const cpu = res.cpu_percent||0, mem = res.memory_percent||0;
2089 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Task Manager</div>';
2090 html += dsMetricBar('CPU', cpu, '%')+dsMetricBar('Memory', mem, '%');
2091 html += '<div class="ds-section-label">Processes ('+procs.length+')</div><div class="ds-stagger">';
2092 html += procs.slice(0,20).map(p=>
2093 '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-accent">memory</span>'+
2094 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+p.name+'</div>'+
2095 '<div class="ds-list-item-secondary">PID '+p.pid+' · CPU '+((p.cpu_percent||0).toFixed(1))+'% · Mem '+((p.memory_percent||0).toFixed(1))+'%</div></div>'+
2096 '<span class="ds-list-item-trailing" style="cursor:pointer" onclick="taskKill('+p.pid+')">'+
2097 '<span class="mi material-icons-round ds-text-muted" style="font-size:18px">close</span></span></div>'
2098 ).join('');
2099 html += '</div></div>';
2100 el.innerHTML = html;
2101 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Task info unavailable</div>'; }});
2102}}
2103function taskKill(pid) {{
2104 dsConfirm('End Process','Kill process PID '+pid+'?',{{okLabel:'Kill',danger:true}}).then(function(ok){{
2105 if(!ok) return;
2106 fetch(SHELL+'/api/shell/tasks/kill',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{pid:pid}})}}
2107 ).then(r=>r.json()).then(d=>{{
2108 if(d.success) {{ showToast('Task Manager','Process killed','info'); loadTaskManagerPanel(document.getElementById('sys-task_manager')); }}
2109 else dsAlert('Error', d.error||'Failed','error');
2110 }}).catch(e=>dsAlert('Error',e.message,'error'));
2111 }});
2112}}
2114// ═══ Storage ═══
2115function loadStoragePanel(el) {{
2116 Promise.all([
2117 fetch(SHELL+'/api/shell/storage',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}})),
2118 fetch(SHELL+'/api/shell/storage/cleanup',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}}))
2119 ]).then(([st,cl])=>{{
2120 const disks = st.disks||[];
2121 const cleanable = cl.total_cleanable_mb||0;
2122 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Storage</div>';
2123 disks.forEach(d=>{{
2124 html += dsMetricBar(d.mountpoint||d.device, d.percent||0, '%', (d.used_gb||0).toFixed(1)+' / '+(d.total_gb||0).toFixed(1)+' GB');
2125 }});
2126 if(cleanable>0) html += '<div class="ds-flex ds-gap-2" style="margin-top:8px">'+
2127 '<div class="ds-body-md ds-text-muted">'+(cleanable/1024).toFixed(1)+' GB cleanable</div>'+
2128 dsBtn('Clean Up',{{variant:'secondary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'/api/shell/storage/clean',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:'{{}}'}}).then(()=>showToast('Storage','Cleaned up','success'))"}})+'</div>';
2129 html += '</div>';
2130 el.innerHTML = html;
2131 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Storage info unavailable</div>'; }});
2132}}
2134// ═══ Startup Apps ═══
2135function loadStartupAppsPanel(el) {{
2136 fetch(SHELL+'/api/shell/startup',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2137 const apps = data.apps||[];
2138 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Startup Apps</div><div class="ds-stagger">';
2139 if(apps.length===0) html += '<div class="ds-body-md ds-text-muted">No startup apps configured</div>';
2140 else apps.forEach(a=>{{
2141 html += '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon '+(a.enabled?'ds-text-active':'ds-text-muted')+'">play_circle</span>'+
2142 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+a.name+'</div>'+
2143 '<div class="ds-list-item-secondary">'+(a.comment||a.exec||'')+'</div></div>'+
2144 '<label class="ds-switch"><input type="checkbox" '+(a.enabled?'checked':'')+' onchange="toggleStartup(\\''+a.id+'\\',this.checked)"><span class="ds-switch-slider"></span></label></div>';
2145 }});
2146 html += '</div></div>';
2147 el.innerHTML = html;
2148 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Startup apps unavailable</div>'; }});
2149}}
2150function toggleStartup(id,en) {{
2151 fetch(SHELL+'/api/shell/startup/toggle',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{id:id,enabled:en}})}}).catch(()=>{{}});
2152}}
2154// ═══ Bluetooth Manager ═══
2155function loadBluetoothManagerPanel(el) {{
2156 fetch(SHELL+'/api/shell/bluetooth/status',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2157 const devs = data.devices||[];
2158 const powered = data.powered!==false;
2159 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">Bluetooth</span>'+
2160 '<span class="ds-chip"><span class="ds-chip-dot" style="background:var('+(powered?'--hart-active':'--hart-muted')+')"></span>'+(powered?'On':'Off')+'</span></div>';
2161 html += dsBtn('Scan',{{variant:'secondary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'/api/shell/bluetooth/scan',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:'{{}}'}}).then(()=>{{showToast('Bluetooth','Scanning...','info');setTimeout(()=>loadBluetoothManagerPanel(document.getElementById('sys-bluetooth_manager')),5000);}})"}});
2162 html += '<div class="ds-stagger">';
2163 devs.forEach(d=>{{
2164 html += dsStatusRow(d.connected?'bluetooth_connected':'bluetooth', d.name||d.address, d.connected?'Connected':'Paired',
2165 d.connected?'var(--hart-active)':'var(--hart-muted)',{{sublabel:d.address||''}});
2166 }});
2167 if(devs.length===0) html += '<div class="ds-body-md ds-text-muted">No paired devices</div>';
2168 html += '</div></div>';
2169 el.innerHTML = html;
2170 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Bluetooth unavailable</div>'; }});
2171}}
2173// ═══ Print Manager ═══
2174function loadPrintManagerPanel(el) {{
2175 fetch(SHELL+'/api/shell/printers',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2176 const printers = data.printers||[];
2177 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Printers</div><div class="ds-stagger">';
2178 if(printers.length===0) html += '<div class="ds-body-md ds-text-muted">No printers found</div>';
2179 else printers.forEach(p=>{{
2180 html += dsStatusRow('print', p.name, p.is_default?'Default':p.status||'Ready',
2181 p.is_default?'var(--hart-accent)':'var(--hart-muted)',{{sublabel:p.location||p.device||''}});
2182 }});
2183 html += '</div></div>';
2184 el.innerHTML = html;
2185 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Printers unavailable</div>'; }});
2186}}
2188// ═══ Media Library ═══
2189function loadMediaLibraryPanel(el) {{
2190 fetch(SHELL+'/api/shell/media/status',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2191 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Media Library</div>';
2192 html += '<div class="ds-flex ds-gap-3 ds-flex-wrap">';
2193 html += dsCard('<div class="ds-metric"><div class="ds-metric-value ds-text-accent">'+(data.photo_count||0)+'</div><div class="ds-metric-label">Photos</div></div>',{{elevated:true}});
2194 html += dsCard('<div class="ds-metric"><div class="ds-metric-value ds-text-active">'+(data.video_count||0)+'</div><div class="ds-metric-label">Videos</div></div>',{{elevated:true}});
2195 html += dsCard('<div class="ds-metric"><div class="ds-metric-value ds-text-muted">'+(data.audio_count||0)+'</div><div class="ds-metric-label">Audio</div></div>',{{elevated:true}});
2196 html += '</div></div>';
2197 el.innerHTML = html;
2198 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Media library unavailable</div>'; }});
2199}}
2201// ═══ File Manager ═══
2202function loadFileManagerPanel(el) {{
2203 fetch(SHELL+'/api/shell/files/browse?path=~',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2204 const items = data.items||[];
2205 const cwd = data.path||'~';
2206 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">Files</span>'+
2207 '<span class="ds-label-sm ds-text-muted">'+cwd+'</span></div><div class="ds-stagger">';
2208 items.slice(0,30).forEach(f=>{{
2209 const icon = f.is_dir?'folder':'description';
2210 const size = f.is_dir?'':' · '+(f.size_human||'');
2211 html += '<div class="ds-list-item'+(f.is_dir?' ds-list-item-interactive':'')+'"'+
2212 (f.is_dir?' onclick="browseDir(\\''+f.path.replace(/'/g,"\\\\'")+'\\');"':'')+'>'+
2213 '<span class="mi material-icons-round ds-list-item-icon '+(f.is_dir?'ds-text-accent':'ds-text-muted')+'">'+icon+'</span>'+
2214 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+f.name+'</div>'+
2215 '<div class="ds-list-item-secondary">'+(f.modified||'')+size+'</div></div></div>';
2216 }});
2217 html += '</div></div>';
2218 el.innerHTML = html;
2219 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">File browser unavailable</div>'; }});
2220}}
2221function browseDir(path) {{
2222 const el = document.getElementById('sys-file_manager');
2223 if(!el) return;
2224 el.innerHTML = dsSkeleton('panel',3);
2225 fetch(SHELL+'/api/shell/files/browse?path='+encodeURIComponent(path),{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2226 const items = data.items||[];
2227 const cwd = data.path||path;
2228 const parent = data.parent||'';
2229 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">Files</span>'+
2230 '<span class="ds-label-sm ds-text-muted">'+cwd+'</span></div>';
2231 if(parent) html += '<div class="ds-list-item ds-list-item-interactive" onclick="browseDir(\\''+parent.replace(/'/g,"\\\\'")+'\\')">'+
2232 '<span class="mi material-icons-round ds-list-item-icon ds-text-muted">arrow_back</span>'+
2233 '<div class="ds-list-item-content"><div class="ds-list-item-primary">..</div></div></div>';
2234 html += '<div class="ds-stagger">';
2235 items.slice(0,30).forEach(f=>{{
2236 const icon = f.is_dir?'folder':'description';
2237 html += '<div class="ds-list-item'+(f.is_dir?' ds-list-item-interactive':'')+'"'+
2238 (f.is_dir?' onclick="browseDir(\\''+f.path.replace(/'/g,"\\\\'")+'\\');"':'')+'>'+
2239 '<span class="mi material-icons-round ds-list-item-icon '+(f.is_dir?'ds-text-accent':'ds-text-muted')+'">'+icon+'</span>'+
2240 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+f.name+'</div></div></div>';
2241 }});
2242 html += '</div></div>';
2243 el.innerHTML = html;
2244 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Cannot browse</div>'; }});
2245}}
2247// ═══ Terminal ═══
2248function loadTerminalPanel(el) {{
2249 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Terminal</div>'+
2250 '<div style="background:#0d0d0d;border-radius:8px;padding:12px;font-family:monospace;min-height:200px;position:relative">'+
2251 '<div id="term-output" style="color:#a0ffa0;white-space:pre-wrap;max-height:280px;overflow-y:auto;font-size:13px;line-height:1.5"></div>'+
2252 '<div style="display:flex;align-items:center;margin-top:8px">'+
2253 '<span style="color:#a0ffa0;margin-right:4px">$</span>'+
2254 '<input id="term-input" type="text" style="flex:1;background:transparent;border:none;color:#a0ffa0;font-family:monospace;font-size:13px;outline:none" '+
2255 'placeholder="Type command..." onkeydown="if(event.key===\\'Enter\\')termExec()">'+
2256 '</div></div></div>';
2257}}
2258function termExec() {{
2259 const inp = document.getElementById('term-input');
2260 const out = document.getElementById('term-output');
2261 if(!inp||!out) return;
2262 const cmd = inp.value.trim();
2263 if(!cmd) return;
2264 inp.value = '';
2265 out.textContent += '$ '+cmd+'\\n';
2266 fetch(SHELL+'/api/shell/terminal/exec',{{method:'POST',headers:{{'Content-Type':'application/json'}},
2267 body:JSON.stringify({{command:cmd}}),signal:AbortSignal.timeout(30000)}}
2268 ).then(r=>r.json()).then(d=>{{
2269 out.textContent += (d.stdout||'')+(d.stderr?'\\n'+d.stderr:'')+'\\n';
2270 out.scrollTop = out.scrollHeight;
2271 }}).catch(e=>{{ out.textContent += 'Error: '+e.message+'\\n'; }});
2272}}
2274// ═══ User Accounts ═══
2275function loadUserAccountsPanel(el) {{
2276 fetch(SHELL+'/api/shell/users',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2277 const users = data.users||[];
2278 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">User Accounts</div><div class="ds-stagger">';
2279 users.forEach(u=>{{
2280 html += dsStatusRow('person', u.username||u.name, u.is_admin?'Admin':'User',
2281 u.is_admin?'var(--hart-accent)':'var(--hart-muted)',{{sublabel:'UID '+(u.uid||'')}});
2282 }});
2283 html += '</div></div>';
2284 el.innerHTML = html;
2285 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">User accounts unavailable</div>'; }});
2286}}
2288// ═══ Notification Center ═══
2289function loadNotificationCenterPanel(el) {{
2290 fetch(SHELL+'/api/shell/notifications',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2291 const notifs = data.notifications||[];
2292 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Notifications</div><div class="ds-stagger">';
2293 if(notifs.length===0) html += '<div class="ds-body-md ds-text-muted">No notifications</div>';
2294 else notifs.slice(0,20).forEach(n=>{{
2295 html += '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon '+(n.read?'ds-text-muted':'ds-text-accent')+'">'+
2296 (n.read?'notifications_none':'notifications_active')+'</span>'+
2297 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+(n.title||n.message||'Notification')+'</div>'+
2298 '<div class="ds-list-item-secondary">'+(n.time||n.created_at||'')+'</div></div></div>';
2299 }});
2300 html += '</div></div>';
2301 el.innerHTML = html;
2302 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Notifications unavailable</div>'; }});
2303}}
2305// ═══ Updates ═══
2306function loadUpdatesPanel(el) {{
2307 fetch(BACKEND+'/api/upgrades/status',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2308 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">System Updates</div>';
2309 html += dsStatusRow('system_update', 'Current Version', data.current_version||'unknown', 'var(--hart-active)');
2310 if(data.new_version) html += dsStatusRow('upgrade', 'Available', data.new_version, 'var(--hart-accent)');
2311 else html += '<div class="ds-body-md ds-text-active" style="padding:12px 0">System is up to date</div>';
2312 html += dsStatusRow('schedule', 'Pipeline', data.pipeline_stage||'idle', 'var(--hart-muted)');
2313 html += '</div>';
2314 el.innerHTML = html;
2315 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Update status unavailable</div>'; }});
2316}}
2318// ═══ Backup & Restore ═══
2319function loadBackupRestorePanel(el) {{
2320 fetch(SHELL+'/api/shell/backup/list',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2321 const backups = data.backups||[];
2322 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Backup & Restore</div><div class="ds-stagger">';
2323 if(backups.length===0) html += '<div class="ds-body-md ds-text-muted">No backups found</div>';
2324 else backups.forEach(b=>{{
2325 html += dsStatusRow('backup', b.name||b.path, b.date||b.created||'', 'var(--hart-muted)');
2326 }});
2327 html += '</div></div>';
2328 el.innerHTML = html;
2329 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Backup info unavailable</div>'; }});
2330}}
2332// ═══ Devices & Mesh ═══
2333function loadDevicesPanel(el) {{
2334 fetch(SHELL+'/api/shell/devices',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2335 const devs = data.devices||[];
2336 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Devices & Mesh</div><div class="ds-stagger">';
2337 if(devs.length===0) html += '<div class="ds-body-md ds-text-muted">No paired devices</div>';
2338 else devs.forEach(d=>{{
2339 html += dsStatusRow('devices_other', d.name||d.device_id||'Device', d.status||'unknown',
2340 d.status==='paired'?'var(--hart-active)':'var(--hart-muted)',{{sublabel:d.device_id||''}});
2341 }});
2342 html += '</div></div>';
2343 el.innerHTML = html;
2344 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Devices unavailable</div>'; }});
2345}}
2347// ═══ Language & Region ═══
2348function loadI18nPanel(el) {{
2349 fetch(SHELL+'/api/shell/i18n/locales',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2350 const current = data.current||'en';
2351 const locales = data.available||[];
2352 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Language & Region</div>';
2353 html += dsStatusRow('language', 'Current', current, 'var(--hart-accent)');
2354 html += '<div class="ds-section-label">Available Languages</div><div class="ds-stagger">';
2355 locales.slice(0,15).forEach(l=>{{
2356 const active = l.code===current;
2357 html += '<div class="ds-list-item'+(active?'':' ds-list-item-interactive')+'"'+
2358 (active?'':' onclick="setLocale(\\''+l.code+'\\')"')+'>'+
2359 '<span class="mi material-icons-round ds-list-item-icon '+(active?'ds-text-active':'ds-text-muted')+'">translate</span>'+
2360 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+(l.name||l.code)+'</div></div>'+
2361 (active?'<span class="ds-list-item-trailing ds-text-active"><span class="mi material-icons-round">check</span></span>':'')+
2362 '</div>';
2363 }});
2364 html += '</div></div>';
2365 el.innerHTML = html;
2366 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Language settings unavailable</div>'; }});
2367}}
2368function setLocale(code) {{
2369 fetch(SHELL+'/api/shell/i18n/set',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{locale:code}})}})
2370 .then(()=>{{showToast('Language','Set to '+code,'success');loadI18nPanel(document.getElementById('sys-i18n'));}}).catch(()=>{{}});
2371}}
2373// ═══ Accessibility ═══
2374function loadAccessibilityPanel(el) {{
2375 fetch(SHELL+'/api/shell/accessibility',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2376 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Accessibility</div><div class="ds-stagger">';
2377 const items = [
2378 ['screen_reader', 'Screen Reader', data.screen_reader],
2379 ['text_increase', 'Large Text', data.large_text],
2380 ['contrast', 'High Contrast', data.high_contrast],
2381 ['animation', 'Reduce Motion', data.reduce_motion],
2382 ];
2383 items.forEach(([icon,label,val])=>{{
2384 html += '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-accent">'+icon+'</span>'+
2385 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+label+'</div></div>'+
2386 '<label class="ds-switch"><input type="checkbox" '+(val?'checked':'')+' onchange="toggleA11y(\\''+label.toLowerCase().replace(/ /g,'_')+'\\',this.checked)"><span class="ds-switch-slider"></span></label></div>';
2387 }});
2388 if(data.font_scale) html += dsStatusRow('format_size', 'Font Scale', data.font_scale+'x', 'var(--hart-muted)');
2389 html += '</div></div>';
2390 el.innerHTML = html;
2391 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Accessibility unavailable</div>'; }});
2392}}
2393function toggleA11y(key,val) {{
2394 const body = {{}};
2395 body[key] = val;
2396 fetch(SHELL+'/api/shell/accessibility',{{method:'PUT',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}}).catch(()=>{{}});
2397}}
2399// ═══ Screenshot & Recording ═══
2400function loadScreenshotPanel(el) {{
2401 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Screenshot & Recording</div>'+
2402 '<div class="ds-flex ds-gap-3 ds-flex-wrap" style="padding:24px 0">'+
2403 dsBtn('Take Screenshot',{{variant:'primary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'/api/shell/screenshot',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{type:'full'}})}}).then(r=>r.json()).then(d=>showToast('Screenshot',d.path||'Captured','success')).catch(()=>showToast('Screenshot','Failed','error'))"}})+
2404 dsBtn('Window Screenshot',{{variant:'secondary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'/api/shell/screenshot',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{type:'window'}})}}).then(r=>r.json()).then(d=>showToast('Screenshot',d.path||'Captured','success')).catch(()=>{{}})"}})+
2405 dsBtn('Start Recording',{{variant:'secondary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'/api/shell/recording/start',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:'{{}}'}}).then(()=>showToast('Recording','Started','info')).catch(()=>{{}})"}})+
2406 dsBtn('Stop Recording',{{variant:'secondary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'/api/shell/recording/stop',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:'{{}}'}}).then(r=>r.json()).then(d=>showToast('Recording','Saved: '+(d.path||''),'success')).catch(()=>{{}})"}})+
2407 '</div></div>';
2408}}
2410// ═══ Firewall ═══
2411function loadFirewallPanel(el) {{
2412 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Firewall & Firmware</div>'+
2413 '<div class="ds-stagger">'+
2414 dsStatusRow('shield', 'Firewall', 'Active (nftables)', 'var(--hart-active)',{{sublabel:'Managed by NixOS declarative config'}})+
2415 dsStatusRow('security', 'Zones', 'trusted / hive / public', 'var(--hart-muted)')+
2416 dsStatusRow('verified_user', 'Firmware Updates', 'fwupd enabled', 'var(--hart-active)')+
2417 '</div></div>';
2418}}
2420// ═══ Default Apps ═══
2421function loadDefaultAppsPanel(el) {{
2422 fetch(SHELL+'/api/shell/default-apps',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2423 const apps = data.defaults||{{}};
2424 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Default Apps</div><div class="ds-stagger">';
2425 const cats = [['web-browser','Web Browser','public'],['text-editor','Text Editor','edit_note'],
2426 ['file-manager','File Manager','folder'],['terminal','Terminal','terminal'],
2427 ['image-viewer','Image Viewer','photo'],['video-player','Video Player','play_circle'],
2428 ['music-player','Music Player','music_note'],['email-client','Email','email'],
2429 ['pdf-viewer','PDF Viewer','picture_as_pdf']];
2430 cats.forEach(([key,label,icon])=>{{
2431 html += dsStatusRow(icon, label, apps[key]||'Not set', apps[key]?'var(--hart-accent)':'var(--hart-muted)');
2432 }});
2433 html += '</div></div>';
2434 el.innerHTML = html;
2435 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Default apps unavailable</div>'; }});
2436}}
2438// ═══ Font Manager ═══
2439function loadFontManagerPanel(el) {{
2440 fetch(SHELL+'/api/shell/fonts',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2441 const fonts = data.fonts||[];
2442 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">Fonts</span>'+
2443 '<span class="ds-chip"><span class="ds-chip-dot" style="background:var(--hart-accent)"></span>'+fonts.length+' installed</span></div>';
2444 html += '<div class="ds-stagger">';
2445 fonts.slice(0,20).forEach(f=>{{
2446 html += '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-accent">font_download</span>'+
2447 '<div class="ds-list-item-content"><div class="ds-list-item-primary" style="font-family:\\''+f.family+'\\'">'+f.family+'</div>'+
2448 '<div class="ds-list-item-secondary">'+(f.style||f.styles||'')+'</div></div></div>';
2449 }});
2450 html += '</div></div>';
2451 el.innerHTML = html;
2452 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Fonts unavailable</div>'; }});
2453}}
2455// ═══ Sound Manager ═══
2456function loadSoundManagerPanel(el) {{
2457 fetch(SHELL+'/api/shell/sounds/themes',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2458 const themes = data.themes||[];
2459 const current = data.current||'freedesktop';
2460 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Sound Theme</div>';
2461 html += dsStatusRow('music_note', 'Current Theme', current, 'var(--hart-accent)');
2462 html += '<div class="ds-stagger">';
2463 themes.forEach(t=>{{
2464 html += '<div class="ds-list-item'+(t===current?'':' ds-list-item-interactive')+'"'+
2465 (t===current?'':' onclick="fetch(SHELL+\\'/api/shell/sounds/set-theme\\',{{method:\\'POST\\',headers:{{\\'Content-Type\\':\\'application/json\\'}},body:JSON.stringify({{theme:\\''+t+'\\'}})}}).then(()=>loadSoundManagerPanel(document.getElementById(\\'sys-sound_manager\\')))"')+'>'+
2466 '<span class="mi material-icons-round ds-list-item-icon '+(t===current?'ds-text-active':'ds-text-muted')+'">volume_up</span>'+
2467 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+t+'</div></div>'+
2468 (t===current?'<span class="ds-list-item-trailing ds-text-active"><span class="mi material-icons-round">check</span></span>':'')+
2469 '</div>';
2470 }});
2471 html += '</div></div>';
2472 el.innerHTML = html;
2473 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Sound settings unavailable</div>'; }});
2474}}
2476// ═══ Clipboard ═══
2477function loadClipboardPanel(el) {{
2478 fetch(SHELL+'/api/shell/clipboard/history',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2479 const items = data.history||[];
2480 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">Clipboard</span>'+
2481 dsBtn('Clear',{{variant:'secondary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'/api/shell/clipboard/clear',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:'{{}}'}}).then(()=>loadClipboardPanel(document.getElementById('sys-clipboard_manager')))"}})+
2482 '</div><div class="ds-stagger">';
2483 if(items.length===0) html += '<div class="ds-body-md ds-text-muted">Clipboard empty</div>';
2484 else items.slice(0,15).forEach((c,i)=>{{
2485 const preview = (c.text||c.content||'').substring(0,80);
2486 html += '<div class="ds-list-item ds-list-item-interactive" onclick="fetch(SHELL+\\'/api/shell/clipboard/copy\\',{{method:\\'POST\\',headers:{{\\'Content-Type\\':\\'application/json\\'}},body:JSON.stringify({{text:\\''+preview.replace(/'/g,"\\\\'").replace(/\n/g,' ')+'\\'}})}}); showToast(\\'Clipboard\\',\\'Copied\\',\\'info\\')">'+
2487 '<span class="mi material-icons-round ds-list-item-icon ds-text-muted">content_paste</span>'+
2488 '<div class="ds-list-item-content"><div class="ds-list-item-primary ds-truncate">'+preview+'</div>'+
2489 '<div class="ds-list-item-secondary">'+(c.time||'')+(c.pinned?' · Pinned':'')+'</div></div></div>';
2490 }});
2491 html += '</div></div>';
2492 el.innerHTML = html;
2493 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Clipboard unavailable</div>'; }});
2494}}
2496// ═══ Date & Time ═══
2497function loadDateTimePanel(el) {{
2498 fetch(SHELL+'/api/shell/datetime',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2499 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Date & Time</div>';
2500 html += '<div class="ds-flex ds-flex-center ds-flex-col ds-gap-2" style="padding:16px 0">'+
2501 '<div class="ds-display-sm ds-text-accent">'+(data.time||'')+'</div>'+
2502 '<div class="ds-title-sm ds-text-muted">'+(data.date||'')+'</div></div>';
2503 html += '<div class="ds-stagger">';
2504 html += dsStatusRow('schedule', 'Timezone', data.timezone||'UTC', 'var(--hart-accent)');
2505 html += dsStatusRow('sync', 'NTP Sync', data.ntp_enabled?'Enabled':'Disabled', data.ntp_enabled?'var(--hart-active)':'var(--hart-muted)');
2506 html += dsStatusRow('today', 'Format', data.format||'24h', 'var(--hart-muted)');
2507 html += '</div></div>';
2508 el.innerHTML = html;
2509 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Date/time unavailable</div>'; }});
2510}}
2512// ═══ Wallpaper ═══
2513function loadWallpaperPanel(el) {{
2514 Promise.all([
2515 fetch(SHELL+'/api/shell/wallpaper',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}})),
2516 fetch(SHELL+'/api/shell/wallpaper/collection',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}}))
2517 ]).then(([cur,col])=>{{
2518 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Wallpaper</div>';
2519 html += dsStatusRow('wallpaper', 'Current', (cur.path||'Default').split('/').pop(), 'var(--hart-accent)');
2520 const walls = col.wallpapers||[];
2521 if(walls.length>0) {{
2522 html += '<div class="ds-section-label">Collection</div><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px">';
2523 walls.slice(0,12).forEach(w=>{{
2524 html += '<div style="aspect-ratio:16/9;border-radius:8px;background:#1a1a1a;cursor:pointer;overflow:hidden;border:2px solid transparent" '+
2525 'onclick="fetch(SHELL+\\'/api/shell/wallpaper/set\\',{{method:\\'POST\\',headers:{{\\'Content-Type\\':\\'application/json\\'}},body:JSON.stringify({{path:\\''+w.path.replace(/'/g,"\\\\'")+'\\'}})}}); showToast(\\'Wallpaper\\',\\'Set\\',\\'success\\')">'+
2526 '<img src="'+SHELL+'/api/shell/files/thumb?path='+encodeURIComponent(w.path)+'" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML=\\'<div style=padding:8px;font-size:11px>'+w.name+'</div>\\'"></div>';
2527 }});
2528 html += '</div>';
2529 }}
2530 html += '</div>';
2531 el.innerHTML = html;
2532 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Wallpaper settings unavailable</div>'; }});
2533}}
2535// ═══ Keyboard & Input Methods ═══
2536function loadInputMethodsPanel(el) {{
2537 fetch(SHELL+'/api/shell/input-methods',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2538 const layout = data.layout||'us';
2539 const variant = data.variant||'';
2540 const methods = data.input_methods||[];
2541 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Keyboard & Input</div><div class="ds-stagger">';
2542 html += dsStatusRow('keyboard', 'Layout', layout+(variant?' ('+variant+')':''), 'var(--hart-accent)');
2543 if(methods.length>0) {{
2544 html += '<div class="ds-section-label">Input Methods</div>';
2545 methods.forEach(m=>{{
2546 html += dsStatusRow('translate', m.name||m.id, m.active?'Active':'Available',
2547 m.active?'var(--hart-active)':'var(--hart-muted)');
2548 }});
2549 }}
2550 html += '</div></div>';
2551 el.innerHTML = html;
2552 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Input settings unavailable</div>'; }});
2553}}
2555// ═══ Night Light ═══
2556function loadNightLightPanel(el) {{
2557 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Night Light</div>'+
2558 '<div class="ds-stagger">'+
2559 dsStatusRow('nightlight', 'Status', 'Managed by gammastep', 'var(--hart-accent)',{{sublabel:'Reduces blue light in the evening'}})+
2560 dsStatusRow('schedule', 'Schedule', 'Sunset to Sunrise', 'var(--hart-muted)')+
2561 dsStatusRow('thermostat', 'Temperature', '3500K', 'var(--hart-accent)')+
2562 '</div><div class="ds-body-sm ds-text-muted" style="margin-top:12px">Configured via NixOS module hart-nightlight.nix</div></div>';
2563}}
2565// ═══ Workspaces ═══
2566function loadWorkspacesPanel(el) {{
2567 fetch(SHELL+'/api/shell/workspaces',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2568 const ws = data.workspaces||[];
2569 const current = data.current||1;
2570 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Workspaces</div>'+
2571 '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px;padding:16px 0">';
2572 (ws.length>0?ws:([1,2,3,4].map(i=>({{number:i}})))).forEach(w=>{{
2573 const num = w.number||w.id;
2574 const active = num===current;
2575 html += '<div style="aspect-ratio:16/9;border-radius:8px;background:'+(active?'var(--hart-accent-10)':'#1a1a1a')+';border:2px solid '+(active?'var(--hart-accent)':'transparent')+';display:flex;align-items:center;justify-content:center;cursor:pointer">'+
2576 '<span class="ds-title-sm '+(active?'ds-text-accent':'ds-text-muted')+'">'+num+'</span></div>';
2577 }});
2578 html += '</div></div>';
2579 el.innerHTML = html;
2580 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Workspaces unavailable</div>'; }});
2581}}
2583// ═══ Calculator ═══
2584function loadCalculatorPanel(el) {{
2585 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Calculator</div>'+
2586 '<input id="calc-display" type="text" readonly value="0" style="width:100%;background:#0d0d0d;color:var(--hart-text);border:none;border-radius:8px;padding:16px;font-size:28px;text-align:right;font-family:monospace;margin-bottom:8px">'+
2587 '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px" id="calc-grid"></div></div>';
2588 const grid = document.getElementById('calc-grid');
2589 const btns = ['C','(',')','/',7,8,9,'*',4,5,6,'-',1,2,3,'+',0,'.','%','='];
2590 btns.forEach(b=>{{
2591 const isOp = typeof b==='string'&&b!=='C';
2592 const el2 = document.createElement('button');
2593 el2.className = 'ds-btn ds-btn-sm';
2594 el2.style.cssText = 'padding:14px;font-size:18px;'+(isOp?'color:var(--hart-accent)':'');
2595 el2.textContent = b;
2596 el2.onclick = ()=>calcPress(String(b));
2597 grid.appendChild(el2);
2598 }});
2599}}
2600function calcPress(b) {{
2601 const d = document.getElementById('calc-display');
2602 if(!d) return;
2603 if(b==='C') {{ d.value='0'; return; }}
2604 if(b==='=') {{ try {{ d.value=String(Function('"use strict";return('+d.value+')')());}} catch {{ d.value='Error'; }} return; }}
2605 if(d.value==='0'&&b!=='.') d.value=b; else d.value+=b;
2606}}
2608// ═══ Image Viewer ═══
2609function loadImageViewerPanel(el) {{
2610 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Image Viewer</div>'+
2611 '<div class="ds-flex ds-flex-center ds-flex-col ds-gap-3" style="padding:40px 0">'+
2612 '<span class="mi material-icons-round ds-text-muted" style="font-size:48px">photo</span>'+
2613 '<div class="ds-body-md ds-text-muted">Open an image from the File Manager</div></div></div>';
2614}}
2616// ═══ Notes ═══
2617function loadNotesAppPanel(el) {{
2618 fetch(SHELL+'/api/shell/notes',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2619 const notes = data.notes||[];
2620 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">Notes</span>'+
2621 dsBtn('New',{{variant:'primary',cls:'ds-btn-sm',onclick:"dsPrompt('New Note','',{{placeholder:'Write your note...',okLabel:'Save'}}).then(c=>{{if(!c)return;fetch(SHELL+'/api/shell/notes',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{content:c}})}}).then(()=>loadNotesAppPanel(document.getElementById('sys-notes_app')))}})"}})+'</div>';
2622 html += '<div class="ds-stagger">';
2623 if(notes.length===0) html += '<div class="ds-body-md ds-text-muted">No notes yet</div>';
2624 else notes.forEach(n=>{{
2625 html += '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-accent">sticky_note_2</span>'+
2626 '<div class="ds-list-item-content"><div class="ds-list-item-primary ds-truncate">'+(n.content||'').substring(0,80)+'</div>'+
2627 '<div class="ds-list-item-secondary">'+(n.created||n.date||'')+'</div></div>'+
2628 '<span class="ds-list-item-trailing" style="cursor:pointer" onclick="fetch(SHELL+\\'/api/shell/notes/delete\\',{{method:\\'POST\\',headers:{{\\'Content-Type\\':\\'application/json\\'}},body:JSON.stringify({{id:\\''+n.id+'\\'}})}}).then(()=>loadNotesAppPanel(document.getElementById(\\'sys-notes_app\\')))">'+
2629 '<span class="mi material-icons-round ds-text-muted" style="font-size:18px">delete_outline</span></span></div>';
2630 }});
2631 html += '</div></div>';
2632 el.innerHTML = html;
2633 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Notes unavailable</div>'; }});
2634}}
2636// ═══ App Store ═══
2637function loadAppStorePanel(el) {{
2638 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">App Store</div>'+
2639 '<div style="display:flex;gap:8px;margin-bottom:12px">'+
2640 '<input id="appstore-search" type="text" placeholder="Search packages..." style="flex:1;background:#1a1a1a;border:1px solid #333;color:var(--hart-text);border-radius:8px;padding:8px 12px;font-size:14px" onkeydown="if(event.key===\\'Enter\\')appStoreSearch()">'+
2641 dsBtn('Search',{{variant:'primary',cls:'ds-btn-sm',onclick:'appStoreSearch()'}})+'</div>'+
2642 '<div id="appstore-results" class="ds-stagger"><div class="ds-body-md ds-text-muted">Search for Nix, Flatpak, or AppImage packages</div></div></div>';
2643}}
2644function appStoreSearch() {{
2645 const q = document.getElementById('appstore-search');
2646 const r = document.getElementById('appstore-results');
2647 if(!q||!r||!q.value.trim()) return;
2648 r.innerHTML = dsSkeleton('panel',2);
2649 fetch(SHELL+'/api/apps/search?q='+encodeURIComponent(q.value),{{signal:AbortSignal.timeout(15000)}}).then(r2=>r2.json()).then(data=>{{
2650 const pkgs = data.results||[];
2651 if(pkgs.length===0) {{ r.innerHTML='<div class="ds-body-md ds-text-muted">No packages found</div>'; return; }}
2652 r.innerHTML = pkgs.slice(0,15).map(p=>
2653 '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-accent">inventory_2</span>'+
2654 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+p.name+'</div>'+
2655 '<div class="ds-list-item-secondary">'+(p.platform||p.source||'')+' · '+(p.version||'')+'</div></div>'+
2656 dsBtn('Install',{{variant:'secondary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'/api/apps/install',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{package:'"+p.name+"',platform:'"+(p.platform||'nix')+"'}})}}).then(()=>showToast('App Store','Installing "+p.name+"','info'))"}})+'</div>'
2657 ).join('');
2658 }}).catch(()=>{{ r.innerHTML='<div class="ds-body-md ds-text-muted">Search failed</div>'; }});
2659}}
2661// ═══ App Permissions ═══
2662function loadAppPermissionsPanel(el) {{
2663 fetch(SHELL+'/api/apps/installed',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2664 const apps = data.apps||[];
2665 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">App Permissions</div><div class="ds-stagger">';
2666 if(apps.length===0) html += '<div class="ds-body-md ds-text-muted">No apps installed</div>';
2667 else apps.slice(0,20).forEach(a=>{{
2668 html += dsStatusRow('admin_panel_settings', a.name||a.id, a.platform||'system',
2669 'var(--hart-muted)',{{sublabel:(a.permissions||[]).join(', ')||'No special permissions'}});
2670 }});
2671 html += '</div></div>';
2672 el.innerHTML = html;
2673 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">App permissions unavailable</div>'; }});
2674}}
2676// ═══ Battery Monitor ═══
2677function loadBatteryMonitorPanel(el) {{
2678 fetch(SHELL+'/api/shell/battery',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2679 const pct = data.percent||0;
2680 const charging = data.charging||false;
2681 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Battery</div>'+
2682 '<div class="ds-flex ds-flex-center ds-flex-col ds-gap-2" style="padding:24px 0">'+
2683 '<span class="mi material-icons-round ds-text-accent" style="font-size:56px">'+(charging?'battery_charging_full':pct>20?'battery_full':'battery_alert')+'</span>'+
2684 '<div class="ds-display-sm ds-text-accent">'+pct+'%</div>'+
2685 '<div class="ds-label-sm ds-text-muted">'+(charging?'Charging':'On Battery')+(data.time_remaining?' · '+data.time_remaining+' remaining':'')+'</div></div>';
2686 html += dsMetricBar('Level', pct, '%');
2687 if(data.power_profile) html += dsStatusRow('power', 'Profile', data.power_profile, 'var(--hart-muted)');
2688 html += '</div>';
2689 el.innerHTML = html;
2690 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Battery info unavailable</div>'; }});
2691}}
2693// ═══ WiFi Manager ═══
2694function loadWiFiManagerPanel(el) {{
2695 Promise.all([
2696 fetch(SHELL+'/api/shell/wifi/status',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}})),
2697 fetch(SHELL+'/api/shell/wifi/scan',{{signal:AbortSignal.timeout(8000)}}).then(r=>r.json()).catch(()=>({{}}))
2698 ]).then(([status,scan])=>{{
2699 const connected = status.connected||{{}};
2700 const networks = scan.networks||[];
2701 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">WiFi</div>';
2702 if(connected.ssid) {{
2703 html += dsCard('<div class="ds-flex ds-flex-center ds-flex-col ds-gap-2">'+
2704 '<span class="mi material-icons-round ds-text-active" style="font-size:28px">wifi</span>'+
2705 '<div class="ds-title-sm ds-text-active">'+connected.ssid+'</div>'+
2706 '<div class="ds-label-sm ds-text-muted">'+(connected.ip||'')+'</div>'+
2707 dsBtn('Disconnect',{{variant:'secondary',cls:'ds-btn-sm',onclick:"wifiDisconnect()"}})+'</div>',{{elevated:true}});
2708 }}
2709 if(networks.length>0) {{
2710 html += '<div class="ds-section-label">Available Networks</div><div class="ds-stagger">';
2711 networks.filter(n=>!n.active).slice(0,8).forEach(n=>{{
2712 html += '<div class="ds-list-item ds-list-item-interactive" onclick="wifiConnect(\\''+n.ssid.replace(/'/g,"\\\\'")+'\\')">'+
2713 '<span class="mi material-icons-round ds-list-item-icon ds-text-accent">wifi</span>'+
2714 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+n.ssid+'</div>'+
2715 '<div class="ds-list-item-secondary">'+n.security+' · '+n.signal+'%</div></div></div>';
2716 }});
2717 html += '</div>';
2718 }}
2719 html += '</div>';
2720 el.innerHTML = html;
2721 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">WiFi unavailable</div>'; }});
2722}}
2724// ═══ VPN Manager ═══
2725function loadVPNManagerPanel(el) {{
2726 fetch(SHELL+'/api/shell/vpn/list',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2727 const vpns = data.connections||[];
2728 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">VPN</span>'+
2729 dsBtn('Import',{{variant:'secondary',cls:'ds-btn-sm',onclick:"dsPrompt('Import VPN','Enter WireGuard config path',{{placeholder:'/path/to/wg0.conf',okLabel:'Import'}}).then(p=>{{if(!p)return;fetch(SHELL+'/api/shell/vpn/import',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{config_path:p,type:'wireguard'}})}}).then(r=>r.json()).then(d=>{{showToast('VPN',d.message||'Imported','success');loadVPNManagerPanel(document.getElementById('sys-vpn_manager'))}})}})"}})+'</div><div class="ds-stagger">';
2730 if(vpns.length===0) html += '<div class="ds-body-md ds-text-muted">No VPN connections</div>';
2731 else vpns.forEach(v=>{{
2732 html += '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon '+(v.active?'ds-text-active':'ds-text-muted')+'">vpn_key</span>'+
2733 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+v.name+'</div>'+
2734 '<div class="ds-list-item-secondary">'+(v.type||'')+'</div></div>'+
2735 dsBtn(v.active?'Disconnect':'Connect',{{variant:'secondary',cls:'ds-btn-sm',
2736 onclick:"fetch(SHELL+'/api/shell/vpn/"+(v.active?'disconnect':'connect')+"',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{name:'"+v.name+"'}})}}).then(()=>loadVPNManagerPanel(document.getElementById('sys-vpn_manager')))"}})+
2737 '</div>';
2738 }});
2739 html += '</div></div>';
2740 el.innerHTML = html;
2741 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">VPN unavailable</div>'; }});
2742}}
2744// ═══ Trash Bin ═══
2745function loadTrashBinPanel(el) {{
2746 fetch(SHELL+'/api/shell/trash',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2747 const items = data.items||[];
2748 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-header"><span class="ds-panel-title">Trash</span>'+
2749 (items.length>0?dsBtn('Empty Trash',{{variant:'secondary',cls:'ds-btn-sm',onclick:"dsConfirm('Empty Trash','Permanently delete all items?',{{okLabel:'Empty',danger:true}}).then(ok=>{{if(!ok)return;fetch(SHELL+'/api/shell/trash/empty',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:'{{}}'}}).then(()=>loadTrashBinPanel(document.getElementById('sys-trash_bin')))}})"}}):'')+
2750 '</div><div class="ds-stagger">';
2751 if(items.length===0) html += '<div class="ds-body-md ds-text-muted">Trash is empty</div>';
2752 else items.slice(0,20).forEach(t=>{{
2753 html += '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-muted">delete</span>'+
2754 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+t.name+'</div>'+
2755 '<div class="ds-list-item-secondary">'+(t.deleted_at||'')+'</div></div>'+
2756 '<span class="ds-list-item-trailing" style="cursor:pointer" onclick="fetch(SHELL+\\'/api/shell/trash/restore\\',{{method:\\'POST\\',headers:{{\\'Content-Type\\':\\'application/json\\'}},body:JSON.stringify({{path:\\''+t.original_path.replace(/'/g,"\\\\'")+'\\'}})}}); loadTrashBinPanel(document.getElementById(\\'sys-trash_bin\\'))">'+
2757 '<span class="mi material-icons-round ds-text-accent" style="font-size:18px">restore</span></span></div>';
2758 }});
2759 html += '</div></div>';
2760 el.innerHTML = html;
2761 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Trash unavailable</div>'; }});
2762}}
2764// ═══ Webcam Viewer ═══
2765function loadWebcamViewerPanel(el) {{
2766 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Camera</div>'+
2767 '<div class="ds-flex ds-flex-center ds-flex-col ds-gap-3" style="padding:40px 0">'+
2768 '<span class="mi material-icons-round ds-text-muted" style="font-size:48px">videocam</span>'+
2769 '<div class="ds-body-md ds-text-muted">Camera preview requires native GNOME Cheese or direct device access</div>'+
2770 dsBtn('Open Camera App',{{variant:'secondary',cls:'ds-btn-sm',onclick:"showToast('Camera','Opening cheese...','info')"}})+'</div></div>';
2771}}
2773// ═══ Scanner ═══
2774function loadScannerPanel(el) {{
2775 fetch(SHELL+'/api/shell/scanner/list',{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).then(data=>{{
2776 const scanners = data.scanners||[];
2777 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Scanner</div><div class="ds-stagger">';
2778 if(scanners.length===0) html += '<div class="ds-body-md ds-text-muted">No scanners detected</div>';
2779 else scanners.forEach(s=>{{
2780 html += dsStatusRow('scanner', s.name||s.device, s.status||'Ready', 'var(--hart-active)');
2781 }});
2782 html += '</div></div>';
2783 el.innerHTML = html;
2784 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Scanner unavailable</div>'; }});
2785}}
2787// ═══ Weather ═══
2788function loadWeatherPanel(el) {{
2789 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Weather</div>'+
2790 '<div class="ds-flex ds-flex-center ds-flex-col ds-gap-3" style="padding:40px 0">'+
2791 '<span class="mi material-icons-round ds-text-accent" style="font-size:56px">cloud</span>'+
2792 '<div class="ds-body-md ds-text-muted">Weather widget uses GNOME Weather or wttr.in</div>'+
2793 '<div class="ds-label-sm ds-text-muted">Connect location services for automatic weather</div></div></div>';
2794}}
2796function loadEventLog(el) {{
2797 fetch(SHELL+'/api/shell/events',{{signal:AbortSignal.timeout(3000)}})
2798 .then(r=>r.json()).then(data=>{{
2799 const events = data.events||[];
2800 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Events</div>'+
2801 (events.length===0?'<div class="ds-body-md ds-text-muted">No events recorded</div>':
2802 '<div class="ds-stagger">'+events.slice(0,20).map(e=>
2803 '<div class="ds-list-item"><span class="mi material-icons-round ds-list-item-icon ds-text-muted">schedule</span>'+
2804 '<div class="ds-list-item-content"><div class="ds-list-item-primary">'+e.message+'</div>'+
2805 '<div class="ds-list-item-secondary">'+e.time+'</div></div></div>'
2806 ).join('')+'</div>')+
2807 '</div>';
2808 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">No events</div>'; }});
2809}}
2811function loadDriversPanel(el) {{
2812 fetch(SHELL+'/api/shell/drivers',{{signal:AbortSignal.timeout(5000)}})
2813 .then(r=>r.json()).then(data=>{{
2814 const devs = data.devices||[];
2815 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Drivers & Devices</div>'+
2816 (devs.length===0?'<div class="ds-body-md ds-text-muted">No devices detected</div>':
2817 '<div class="ds-stagger">'+devs.slice(0,20).map(d=>
2818 dsStatusRow(d.type==='usb'?'usb':'memory', d.info, d.type.toUpperCase(),
2819 d.type==='usb'?'var(--hart-active)':'var(--hart-accent)')
2820 ).join('')+'</div>')+
2821 '</div>';
2822 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Drivers panel unavailable</div>'; }});
2823}}
2825function setVolume(sinkId, vol) {{
2826 fetch(SHELL+'/api/shell/audio/volume', {{
2827 method:'POST', headers:{{'Content-Type':'application/json'}},
2828 body:JSON.stringify({{sink_id:sinkId, volume:vol}})
2829 }}).catch(()=>{{}});
2830}}
2831function toggleMute(sinkId, muted) {{
2832 fetch(SHELL+'/api/shell/audio/mute', {{
2833 method:'POST', headers:{{'Content-Type':'application/json'}},
2834 body:JSON.stringify({{sink_id:sinkId, muted:muted}})
2835 }}).then(()=>loadAudioPanel(document.getElementById('sys-audio'))).catch(()=>{{}});
2836}}
2837function setDefaultSink(sinkId) {{
2838 fetch(SHELL+'/api/shell/audio/default', {{
2839 method:'POST', headers:{{'Content-Type':'application/json'}},
2840 body:JSON.stringify({{sink_id:sinkId}})
2841 }}).then(()=>loadAudioPanel(document.getElementById('sys-audio'))).catch(()=>{{}});
2842}}
2843function setSourceVolume(srcId, vol) {{
2844 fetch(SHELL+'/api/shell/audio/source/volume', {{
2845 method:'POST', headers:{{'Content-Type':'application/json'}},
2846 body:JSON.stringify({{source_id:srcId, volume:vol}})
2847 }}).catch(()=>{{}});
2848}}
2850function loadAudioPanel(el) {{
2851 fetch(SHELL+'/api/shell/audio',{{signal:AbortSignal.timeout(5000)}})
2852 .then(r=>r.json()).then(data=>{{
2853 const sinks = data.sinks||[];
2854 const sources = data.sources||[];
2855 let html = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Audio</div>';
2856 html += '<div class="ds-section-label">Output</div>';
2857 if(sinks.length===0) html += '<div class="ds-body-sm ds-text-muted">No audio outputs</div>';
2858 else html += '<div class="ds-stagger">'+sinks.map(s=>
2859 '<div class="ds-card" style="margin-bottom:var(--ds-space-2)">'+
2860 '<div class="ds-flex ds-gap-3" style="align-items:center;margin-bottom:var(--ds-space-3)">'+
2861 '<span class="mi material-icons-round" style="font-size:24px;color:'+(s.mute?'var(--hart-caution)':'var(--hart-active)')+'">'+
2862 (s.mute?'volume_off':'volume_up')+'</span>'+
2863 '<div class="ds-flex-1"><div class="ds-title-sm">'+s.name+'</div>'+
2864 (s.default?'<span class="ds-chip ds-chip-success" style="margin-top:2px"><span class="ds-chip-dot"></span>Default</span>':'')+
2865 '</div>'+
2866 dsBtn(s.mute?'Unmute':'Mute', {{variant:'secondary', cls:'ds-btn-sm', onclick:"toggleMute(\\'"+s.id+"\\',"+(!s.mute)+")"}})+
2867 (!s.default?dsBtn('Set Default', {{variant:'text', cls:'ds-btn-sm', onclick:"setDefaultSink(\\'"+s.id+"\\')"}}):'')+
2868 '</div>'+
2869 dsSlider({{id:'vol-'+s.id.replace(/[^a-z0-9]/gi,''), min:0, max:150, value:s.volume, label:'Volume', unit:'%',
2870 oninput:"setVolume(\\'"+s.id+"\\',this.value)"}})+
2871 '</div>'
2872 ).join('')+'</div>';
2873 html += '<div class="ds-section-label" style="margin-top:var(--ds-space-3)">Input</div>';
2874 if(sources.length===0) html += '<div class="ds-body-sm ds-text-muted">No audio inputs</div>';
2875 else html += '<div class="ds-stagger">'+sources.map(s=>
2876 '<div class="ds-card" style="margin-bottom:var(--ds-space-2)">'+
2877 '<div class="ds-flex ds-gap-3" style="align-items:center;margin-bottom:var(--ds-space-3)">'+
2878 '<span class="mi material-icons-round ds-text-active" style="font-size:24px">mic</span>'+
2879 '<div class="ds-title-sm ds-flex-1">'+s.name+'</div></div>'+
2880 dsSlider({{id:'src-'+s.id.replace(/[^a-z0-9]/gi,''), min:0, max:150, value:s.volume, label:'Volume', unit:'%',
2881 oninput:"setSourceVolume(\\'"+s.id+"\\',this.value)"}})+
2882 '</div>'
2883 ).join('')+'</div>';
2884 html += '</div>';
2885 el.innerHTML = html;
2886 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Audio panel unavailable</div>'; }});
2887}}
2889function loadBluetoothPanel(el) {{
2890 fetch(SHELL+'/api/shell/bluetooth',{{signal:AbortSignal.timeout(5000)}})
2891 .then(r=>r.json()).then(data=>{{
2892 const devs = data.devices||[];
2893 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Bluetooth</div>'+
2894 (devs.length===0?'<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:80px"><span class="mi material-icons-round" style="margin-right:8px;font-size:32px;opacity:0.3">bluetooth_disabled</span>No Bluetooth devices found</div>':
2895 '<div class="ds-stagger">'+devs.map(d=>dsStatusRow('bluetooth',d.name,d.mac,'var(--hart-accent)')).join('')+'</div>')+
2896 '</div>';
2897 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted">Bluetooth unavailable</div>'; }});
2898}}
2900function loadPowerPanel(el) {{
2901 fetch(SHELL+'/api/shell/power',{{signal:AbortSignal.timeout(5000)}})
2902 .then(r=>r.json()).then(data=>{{
2903 const pct = data.percent||100;
2904 const state = data.state||'unknown';
2905 const remaining = data.time_remaining||'';
2906 const icon = pct>80?'battery_full':pct>50?'battery_5_bar':pct>20?'battery_3_bar':'battery_1_bar';
2907 const color = pct>20?'var(--hart-active)':pct>10?'var(--hart-caution)':'var(--hart-error)';
2908 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Power</div>'+
2909 '<div class="ds-card ds-card-elevated">'+
2910 '<div class="ds-metric">'+
2911 '<span class="mi material-icons-round ds-metric-icon" style="color:'+color+'">'+icon+'</span>'+
2912 '<div class="ds-metric-value" style="color:'+color+'">'+pct+'%</div>'+
2913 '<div class="ds-metric-label">'+state+(remaining?' · '+remaining:'')+'</div></div></div>'+
2914 dsMetricBar('Battery', pct, '%')+
2915 '</div>';
2916 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Power info unavailable</div>'; }});
2917}}
2919function setResolution(output, res, rate) {{
2920 fetch(SHELL+'/api/shell/display/resolution', {{
2921 method:'POST', headers:{{'Content-Type':'application/json'}},
2922 body:JSON.stringify({{output:output, resolution:res, rate:rate}})
2923 }}).then(r=>r.json()).then(d=>{{
2924 if(d.success) {{ showToast('Display', 'Resolution updated', 'success'); loadDisplayPanel(document.getElementById('sys-display')); }}
2925 else dsAlert('Resolution Change Failed', d.error||'Unknown error', 'error');
2926 }}).catch(e=>dsAlert('Error', e.message, 'error'));
2927}}
2928function setBrightness(output, val) {{
2929 fetch(SHELL+'/api/shell/display/brightness', {{
2930 method:'POST', headers:{{'Content-Type':'application/json'}},
2931 body:JSON.stringify({{output:output, brightness:val}})
2932 }}).catch(()=>{{}});
2933}}
2935function loadDisplayPanel(el) {{
2936 fetch(SHELL+'/api/shell/display',{{signal:AbortSignal.timeout(5000)}})
2937 .then(r=>r.json()).then(data=>{{
2938 const displays = data.displays||[];
2939 if(displays.length===0) {{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px;font-size:32px;opacity:0.3">desktop_access_disabled</span>No displays detected</div>'; return; }}
2940 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Displays</div>'+
2941 '<div class="ds-stagger">'+displays.map(d=>{{
2942 const modes = d.modes||[];
2943 let html = '<div class="ds-card" style="margin-bottom:var(--ds-space-2)">';
2944 html += '<div class="ds-flex ds-gap-3" style="align-items:center;margin-bottom:var(--ds-space-4)">'+
2945 '<span class="mi material-icons-round ds-text-accent" style="font-size:28px">desktop_windows</span>'+
2946 '<div class="ds-flex-1"><div class="ds-title-sm">'+d.name+'</div>'+
2947 '<span class="ds-label-sm ds-text-active">'+d.resolution+'</span></div></div>';
2948 if(modes.length>0) {{
2949 const options = modes.map(m=>{{
2950 const r = m.rates&&m.rates[0]?m.rates[0]:'';
2951 return {{value:m.resolution+'@'+r, label:m.resolution+(r?' @ '+r+'Hz':'')+(m.active?' (current)':''), selected:m.active}};
2952 }});
2953 html += dsSelect({{label:'Resolution', options:options,
2954 onchange:"const p=this.value.split(\\'@\\');setResolution(\\'"+d.name+"\\',p[0],p[1])"}});
2955 }}
2956 html += '<div style="margin-top:var(--ds-space-4)">'+
2957 dsSlider({{id:'bright-'+d.name.replace(/[^a-z0-9]/gi,''), min:10, max:100, value:100, label:'Brightness', unit:'%',
2958 oninput:"setBrightness(\\'"+d.name+"\\',this.value/100)"}})+
2959 '</div></div>';
2960 return html;
2961 }}).join('')+'</div></div>';
2962 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Display info unavailable</div>'; }});
2963}}
2965// ═══ Remote Desktop Panel ═══
2966function rdStartHost() {{
2967 showToast('Remote Desktop', 'Starting host session...', 'info');
2968 fetch(BACKEND+'/api/remote-desktop/host',{{method:'POST',headers:{{'Content-Type':'application/json'}},
2969 body:JSON.stringify({{engine:'auto'}})}}).then(r=>r.json()).then(d=>{{
2970 dsAlert('Host Started', 'Device ID: <strong>'+d.formatted_id+'</strong><br>Password: <strong>'+d.password+'</strong><br><br><span class="ds-label-sm ds-text-muted">Share these with the person connecting</span>', 'success');
2971 }}).catch(e=>dsAlert('Host Failed', e.message, 'error'));
2972}}
2973function rdConnect() {{
2974 dsPrompt('Connect to Device', 'Enter the remote device ID', {{placeholder:'XXX-XXX-XXX', okLabel:'Next'}}).then(function(id){{
2975 if(!id) return;
2976 dsPrompt('Enter Password', 'Password for device <strong>'+id+'</strong>', {{type:'password', placeholder:'Password', okLabel:'Connect'}}).then(function(pw){{
2977 if(!pw) return;
2978 showToast('Remote Desktop', 'Connecting to '+id+'...', 'info');
2979 fetch(BACKEND+'/api/remote-desktop/connect',{{method:'POST',headers:{{'Content-Type':'application/json'}},
2980 body:JSON.stringify({{device_id:id,password:pw}})}}).then(r=>r.json()).then(d=>{{
2981 if(d.error) dsAlert('Connection Failed', d.error, 'error');
2982 else showToast('Remote Desktop', d.message||'Connected', 'success');
2983 }}).catch(e=>dsAlert('Connection Failed', e.message, 'error'));
2984 }});
2985 }});
2986}}
2988function loadRemoteDesktopPanel(el, apis) {{
2989 Promise.all(apis.map(u=>fetch(BACKEND+u,{{signal:AbortSignal.timeout(5000)}}).then(r=>r.json()).catch(()=>({{}}))))
2990 .then(([status,engines,sessions])=>{{
2991 const did = status.formatted_id || 'Unknown';
2992 const deviceId = status.device_id || '';
2993 const engineList = status.engines || engines.engines || {{}};
2994 const sess = (sessions.sessions || status.active_sessions || []);
2995 const recs = engines.install_recommendations || status.install_recommendations || [];
2997 let html = '<div class="ds-panel-grid ds-fade-in">';
2998 html += '<div class="ds-panel-header"><span class="ds-panel-title">Remote Desktop</span>'+
2999 '<span class="mi material-icons-round ds-text-active" style="font-size:24px">connected_tv</span></div>';
3001 // Device ID card
3002 html += '<div class="ds-card ds-card-elevated ds-card-interactive" onclick="navigator.clipboard.writeText(\\''+deviceId+'\\').then(()=>{{this.querySelector(\\'.copy-hint\\').textContent=\\'Copied!\\';setTimeout(()=>this.querySelector(\\'.copy-hint\\').textContent=\\'Click to copy\\',2000)}})" title="Click to copy">';
3003 html += '<div class="ds-metric"><div class="ds-label-sm ds-text-muted">Your Device ID</div>';
3004 html += '<div class="ds-headline-md ds-text-heading" style="letter-spacing:3px;margin:var(--ds-space-2) 0">'+did+'</div>';
3005 html += '<div class="copy-hint ds-label-sm ds-text-muted">Click to copy</div></div></div>';
3007 // Engines
3008 html += '<div class="ds-section-label">Engines</div><div class="ds-stagger">';
3009 for(const [name,info] of Object.entries(engineList)) {{
3010 const avail = info.available;
3011 html += dsStatusRow(avail?'check_circle':'cancel',
3012 name.charAt(0).toUpperCase()+name.slice(1),
3013 avail?'Available':'Not installed',
3014 avail?'var(--hart-active)':'var(--hart-muted)');
3015 }}
3016 html += '</div>';
3018 // Sessions
3019 if(sess.length > 0) {{
3020 html += '<div class="ds-section-label">Active Sessions ('+sess.length+')</div><div class="ds-stagger">';
3021 for(const s of sess) {{
3022 html += dsStatusRow('cast_connected', s.session_id.substring(0,8)+' — '+s.mode, s.state, 'var(--hart-active)');
3023 }}
3024 html += '</div>';
3025 }}
3027 // Recommendations
3028 if(recs.length > 0) {{
3029 html += '<div class="ds-section-label">Recommended</div><div class="ds-stagger">';
3030 for(const r of recs) {{
3031 html += dsStatusRow('recommend', r.engine, r.reason, 'var(--hart-accent)');
3032 }}
3033 html += '</div>';
3034 }}
3036 // Action buttons
3037 html += '<div class="ds-flex ds-gap-3" style="margin-top:var(--ds-space-2)">';
3038 html += dsBtn('Host', {{variant:'primary', icon:'screen_share', onclick:'rdStartHost()'}});
3039 html += dsBtn('Connect', {{variant:'secondary', icon:'cast', onclick:'rdConnect()'}});
3040 html += '</div>';
3042 html += '</div>';
3043 el.innerHTML = html;
3044 }}).catch(()=>{{ el.innerHTML='<div class="ds-body-md ds-text-muted ds-flex ds-flex-center" style="height:100px"><span class="mi material-icons-round" style="margin-right:8px">error_outline</span>Remote desktop unavailable</div>'; }});
3045}}
3047// ═══ Agent Pill ═══
3048function focusAgent() {{
3049 document.getElementById('agent-input').focus();
3050 document.getElementById('agent-pill').classList.add('expanded');
3051}}
3052function askAgent() {{
3053 const input = document.getElementById('agent-input');
3054 const text = input.value.trim();
3055 if(!text) return;
3056 input.value = '';
3057 const resp = document.getElementById('agent-resp');
3058 resp.textContent = 'Thinking...';
3059 resp.classList.add('visible');
3061 // Check for theme commands first
3062 const lower = text.toLowerCase();
3063 if(lower.includes('theme')||lower.includes('font')||lower.includes('bigger')||
3064 lower.includes('smaller')||lower.includes('dark')||lower.includes('light')) {{
3065 handleThemeCommand(lower, resp);
3066 return;
3067 }}
3068 // Check for panel open commands
3069 if(lower.startsWith('open ')) {{
3070 const target = lower.replace('open ','').trim();
3071 const match = Object.entries(MANIFEST).find(([k,v])=>
3072 v.title.toLowerCase().includes(target)||k.includes(target));
3073 if(match) {{ openPanel(match[0]); resp.textContent='Opened '+match[1].title; return; }}
3074 }}
3076 fetch(SHELL+'/api/agent/ask',{{method:'POST',headers:{{'Content-Type':'application/json'}},
3077 body:JSON.stringify({{text}})}})
3078 .then(r=>r.json()).then(data=>{{
3079 const txt = data.response || data.error || 'No response';
3080 resp.textContent = txt;
3081 speakText(txt, 'chat_response');
3082 }}).catch(()=>{{ resp.textContent='Could not reach agent'; }});
3083}}
3085// ═══ Floating Assistant Chat ═══
3086const AC_CAPS = [
3087 {{id:'chat',name:'Chat',icon:'chat'}},
3088 {{id:'recipe',name:'Recipes',icon:'receipt_long'}},
3089 {{id:'agents',name:'Agents',icon:'smart_toy'}},
3090 {{id:'vision',name:'Vision',icon:'visibility'}},
3091 {{id:'voice',name:'Voice',icon:'record_voice_over'}},
3092 {{id:'expert',name:'Experts',icon:'psychology'}},
3093 {{id:'openclaw',name:'OpenClaw',icon:'extension'}},
3094 {{id:'code',name:'Code',icon:'code'}},
3095 {{id:'remote',name:'Remote',icon:'desktop_windows'}},
3096 {{id:'channels',name:'Channels',icon:'forum'}},
3097];
3098let acMessages = [];
3099let acActiveCap = 'chat';
3100let acDragging = false;
3101let acDragOfs = {{x:0,y:0}};
3103function initAssistantChat() {{
3104 // Render capability pills
3105 const capsEl = document.getElementById('ac-caps');
3106 if(!capsEl) return;
3107 capsEl.innerHTML = AC_CAPS.map(c=>
3108 '<div class="ac-cap'+(c.id===acActiveCap?' active':'')+'" onclick="acSelectCap(\''+c.id+'\')" title="'+c.name+'">'+
3109 '<span class="mi material-icons-round">'+c.icon+'</span>'+c.name+'</div>'
3110 ).join('');
3112 // Drag support
3113 const handle = document.getElementById('ac-drag-handle');
3114 const chat = document.getElementById('assistant-chat');
3115 if(!handle||!chat) return;
3116 handle.addEventListener('mousedown', function(e) {{
3117 if(e.target.closest('.ac-btn')) return;
3118 acDragging = true;
3119 const rect = chat.getBoundingClientRect();
3120 acDragOfs = {{x: e.clientX - rect.left, y: e.clientY - rect.top}};
3121 e.preventDefault();
3122 }});
3123 document.addEventListener('mousemove', function(e) {{
3124 if(!acDragging) return;
3125 const chat = document.getElementById('assistant-chat');
3126 chat.style.left = (e.clientX - acDragOfs.x) + 'px';
3127 chat.style.top = (e.clientY - acDragOfs.y) + 'px';
3128 chat.style.right = 'auto';
3129 chat.style.bottom = 'auto';
3130 }});
3131 document.addEventListener('mouseup', function() {{ acDragging = false; }});
3132}}
3134function toggleAssistantChat() {{
3135 const chat = document.getElementById('assistant-chat');
3136 const pill = document.getElementById('agent-pill');
3137 if(!chat) return;
3138 const isOpen = chat.classList.contains('open');
3139 if(isOpen) {{
3140 chat.classList.remove('open');
3141 pill.classList.remove('hidden');
3142 }} else {{
3143 chat.classList.add('open');
3144 pill.classList.add('hidden');
3145 initAssistantChat();
3146 const input = document.getElementById('ac-input');
3147 if(input) setTimeout(function(){{input.focus()}},100);
3148 }}
3149}}
3151function minimizeAssistant() {{
3152 const chat = document.getElementById('assistant-chat');
3153 const pill = document.getElementById('agent-pill');
3154 if(chat) chat.classList.remove('open');
3155 if(pill) pill.classList.remove('hidden');
3156}}
3158function acSelectCap(id) {{
3159 acActiveCap = id;
3160 document.querySelectorAll('.ac-cap').forEach(function(el) {{
3161 el.classList.toggle('active', el.getAttribute('onclick').includes("'"+id+"'"));
3162 }});
3163}}
3165function acAddMsg(role, text) {{
3166 const msgsEl = document.getElementById('ac-messages');
3167 if(!msgsEl) return;
3168 const div = document.createElement('div');
3169 div.className = 'ac-msg ' + role;
3170 div.textContent = text;
3171 msgsEl.appendChild(div);
3172 msgsEl.scrollTop = msgsEl.scrollHeight;
3173 return div;
3174}}
3176function acSend() {{
3177 const input = document.getElementById('ac-input');
3178 if(!input) return;
3179 const text = input.value.trim();
3180 if(!text) return;
3181 input.value = '';
3183 acAddMsg('user', text);
3185 // Show typing indicator
3186 const typing = acAddMsg('assistant', 'Thinking...');
3187 typing.classList.add('typing');
3189 // Check local commands first
3190 const lower = text.toLowerCase();
3191 if(lower.startsWith('open ')) {{
3192 const target = lower.replace('open ','').trim();
3193 const match = Object.entries(MANIFEST).find(([k,v])=>
3194 v.title.toLowerCase().includes(target)||k.includes(target));
3195 if(match) {{
3196 openPanel(match[0]);
3197 typing.textContent = 'Opened ' + match[1].title;
3198 typing.classList.remove('typing');
3199 return;
3200 }}
3201 }}
3202 if(lower.includes('theme')||lower.includes('font')||lower.includes('bigger')||
3203 lower.includes('smaller')||lower.includes('dark')||lower.includes('light')) {{
3204 const fakeResp = {{set textContent(v){{typing.textContent=v;typing.classList.remove('typing')}}}};
3205 handleThemeCommand(lower, fakeResp);
3206 return;
3207 }}
3209 // Send to backend
3210 fetch(SHELL+'/api/agent/ask',{{method:'POST',headers:{{'Content-Type':'application/json'}},
3211 body:JSON.stringify({{text:text,capability:acActiveCap}})}})
3212 .then(function(r){{return r.json()}}).then(function(data){{
3213 const reply = data.response || data.error || 'No response';
3214 typing.textContent = reply;
3215 typing.classList.remove('typing');
3216 speakText(reply, 'chat_response');
3217 }}).catch(function(){{
3218 typing.textContent = 'Could not reach agent';
3219 typing.classList.remove('typing');
3220 }});
3221}}
3223function acVoiceInput() {{
3224 toggleVoice();
3225}}
3227// Init on load
3228setTimeout(initAssistantChat, 500);
3230function handleThemeCommand(text, resp) {{
3231 let customization = {{}};
3232 if(text.includes('bigger')||text.includes('larger')) customization = {{font:{{size:16,heading_size:22}}}};
3233 else if(text.includes('smaller')) customization = {{font:{{size:12,heading_size:16}}}};
3234 else if(text.includes('dark')) {{ applyPreset('hart-default',resp); return; }}
3235 else if(text.includes('light')||text.includes('arctic')) {{ applyPreset('arctic',resp); return; }}
3236 else if(text.includes('cyberpunk')) {{ applyPreset('cyberpunk',resp); return; }}
3237 else if(text.includes('midnight')) {{ applyPreset('midnight',resp); return; }}
3238 else if(text.includes('forest')) {{ applyPreset('forest',resp); return; }}
3239 else if(text.includes('sunset')||text.includes('warm')) {{ applyPreset('sunset',resp); return; }}
3240 else if(text.includes('minimal')) {{ applyPreset('minimal',resp); return; }}
3241 else if(text.includes('potato')||text.includes('ultra')||text.includes('lite')||text.includes('performance')||text.includes('fast')) {{ applyPreset('potato',resp); return; }}
3242 else {{ resp.textContent='Try: dark, light, cyberpunk, midnight, forest, sunset, potato, bigger, smaller'; return; }}
3244 fetch(BACKEND+'/api/social/theme/customize',{{method:'POST',
3245 headers:{{'Content-Type':'application/json'}},body:JSON.stringify(customization)}})
3246 .then(r=>r.json()).then(()=>{{
3247 resp.textContent='Done! Refreshing...';
3248 setTimeout(()=>location.reload(), 500);
3249 }}).catch(()=>{{ resp.textContent='Failed to customize'; }});
3250}}
3252function applyPreset(id, resp) {{
3253 fetch(BACKEND+'/api/social/theme/apply',{{method:'POST',
3254 headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{theme_id:id}})}})
3255 .then(r=>r.json()).then(()=>{{
3256 resp.textContent='Applied '+id+'! Refreshing...';
3257 setTimeout(()=>location.reload(), 500);
3258 }}).catch(()=>{{ resp.textContent='Failed to apply theme'; }});
3259}}
3261// ═══ Context Menu ═══
3262document.addEventListener('contextmenu', e => {{
3263 e.preventDefault();
3264 const menu = document.getElementById('ctx-menu');
3265 // Desktop right-click
3266 if(e.target.classList.contains('wallpaper')||e.target===document.body) {{
3267 menu.innerHTML = [
3268 ctxItem('palette','Appearance','openPanel("appearance")'),
3269 ctxItem('wallpaper','Wallpaper','openPanel("appearance")'),
3270 ctxSep(),
3271 ctxItem('terminal','Terminal','launchApp("terminal")'),
3272 ctxItem('refresh','Refresh','location.reload()'),
3273 ].join('');
3274 }} else {{
3275 menu.innerHTML = [
3276 ctxItem('open_in_new','Open in New Panel',''),
3277 ctxItem('info','Properties',''),
3278 ].join('');
3279 }}
3280 menu.style.left = e.clientX+'px';
3281 menu.style.top = e.clientY+'px';
3282 menu.style.display = 'block';
3283}});
3284document.addEventListener('click', ()=>{{document.getElementById('ctx-menu').style.display='none';}});
3286function ctxItem(icon,label,action) {{
3287 return '<div class="ctx-menu-item" onclick="'+action+';document.getElementById(\'ctx-menu\').style.display=\'none\'">'+
3288 '<span class="mi material-icons-round">'+icon+'</span>'+label+'</div>';
3289}}
3290function ctxSep() {{ return '<div class="ctx-menu-sep"></div>'; }}
3292// ═══ Keyboard Shortcuts ═══
3293document.addEventListener('keydown', e => {{
3294 // Super key (Meta) — toggle start menu
3295 if(e.key==='Meta'&&!e.ctrlKey&&!e.altKey) {{ e.preventDefault(); toggleStartMenu(); }}
3296 // Alt+F4 — close focused panel
3297 if(e.key==='F4'&&e.altKey&&focusedPanel) {{ e.preventDefault(); closePanel(focusedPanel); }}
3298 // Alt+Tab — cycle through panels
3299 if(e.key==='Tab'&&e.altKey) {{
3300 e.preventDefault();
3301 const ids = Object.keys(panels);
3302 if(ids.length<2) return;
3303 const idx = (ids.indexOf(focusedPanel)+1)%ids.length;
3304 bringToFront(ids[idx]);
3305 }}
3306 // Super+D — show desktop (minimize all)
3307 if(e.key==='d'&&e.metaKey) {{ e.preventDefault(); Object.keys(panels).forEach(minimizePanel); }}
3308 // Super+L — lock
3309 if(e.key==='l'&&e.metaKey) {{ e.preventDefault(); shellAction('lock'); }}
3310 // Super+E — files
3311 if(e.key==='e'&&e.metaKey) {{ e.preventDefault(); openPanel('backup'); }}
3312 // Super+A — agent
3313 if(e.key==='a'&&e.metaKey) {{ e.preventDefault(); focusAgent(); }}
3314 // Super+Left/Right — snap panel
3315 if(e.key==='ArrowLeft'&&e.metaKey&&focusedPanel) {{ e.preventDefault(); snapPanel(focusedPanel,'left'); }}
3316 if(e.key==='ArrowRight'&&e.metaKey&&focusedPanel) {{ e.preventDefault(); snapPanel(focusedPanel,'right'); }}
3317 // Super+Up — maximize, Super+Down — minimize
3318 if(e.key==='ArrowUp'&&e.metaKey&&focusedPanel) {{ e.preventDefault(); toggleMax(focusedPanel); }}
3319 if(e.key==='ArrowDown'&&e.metaKey&&focusedPanel) {{ e.preventDefault(); minimizePanel(focusedPanel); }}
3320 // Escape — close start menu
3321 if(e.key==='Escape'&&startOpen) toggleStartMenu();
3322 // F11 — fullscreen focused
3323 if(e.key==='F11'&&focusedPanel) {{ e.preventDefault(); toggleMax(focusedPanel); }}
3324}});
3326// ═══ Shell Actions ═══
3327function shellAction(action) {{
3328 if(action==='lock') {{
3329 document.getElementById('lock-screen').classList.add('active');
3330 document.getElementById('lock-pw').focus();
3331 return;
3332 }}
3333 const labels = {{suspend:'put the system to sleep',restart:'restart the system',shutdown:'shut down the system'}};
3334 dsConfirm(action.charAt(0).toUpperCase()+action.slice(1),
3335 'Are you sure you want to '+(labels[action]||action)+'?',
3336 {{okLabel:action.charAt(0).toUpperCase()+action.slice(1), danger:action==='shutdown'}}).then(function(ok){{
3337 if(ok) fetch(SHELL+'/api/shell/session/'+action,{{method:'POST'}}).catch(()=>{{}});
3338 }});
3339}}
3340function unlock() {{
3341 // In production: PAM verification. Dev mode: any password works.
3342 document.getElementById('lock-screen').classList.remove('active');
3343}}
3345// ═══ App Launch ═══
3346function launchApp(appId) {{
3347 fetch(SHELL+'/api/shell/launch',{{method:'POST',
3348 headers:{{'Content-Type':'application/json'}},
3349 body:JSON.stringify({{app_id:appId,subsystem:'linux'}})}}).catch(()=>{{}});
3350}}
3352// ═══ Close start menu on outside click ═══
3353document.addEventListener('click', e => {{
3354 if(startOpen && !document.getElementById('start-menu').contains(e.target) &&
3355 !e.target.closest('.start-btn')) {{
3356 toggleStartMenu();
3357 }}
3358}});
3360// ═══ Voice I/O (push-to-talk + TTS) ═══
3361let mediaRecorder = null;
3362let audioChunks = [];
3363let isRecording = false;
3365function toggleVoice() {{
3366 if(isRecording) {{ stopRecording(); return; }}
3367 startRecording();
3368}}
3370async function startRecording() {{
3371 try {{
3372 const stream = await navigator.mediaDevices.getUserMedia({{audio:true}});
3373 const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : '';
3374 mediaRecorder = mimeType ? new MediaRecorder(stream,{{mimeType}}) : new MediaRecorder(stream);
3375 audioChunks = [];
3376 mediaRecorder.ondataavailable = function(e) {{ audioChunks.push(e.data); }};
3377 mediaRecorder.onstop = async function() {{
3378 stream.getTracks().forEach(function(t){{t.stop();}});
3379 const blob = new Blob(audioChunks, {{type: mediaRecorder.mimeType || 'audio/webm'}});
3380 const formData = new FormData();
3381 formData.append('audio', blob, 'voice.webm');
3382 const resp = document.getElementById('agent-resp');
3383 resp.textContent = 'Transcribing...';
3384 resp.classList.add('visible');
3385 try {{
3386 const r = await fetch(SHELL+'/api/voice', {{method:'POST', body:formData}});
3387 const data = await r.json();
3388 if(data.text) {{
3389 document.getElementById('agent-input').value = data.text;
3390 askAgent();
3391 }} else if(data.error) {{
3392 resp.textContent = data.error;
3393 }}
3394 }} catch(err) {{ resp.textContent = 'Voice processing failed'; }}
3395 }};
3396 mediaRecorder.start();
3397 isRecording = true;
3398 document.querySelector('.mic-btn').classList.add('recording');
3399 showToast('Voice','Recording... click mic again to stop','info');
3400 }} catch(err) {{
3401 showToast('Voice','Microphone access denied','warning');
3402 }}
3403}}
3405function stopRecording() {{
3406 if(mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
3407 isRecording = false;
3408 const btn = document.querySelector('.mic-btn');
3409 if(btn) btn.classList.remove('recording');
3410}}
3412// TTS helper — hybrid: browser instant + server quality
3413function speakText(text, source) {{
3414 if(!text || PERF.potato) return;
3415 source = source || 'chat_response';
3416 // 1. Browser instant feedback (Web Speech API)
3417 if('speechSynthesis' in window) {{
3418 const utt = new SpeechSynthesisUtterance(text);
3419 utt.rate = 1.0; utt.pitch = 1.0;
3420 speechSynthesis.speak(utt);
3421 }}
3422 // 2. Server quality audio (async, replaces browser TTS when ready)
3423 fetch(SHELL+'/api/voice/speak', {{
3424 method:'POST',
3425 headers:{{'Content-Type':'application/json'}},
3426 body:JSON.stringify({{text:text, source:source}})
3427 }}).then(function(r){{ return r.json(); }}).then(function(d){{
3428 if(d.audio_url && !d.error) {{
3429 if('speechSynthesis' in window) speechSynthesis.cancel();
3430 var a = new Audio(SHELL+d.audio_url);
3431 a.play().catch(function(){{}});
3432 }}
3433 }}).catch(function(){{}});
3434}}
3436// ═══ SSE Live Agent Action Stream ═══
3437// Renders ALL agent components as floating overlay fragments in real-time.
3438// Notification = toast. Everything else = floating glass panel overlay.
3439if(!PERF.potato) {{
3440 try {{
3441 const evtSrc = new EventSource(SHELL+'/api/notifications/stream');
3442 evtSrc.onmessage = function(e) {{
3443 try {{
3444 const events = JSON.parse(e.data);
3445 events.forEach(function(ev) {{
3446 const type = ev.type || 'notification';
3447 if(type === 'notification') {{
3448 showToast(ev.title||ev.agent||'Notification', ev.message||'', ev.severity||'info');
3449 }} else {{
3450 // Render as floating overlay fragment
3451 renderAgentOverlay(ev);
3452 }}
3453 }});
3454 }} catch(err) {{}}
3455 }};
3456 evtSrc.onerror = function() {{ /* SSE reconnects automatically */ }};
3457 }} catch(err) {{}}
3458}}
3460// ═══ Approval Helper ═══
3461function _postApproval(agentId, action, decision) {{
3462 try {{
3463 fetch(SHELL+'/api/agent/approval', {{
3464 method:'POST',
3465 headers:{{'Content-Type':'application/json'}},
3466 body:JSON.stringify({{agent_id:agentId, action:action, decision:decision}})
3467 }}).catch(function(){{}});
3468 }} catch(e) {{}}
3469}}
3471// ═══ Agent Action Floating Overlay Renderer ═══
3472var _overlayStack = [];
3473// HTML escape — prevents XSS from agent-pushed content
3474function _esc(s){{if(!s)return'';var d=document.createElement('div');d.textContent=String(s);return d.innerHTML;}}
3476function renderAgentOverlay(ev) {{
3477 // Sanitize all string fields to prevent XSS injection
3478 var _orig = ev;
3479 ev = {{}};
3480 for(var k in _orig) {{ ev[k] = (typeof _orig[k] === 'string') ? _esc(_orig[k]) : _orig[k]; }}
3481 // Preserve arrays/objects that need special handling
3482 if(_orig.items) ev.items = _orig.items;
3483 if(_orig.apps) ev.apps = _orig.apps;
3484 if(_orig.steps) ev.steps = _orig.steps;
3485 if(_orig.fields) ev.fields = _orig.fields;
3486 if(_orig.data) ev.data = _orig.data;
3487 if(_orig.labels) ev.labels = _orig.labels;
3488 if(_orig.children) ev.children = _orig.children;
3489 var id = 'overlay-'+(ev.agent||'')+(ev._ts||Date.now());
3490 // Remove oldest if > 3 overlays
3491 while(_overlayStack.length >= 3) {{
3492 var oldest = _overlayStack.shift();
3493 var el = document.getElementById(oldest);
3494 if(el) el.remove();
3495 }}
3496 var overlay = document.createElement('div');
3497 overlay.id = id;
3498 overlay.className = 'agent-overlay glass ds-fade-in';
3499 overlay.style.cssText = 'position:fixed;bottom:'+(80+_overlayStack.length*220)+'px;right:16px;z-index:'+(2000+_overlayStack.length)+';width:360px;max-height:200px;overflow-y:auto;border-radius:16px;padding:16px;backdrop-filter:blur(20px);background:rgba(20,20,30,0.85);border:1px solid rgba(255,255,255,0.08);box-shadow:0 8px 32px rgba(0,0,0,0.4);animation:dsSlideUp 0.3s ease;';
3500 var html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"><span class="ds-label-sm ds-text-accent">'+(ev.agent||'Agent')+'</span><span class="mi material-icons-round" style="cursor:pointer;font-size:16px;color:var(--hart-muted)" onclick="this.parentElement.parentElement.remove()">close</span></div>';
3501 var type = ev.type||'card';
3503 if(type === 'product_card') {{
3504 html += '<div style="display:flex;gap:12px">';
3505 if(ev.image) html += '<img src="'+ev.image+'" style="width:64px;height:64px;border-radius:8px;object-fit:cover">';
3506 html += '<div><div class="ds-body-md" style="font-weight:600">'+(ev.name||'Product')+'</div>';
3507 html += '<div class="ds-body-sm ds-text-muted">'+(ev.description||'').substring(0,100)+'</div>';
3508 html += '<div style="margin-top:4px"><span class="ds-label-sm ds-text-accent">'+(ev.price||'Free')+'</span>';
3509 if(ev.rating) html += ' <span class="ds-label-sm ds-text-muted">★ '+ev.rating+'</span>';
3510 html += '</div></div></div>';
3511 if(ev.buy_action) html += '<div style="margin-top:8px;text-align:right">'+dsBtn('Buy',{{variant:'primary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'"+ev.buy_action+"',{{method:'POST'}})"}})+'</div>';
3513 }} else if(type === 'cart') {{
3514 html += '<div class="ds-body-md" style="font-weight:600">🛒 Cart ('+(ev.items||[]).length+' items)</div>';
3515 (ev.items||[]).forEach(function(item){{
3516 html += '<div class="ds-list-item" style="padding:4px 0"><span class="ds-body-sm">'+_esc(item.name)+'</span><span class="ds-label-sm ds-text-accent" style="margin-left:auto">'+_esc(item.price)+'</span></div>';
3517 }});
3518 html += '<div style="border-top:1px solid rgba(255,255,255,0.1);margin-top:8px;padding-top:8px;text-align:right"><span class="ds-body-md ds-text-accent">Total: '+(ev.total||0)+' '+(ev.currency||'Spark')+'</span></div>';
3520 }} else if(type === 'checkout') {{
3521 html += '<div class="ds-body-md" style="font-weight:600">Checkout</div>';
3522 html += '<div class="ds-body-sm ds-text-muted">'+(ev.items||[]).length+' items — '+(ev.total||0)+' '+(ev.currency||'Spark')+'</div>';
3523 if(ev.confirm_action) html += '<div style="margin-top:8px;text-align:right">'+dsBtn('Confirm Payment',{{variant:'primary',cls:'ds-btn-sm',onclick:"fetch(SHELL+'"+ev.confirm_action+"',{{method:'POST'}})"}})+'</div>';
3525 }} else if(type === 'payment_status') {{
3526 var statusIcon = ev.status==='success'?'check_circle':ev.status==='pending'?'hourglass_empty':'error';
3527 var statusColor = ev.status==='success'?'var(--hart-success)':ev.status==='pending'?'var(--hart-accent)':'var(--hart-error)';
3528 html += '<div style="text-align:center;padding:8px"><span class="mi material-icons-round" style="font-size:40px;color:'+statusColor+'">'+statusIcon+'</span><div class="ds-body-md" style="margin-top:8px">'+(ev.status||'unknown').toUpperCase()+'</div><div class="ds-body-sm ds-text-muted">'+(ev.amount||'')+' via '+(ev.method||'')+'</div></div>';
3530 }} else if(type === 'order_tracking') {{
3531 html += '<div class="ds-body-md" style="font-weight:600">Order '+(ev.order_id||'')+'</div>';
3532 (ev.steps||[]).forEach(function(step,i){{
3533 var done = i < (ev.current_step||0);
3534 html += '<div style="display:flex;align-items:center;gap:8px;padding:2px 0"><span class="mi material-icons-round" style="font-size:16px;color:'+(done?'var(--hart-success)':'var(--hart-muted)')+'">'+(done?'check_circle':'radio_button_unchecked')+'</span><span class="ds-body-sm">'+(step.label||step)+'</span></div>';
3535 }});
3536 if(ev.eta) html += '<div class="ds-label-sm ds-text-muted" style="margin-top:4px">ETA: '+ev.eta+'</div>';
3538 }} else if(type === 'comparison') {{
3539 html += '<div class="ds-body-md" style="font-weight:600">Feature Comparison</div>';
3540 (ev.apps||[]).forEach(function(a){{
3541 html += '<div class="ds-list-item" style="padding:4px 0"><span class="ds-body-sm" style="font-weight:600">'+a.name+'</span><span class="ds-label-sm ds-text-muted" style="margin-left:auto">★ '+(a.rating||'-')+'</span></div>';
3542 }});
3543 if(ev.winner) html += '<div class="ds-label-sm ds-text-accent" style="margin-top:4px">Winner: '+ev.winner+'</div>';
3545 }} else if(type === 'progress') {{
3546 var pct = Math.min(100, Math.max(0, (ev.value||0)/(ev.max||100)*100));
3547 html += '<div class="ds-body-sm">'+(ev.label||'Progress')+'</div>';
3548 html += '<div style="background:#1a1a1a;border-radius:4px;height:8px;margin-top:4px"><div style="width:'+pct+'%;height:100%;border-radius:4px;background:'+(ev.color||'var(--hart-accent)')+';transition:width 0.3s"></div></div>';
3549 html += '<div class="ds-label-sm ds-text-muted" style="margin-top:2px">'+Math.round(pct)+'%</div>';
3551 }} else if(type === 'agent_action') {{
3552 var actionIcon = ev.status==='completed'?'check_circle':ev.status==='error'?'error':'play_circle';
3553 html += '<div style="display:flex;align-items:center;gap:8px"><span class="mi material-icons-round" style="font-size:20px">'+actionIcon+'</span><div><div class="ds-body-sm">'+(ev.description||ev.action_type||'Action')+'</div><div class="ds-label-sm ds-text-muted">'+(ev.status||'running')+'</div></div></div>';
3554 if(ev.result) html += '<div class="ds-body-sm ds-text-muted" style="margin-top:4px;font-style:italic">'+String(ev.result).substring(0,150)+'</div>';
3556 }} else if(type === 'chart') {{
3557 html += '<div class="ds-body-sm" style="font-weight:600">'+(ev.title||'Chart')+'</div>';
3558 var chartData = ev.data||[];
3559 var chartLabels = ev.labels||[];
3560 var chartType = ev.chart_type||'bar';
3561 var maxVal = Math.max.apply(null, chartData.length?chartData:[1]);
3562 if(chartType === 'bar') {{
3563 html += '<div style="display:flex;align-items:flex-end;gap:4px;height:100px;margin-top:8px;padding-top:4px;border-bottom:1px solid rgba(255,255,255,0.1)">';
3564 chartData.forEach(function(v,i){{
3565 var h = Math.max(4, (v/maxVal)*90);
3566 html += '<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:2px">';
3567 html += '<span class="ds-label-sm" style="font-size:9px;color:var(--hart-accent)">'+v+'</span>';
3568 html += '<div style="width:100%;height:'+h+'px;background:var(--hart-accent);border-radius:3px 3px 0 0;min-width:12px"></div>';
3569 html += '</div>';
3570 }});
3571 html += '</div>';
3572 if(chartLabels.length) {{
3573 html += '<div style="display:flex;gap:4px;margin-top:2px">';
3574 chartLabels.forEach(function(l){{ html += '<span class="ds-label-sm ds-text-muted" style="flex:1;text-align:center;font-size:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+l+'</span>'; }});
3575 html += '</div>';
3576 }}
3577 }} else {{
3578 // Line chart: SVG polyline
3579 var w = 320, h = 80;
3580 var pts = chartData.map(function(v,i){{ return ((i/(Math.max(1,chartData.length-1)))*w)+','+(h - (v/maxVal)*h); }}).join(' ');
3581 html += '<svg width="'+w+'" height="'+(h+10)+'" style="margin-top:8px"><polyline points="'+pts+'" fill="none" stroke="var(--hart-accent)" stroke-width="2" stroke-linejoin="round"/>';
3582 chartData.forEach(function(v,i){{
3583 var cx = (i/(Math.max(1,chartData.length-1)))*w;
3584 var cy = h - (v/maxVal)*h;
3585 html += '<circle cx="'+cx+'" cy="'+cy+'" r="3" fill="var(--hart-accent)"/>';
3586 }});
3587 html += '</svg>';
3588 if(chartLabels.length) {{
3589 html += '<div style="display:flex;justify-content:space-between;margin-top:2px">';
3590 chartLabels.forEach(function(l){{ html += '<span class="ds-label-sm ds-text-muted" style="font-size:8px">'+l+'</span>'; }});
3591 html += '</div>';
3592 }}
3593 }}
3595 }} else if(type === 'code') {{
3596 html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">';
3597 if(ev.filename) html += '<span class="ds-label-sm ds-text-muted" style="font-family:monospace">'+(ev.filename)+'</span>';
3598 if(ev.language) html += '<span class="ds-label-sm" style="color:var(--hart-accent);font-size:9px;text-transform:uppercase">'+(ev.language)+'</span>';
3599 html += '</div>';
3600 html += '<pre style="margin:0;padding:10px;background:rgba(0,0,0,0.5);border-radius:8px;overflow-x:auto;font-family:\'Fira Code\',\'Cascadia Code\',monospace;font-size:12px;line-height:1.4;color:#e0e0e0;white-space:pre-wrap;word-break:break-all"><code>'+(ev.content||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+'</code></pre>';
3602 }} else if(type === 'markdown') {{
3603 var md = ev.content||'';
3604 // Basic markdown→HTML: bold, italic, links, inline code, headers, lists
3605 md = md.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
3606 md = md.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
3607 md = md.replace(/\*(.+?)\*/g,'<em>$1</em>');
3608 md = md.replace(/`([^`]+)`/g,'<code style="background:rgba(255,255,255,0.08);padding:1px 4px;border-radius:3px;font-family:monospace;font-size:0.9em">$1</code>');
3609 md = md.replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" target="_blank" style="color:var(--hart-accent);text-decoration:underline">$1</a>');
3610 md = md.replace(/^### (.+)$/gm,'<div class="ds-body-md" style="font-weight:700;margin-top:6px">$1</div>');
3611 md = md.replace(/^## (.+)$/gm,'<div class="ds-body-md" style="font-weight:700;font-size:1.1em;margin-top:6px">$1</div>');
3612 md = md.replace(/^# (.+)$/gm,'<div class="ds-body-lg" style="font-weight:700;margin-top:6px">$1</div>');
3613 md = md.replace(/^[-*] (.+)$/gm,'<div style="padding-left:12px">• $1</div>');
3614 md = md.replace(/^\d+\. (.+)$/gm,function(m,p1){{ return '<div style="padding-left:12px">'+m.split('.')[0]+'. '+p1+'</div>'; }});
3615 md = md.replace(/\n/g,'<br>');
3616 html += '<div class="ds-body-sm" style="line-height:1.5">'+md+'</div>';
3618 }} else if(type === 'media') {{
3619 var mediaType = ev.media_type||ev.type||'image';
3620 var src = ev.src||ev.url||'';
3621 var alt = ev.alt||'Media';
3622 if(ev.title) html += '<div class="ds-body-sm" style="font-weight:600;margin-bottom:4px">'+(ev.title)+'</div>';
3623 if(mediaType === 'video' || src.match(/\.(mp4|webm|ogg)($|\?)/i)) {{
3624 html += '<video src="'+src+'" '+(ev.controls!==false?'controls':'')+' style="width:100%;border-radius:8px;max-height:160px" preload="metadata">'+alt+'</video>';
3625 }} else if(mediaType === 'audio' || src.match(/\.(mp3|wav|ogg|aac)($|\?)/i)) {{
3626 html += '<audio src="'+src+'" '+(ev.controls!==false?'controls':'')+' style="width:100%">'+alt+'</audio>';
3627 }} else {{
3628 html += '<img src="'+src+'" alt="'+alt+'" style="width:100%;border-radius:8px;max-height:160px;object-fit:cover" onerror="this.style.display=\'none\'">';
3629 }}
3630 if(ev.caption) html += '<div class="ds-label-sm ds-text-muted" style="margin-top:4px">'+(ev.caption)+'</div>';
3632 }} else if(type === 'metric') {{
3633 var trend = ev.trend||'flat';
3634 var arrow = trend==='up'?'\\u2191':trend==='down'?'\\u2193':'\\u2192';
3635 var tColor = trend==='up'?'var(--hart-success)':trend==='down'?'var(--hart-error)':'var(--hart-muted)';
3636 html += '<div style="text-align:center;padding:8px 0">';
3637 html += '<div style="font-size:32px;font-weight:700;color:var(--hart-text)">'+(ev.value||0)+'<span class="ds-label-sm" style="font-size:14px;margin-left:4px;color:var(--hart-muted)">'+(ev.unit||'')+'</span></div>';
3638 html += '<div class="ds-body-sm" style="margin-top:2px">'+(ev.label||'Metric')+' <span style="color:'+tColor+';font-weight:600">'+arrow+'</span></div>';
3639 if(ev.explanation) html += '<div class="ds-label-sm ds-text-muted" style="margin-top:4px">'+(ev.explanation)+'</div>';
3640 html += '</div>';
3642 }} else if(type === 'form') {{
3643 html += '<div class="ds-body-md" style="font-weight:600;margin-bottom:8px">'+(ev.title||'Form')+'</div>';
3644 var formId = 'form-'+(ev._ts||Date.now());
3645 html += '<form id="'+formId+'" style="display:flex;flex-direction:column;gap:6px" onsubmit="event.preventDefault();var fd={{}};new FormData(this).forEach(function(v,k){{fd[k]=v}});fetch(SHELL+\''+(ev.action||'/api/a2ui')+'\',{{method:\'POST\',headers:{{\'Content-Type\':\'application/json\'}},body:JSON.stringify(fd)}}).catch(function(){{}});return false;">';
3646 (ev.fields||[]).forEach(function(f){{
3647 var ftype = f.type||'text';
3648 var fname = f.name||f.label||'field';
3649 html += '<div>';
3650 if(f.label) html += '<label class="ds-label-sm" style="display:block;margin-bottom:2px;color:var(--hart-muted)">'+f.label+'</label>';
3651 if(ftype === 'textarea') {{
3652 html += '<textarea name="'+fname+'" placeholder="'+(f.placeholder||'')+'" style="width:100%;padding:6px 8px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:var(--hart-text);font-size:13px;resize:vertical;min-height:40px">'+(f.value||'')+'</textarea>';
3653 }} else if(ftype === 'select') {{
3654 html += '<select name="'+fname+'" style="width:100%;padding:6px 8px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:var(--hart-text);font-size:13px">';
3655 (f.options||[]).forEach(function(o){{ html += '<option value="'+(o.value||o)+'">'+(o.label||o)+'</option>'; }});
3656 html += '</select>';
3657 }} else {{
3658 html += '<input type="'+ftype+'" name="'+fname+'" placeholder="'+(f.placeholder||'')+'" value="'+(f.value||'')+'" style="width:100%;padding:6px 8px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;color:var(--hart-text);font-size:13px">';
3659 }}
3660 html += '</div>';
3661 }});
3662 html += '<div style="text-align:right;margin-top:4px">'+dsBtn(ev.submit_label||'Submit',{{variant:'primary',cls:'ds-btn-sm'}})+'</div></form>';
3664 }} else if(type === 'list') {{
3665 html += '<div class="ds-body-md" style="font-weight:600;margin-bottom:4px">'+(ev.title||'List')+'</div>';
3666 var ordered = ev.ordered||false;
3667 var tag = ordered?'ol':'ul';
3668 html += '<'+tag+' style="margin:0;padding-left:18px;color:var(--hart-text)">';
3669 (ev.items||[]).forEach(function(item,i){{
3670 var text = typeof item === 'string' ? item : (item.label||item.text||item.name||JSON.stringify(item));
3671 var action = typeof item === 'object' ? item.action : null;
3672 if(action || ev.interactive) {{
3673 html += '<li style="padding:2px 0;cursor:pointer;color:var(--hart-accent)" onclick="try{{fetch(SHELL+\''+( action||'/api/a2ui')+'\',{{method:\'POST\',headers:{{\'Content-Type\':\'application/json\'}},body:JSON.stringify({{selected:'+i+',item:\''+text.replace(/'/g,'\\\\\\\'')+'\'}})}})}}catch(e){{}}">'+(text)+'</li>';
3674 }} else {{
3675 html += '<li style="padding:2px 0">'+(text)+'</li>';
3676 }}
3677 }});
3678 html += '</'+tag+'>';
3680 }} else if(type === 'approval') {{
3681 html += '<div class="ds-body-md" style="font-weight:600;margin-bottom:4px">Approval Required</div>';
3682 html += '<div class="ds-body-sm ds-text-muted" style="margin-bottom:8px">'+(ev.description||ev.action||'An agent requests your approval.')+'</div>';
3683 if(ev.agent_id) html += '<div class="ds-label-sm ds-text-muted" style="margin-bottom:6px">Agent: '+(ev.agent_id)+'</div>';
3684 html += '<div style="display:flex;gap:6px;justify-content:flex-end">';
3685 html += '<button class="ds-btn ds-btn-primary ds-btn-sm" onclick="dsRipple(event);_postApproval(\''+((ev.agent_id||'').replace(/'/g,''))+'\',\''+((ev.action||'').replace(/'/g,''))+'\',\'approve\');this.closest(\'.agent-overlay\').remove()"><span>Approve</span></button>';
3686 html += '<button class="ds-btn ds-btn-outline ds-btn-sm" onclick="dsRipple(event);_postApproval(\''+((ev.agent_id||'').replace(/'/g,''))+'\',\''+((ev.action||'').replace(/'/g,''))+'\',\'deny\');this.closest(\'.agent-overlay\').remove()"><span>Deny</span></button>';
3687 html += '<button class="ds-btn ds-btn-ghost ds-btn-sm" onclick="dsRipple(event);this.closest(\'.agent-overlay\').remove()"><span>Later</span></button>';
3688 html += '</div>';
3690 }} else if(type === 'navigate') {{
3691 var target = ev.target||'';
3692 var transition = ev.transition||'default';
3693 // Only allow known panel IDs and safe internal /api/ routes — no external URLs
3694 if(MANIFEST[target] || SYSTEM_PANELS[target]) {{
3695 openPanel(target, ev.params||{{}});
3696 }} else if(target.indexOf('/api/') === 0 && target.indexOf('..') === -1) {{
3697 fetch(SHELL+target, {{method:'GET',signal:AbortSignal.timeout(5000)}}).catch(function(){{}});
3698 }}
3699 // External URLs and arbitrary paths are BLOCKED — prevents SSRF/open redirect
3700 // Minimal overlay confirmation
3701 html += '<div style="text-align:center;padding:8px"><span class="mi material-icons-round" style="font-size:24px;color:var(--hart-accent)">open_in_new</span><div class="ds-body-sm" style="margin-top:4px">Navigating to '+(ev.title||target||'...')+'</div></div>';
3703 }} else {{
3704 // Generic fallback
3705 html += '<div class="ds-body-md" style="font-weight:600">'+(ev.title||type)+'</div>';
3706 html += '<div class="ds-body-sm ds-text-muted">'+(ev.content||ev.message||JSON.stringify(ev).substring(0,200))+'</div>';
3707 }}
3709 overlay.innerHTML = html;
3710 document.body.appendChild(overlay);
3711 _overlayStack.push(id);
3713 // Auto-dismiss after 15s (except checkout/approval)
3714 if(type !== 'checkout' && type !== 'approval') {{
3715 setTimeout(function(){{
3716 var el = document.getElementById(id);
3717 if(el) {{ el.style.opacity='0'; el.style.transform='translateX(100px)'; setTimeout(function(){{el.remove()}},300); }}
3718 _overlayStack = _overlayStack.filter(function(x){{return x!==id}});
3719 }}, 15000);
3720 }}
3721}}
3723// ═══ Recent Files in Start Menu ═══
3724(function loadRecentFiles() {{
3725 fetch(SHELL+'/api/shell/files/recent',{{signal:AbortSignal.timeout(3000)}})
3726 .then(function(r){{return r.json();}}).then(function(data) {{
3727 const files = data.files || [];
3728 if(files.length === 0) return;
3729 const scroll = document.getElementById('start-scroll');
3730 if(!scroll) return;
3731 const section = document.createElement('div');
3732 section.className = 'start-group';
3733 section.innerHTML = '<div class="start-group-label">Recent Files</div><div class="start-grid">' +
3734 files.slice(0,8).map(function(f) {{
3735 return '<div class="start-item" onclick="launchApp(\'xdg-open\')">' +
3736 '<span class="mi material-icons-round" style="color:var(--hart-muted)">description</span>' +
3737 '<span class="label" title="'+f.path+'">'+f.name+'</span></div>';
3738 }}).join('') + '</div>';
3739 scroll.appendChild(section);
3740 }}).catch(function(){{}});
3741}})();
3743// ═══ Login Greeting ═══
3744(function loginGreeting() {{
3745 if(PERF.potato) return;
3746 Promise.all([
3747 fetch(BACKEND+'/api/social/dashboard/agents',{{signal:AbortSignal.timeout(3000)}}).then(function(r){{return r.json();}}).catch(function(){{return {{}}; }}),
3748 fetch(BACKEND+'/api/social/dashboard/health',{{signal:AbortSignal.timeout(3000)}}).then(function(r){{return r.json();}}).catch(function(){{return {{}}; }}),
3749 ]).then(function([agents,health]) {{
3750 const agentCount = (agents.agents||[]).filter(function(a){{return a.status==='running';}}).length;
3751 const peerCount = health.peer_count || 0;
3752 const hour = new Date().getHours();
3753 const greeting = hour<12?'Good morning':hour<17?'Good afternoon':'Good evening';
3754 const msg = greeting+'! '+agentCount+' agent'+(agentCount!==1?'s':'')+' running, '+peerCount+' peer'+(peerCount!==1?'s':'')+' connected.';
3755 showToast('HART', msg, 'info');
3756 setTimeout(function(){{ speakText(msg, 'greeting'); }}, 1000);
3757 }});
3758}})();
3759</script>
3760</body></html>'''
3762 def _render_component(self, comp: dict) -> str:
3763 """Render a single A2UI component to HTML snippet."""
3764 comp_type = comp.get('type', 'card')
3765 if comp_type == 'card':
3766 title = comp.get('title', '')
3767 content = comp.get('content', '')
3768 children_html = ''.join(
3769 self._render_component(c) for c in comp.get('children', []))
3770 return (f'<div class="card"><h3>{title}</h3>'
3771 f'<p>{content}</p>{children_html}</div>')
3772 elif comp_type == 'metric':
3773 return (
3774 f'<div class="metric">'
3775 f'<span>{comp.get("label", "")}</span>'
3776 f'<span style="font-weight:600">{comp.get("value", "")}'
3777 f'{comp.get("unit", "")}</span></div>')
3778 elif comp_type == 'notification':
3779 return (
3780 f'<div class="notification notification-{comp.get("severity", "info")}">'
3781 f'<strong>{comp.get("title", "")}</strong>: '
3782 f'{comp.get("message", "")}</div>')
3783 elif comp_type == 'list':
3784 items = ''.join(f'<li>{i}</li>' for i in comp.get('items', []))
3785 return f'<ul>{items}</ul>'
3786 elif comp_type == 'markdown':
3787 return f'<div>{comp.get("content", "")}</div>'
3788 elif comp_type == 'approval':
3789 return (
3790 f'<div style="padding:12px;background:var(--hart-surface);'
3791 f'border-radius:8px;margin:8px 0">'
3792 f'<strong>Agent "{comp.get("agent_id", "?")}"</strong> '
3793 f'requests: {comp.get("action", "?")}<br>'
3794 f'{comp.get("description", "")}</div>')
3795 elif comp_type == 'progress':
3796 value = comp.get('value', 0)
3797 max_val = comp.get('max', 100)
3798 pct = int((value / max_val) * 100) if max_val else 0
3799 return (
3800 f'<div><label>{comp.get("label", "")}</label>'
3801 f'<div style="height:6px;background:var(--hart-surface);'
3802 f'border-radius:3px;overflow:hidden">'
3803 f'<div style="height:100%;width:{pct}%;'
3804 f'background:var(--hart-active);border-radius:3px"></div>'
3805 f'</div></div>')
3806 return f'<div>{json.dumps(comp)}</div>'
3808 # ─── HTTP Server (Glass Shell + Shell APIs) ───────────────
3810 def _create_flask_app(self):
3811 """Create Flask app serving the glass desktop shell + APIs."""
3812 from flask import Flask, request, jsonify, Response, send_from_directory
3814 app = Flask(__name__)
3816 # ── Desktop Shell (the root page IS the OS) ──
3817 @app.route('/')
3818 def index():
3819 return Response(self.render_desktop_shell(), mimetype='text/html')
3821 # ── Nunba SPA embedding (React pages inside panel iframes) ──
3822 nunba_dir = os.environ.get('NUNBA_STATIC_DIR', '')
3823 if nunba_dir and os.path.isdir(nunba_dir):
3824 @app.route('/app/<path:path>')
3825 def nunba_static(path):
3826 return send_from_directory(nunba_dir, path)
3828 @app.route('/app/')
3829 def nunba_index():
3830 return send_from_directory(nunba_dir, 'index.html')
3832 # ── Legacy API: UI components (for terminal/Conky fallback) ──
3833 @app.route('/api/ui', methods=['GET'])
3834 def api_ui():
3835 context = self.context_engine.get_context()
3836 ui = self.generate_ui(context)
3837 inner_html = ''.join(
3838 self._render_component(c) for c in ui.get('components', []))
3839 return jsonify({
3840 'source': ui.get('source'), 'html': inner_html,
3841 'context': ui.get('context_summary'),
3842 'component_count': len(ui.get('components', [])),
3843 })
3845 @app.route('/api/context', methods=['GET'])
3846 def api_context():
3847 return jsonify(self.context_engine.get_context())
3849 # ── A2UI (agent pushes UI components) ──
3850 @app.route('/api/a2ui', methods=['POST'])
3851 def api_a2ui():
3852 import time as _time
3853 data = request.get_json(force=True)
3854 comp = data.get('component', {})
3855 comp['_ts'] = _time.time()
3856 success = self.agent_ui_update(
3857 data.get('agent_id', 'unknown'), comp)
3858 return jsonify({'success': success})
3860 @app.route('/api/approval', methods=['POST'])
3861 def api_approval():
3862 data = request.get_json(force=True)
3863 result = self.agent_request_approval(
3864 data.get('agent_id', 'unknown'),
3865 data.get('action', 'unknown'),
3866 data.get('description', ''))
3867 return jsonify(result)
3869 @app.route('/api/agent/approval', methods=['POST'])
3870 def handle_agent_approval():
3871 """Handle approval decisions from Nunba JS or Android clients."""
3872 data = request.get_json(force=True)
3873 agent_id = data.get('agent_id', '')
3874 action = data.get('action', '')
3875 decision = data.get('decision', '') # approve / deny / later
3876 if decision not in ('approve', 'deny', 'later'):
3877 return jsonify({'error': 'Invalid decision, must be approve/deny/later'}), 400
3878 # Resolve matching pending approval in _agent_components
3879 resolved = False
3880 if agent_id in self._agent_components:
3881 for comp in self._agent_components[agent_id]:
3882 if (comp.get('type') == 'approval'
3883 and comp.get('action') == action
3884 and comp.get('_decision') is None):
3885 comp['_decision'] = decision
3886 comp['_decided_at'] = time.time()
3887 resolved = True
3888 break
3889 # Push decision via EventBus so other frontends can react
3890 try:
3891 from core.platform.events import emit_event
3892 emit_event('agent.approval.decision', {
3893 'agent_id': agent_id,
3894 'action': action,
3895 'decision': decision,
3896 })
3897 except Exception:
3898 pass
3899 logger.info("Approval decision: agent=%s action=%s decision=%s resolved=%s",
3900 agent_id, action, decision, resolved)
3901 return jsonify({
3902 'status': 'ok',
3903 'agent_id': agent_id,
3904 'action': action,
3905 'decision': decision,
3906 'resolved': resolved,
3907 })
3909 # ── Voice ──
3910 @app.route('/api/voice', methods=['POST'])
3911 def api_voice():
3912 audio = request.files.get('audio')
3913 if audio:
3914 import tempfile
3915 with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as f:
3916 audio.save(f)
3917 result = self.handle_voice_input(f.name)
3918 else:
3919 result = {'error': 'No audio provided'}
3920 return jsonify(result)
3922 # ── Theme hot-reload ──
3923 @app.route('/api/theme', methods=['POST'])
3924 def update_theme():
3925 # Called by ThemeService when theme changes
3926 return jsonify({'status': 'updated'})
3928 # ── Agent ambient input (text from agent pill) ──
3929 @app.route('/api/agent/ask', methods=['POST'])
3930 def agent_ask():
3931 data = request.get_json(force=True, silent=True) or {}
3932 text = data.get('text', '').strip()
3933 if not text:
3934 return jsonify({'error': 'No text provided'})
3935 import requests as req
3936 try:
3937 resp = req.post(
3938 f'http://localhost:{self.backend_port}/chat',
3939 json={
3940 'user_id': 'hart_desktop_user',
3941 'prompt_id': 'desktop_agent',
3942 'prompt': text,
3943 }, timeout=30)
3944 return jsonify(resp.json())
3945 except Exception as e:
3946 return jsonify({'error': str(e)})
3948 # ── Shell APIs: Events ──
3949 @app.route('/api/shell/events', methods=['GET'])
3950 def shell_events():
3951 events = []
3952 try:
3953 result = subprocess.run(
3954 ['journalctl', '--since', '1 hour ago', '-p', '0..5',
3955 '--no-pager', '-o', 'short', '-n', '50'],
3956 capture_output=True, text=True, timeout=5)
3957 for line in result.stdout.strip().split('\n'):
3958 if line.strip():
3959 parts = line.split(None, 3)
3960 events.append({
3961 'time': ' '.join(parts[:2]) if len(parts) > 2 else '',
3962 'message': parts[-1] if parts else line,
3963 })
3964 except Exception:
3965 events.append({
3966 'time': '', 'message': 'Event log not available'})
3967 return jsonify({'events': events})
3969 # ── Shell APIs: Apps ──
3970 @app.route('/api/shell/apps', methods=['GET'])
3971 def shell_apps():
3972 apps = []
3973 # Linux .desktop files
3974 app_dirs = ['/usr/share/applications',
3975 os.path.expanduser('~/.local/share/applications')]
3976 for d in app_dirs:
3977 if not os.path.isdir(d):
3978 continue
3979 try:
3980 for fname in os.listdir(d):
3981 if not fname.endswith('.desktop'):
3982 continue
3983 apps.append({
3984 'id': fname.replace('.desktop', ''),
3985 'name': fname.replace('.desktop', '').replace('-', ' ').title(),
3986 'subsystem': 'linux',
3987 })
3988 except OSError:
3989 pass
3990 return jsonify({'apps': apps[:100]})
3992 # ── Shell APIs: Launch ──
3993 @app.route('/api/shell/launch', methods=['POST'])
3994 def shell_launch():
3995 import re
3996 data = request.get_json(force=True, silent=True) or {}
3997 app_id = data.get('app_id', '')
3998 if not app_id or not re.match(r'^[a-zA-Z0-9._-]+$', app_id):
3999 return jsonify({'error': 'Invalid app_id'}), 400
4000 try:
4001 subprocess.Popen(
4002 ['gtk-launch', app_id],
4003 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
4004 return jsonify({'status': 'launched'})
4005 except Exception as e:
4006 return jsonify({'error': str(e)}), 500
4008 # ── Shell APIs: Session ──
4009 @app.route('/api/shell/session/<action>', methods=['POST'])
4010 def shell_session(action):
4011 import re
4012 if action not in ('lock', 'logout', 'suspend', 'shutdown', 'restart'):
4013 return jsonify({'error': 'Invalid action'}), 400
4014 cmds = {
4015 'lock': ['loginctl', 'lock-session'],
4016 'logout': ['loginctl', 'terminate-session', ''],
4017 'suspend': ['systemctl', 'suspend'],
4018 'shutdown': ['systemctl', 'poweroff'],
4019 'restart': ['systemctl', 'reboot'],
4020 }
4021 try:
4022 subprocess.Popen(
4023 cmds[action],
4024 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
4025 return jsonify({'status': action})
4026 except Exception as e:
4027 return jsonify({'error': str(e)}), 500
4029 # ── Shell APIs: Services ──
4030 @app.route('/api/shell/services', methods=['GET'])
4031 def shell_services():
4032 services = []
4033 svc_names = [
4034 'hart-backend', 'hart-agent-daemon', 'hart-vision',
4035 'hart-llm', 'hart-discovery', 'hart-liquid-ui', 'hart-conky']
4036 for name in svc_names:
4037 status = 'unknown'
4038 try:
4039 result = subprocess.run(
4040 ['systemctl', 'is-active', name],
4041 capture_output=True, text=True, timeout=3)
4042 status = result.stdout.strip()
4043 except Exception:
4044 pass
4045 services.append({'name': name, 'status': status})
4046 return jsonify({'services': services})
4048 # ── Shell APIs: Session state persistence ──
4049 @app.route('/api/shell/session-state', methods=['GET'])
4050 def get_session_state():
4051 path = os.path.join(self._data_dir, 'shell_session.json')
4052 if os.path.isfile(path):
4053 try:
4054 with open(path, 'r') as f:
4055 return jsonify(json.load(f))
4056 except Exception:
4057 pass
4058 return jsonify({})
4060 @app.route('/api/shell/session-state', methods=['POST'])
4061 def save_session_state():
4062 data = request.get_json(force=True, silent=True) or {}
4063 path = os.path.join(self._data_dir, 'shell_session.json')
4064 try:
4065 os.makedirs(os.path.dirname(path), exist_ok=True)
4066 with open(path, 'w') as f:
4067 json.dump(data, f)
4068 return jsonify({'status': 'saved'})
4069 except Exception as e:
4070 return jsonify({'error': str(e)}), 500
4072 # ── Shell APIs: Drivers ──
4073 @app.route('/api/shell/drivers', methods=['GET'])
4074 def shell_drivers():
4075 devices = []
4076 for cmd, dev_type in [(['lspci', '-mm'], 'pci'), (['lsusb'], 'usb')]:
4077 try:
4078 r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
4079 for line in r.stdout.strip().split('\n'):
4080 if line.strip():
4081 devices.append({'type': dev_type, 'info': line.strip()})
4082 except Exception:
4083 pass
4084 return jsonify({'devices': devices[:50]})
4086 # ── Shell APIs: WiFi ──
4087 @app.route('/api/shell/network/wifi', methods=['GET'])
4088 def shell_wifi():
4089 networks = []
4090 connected = {}
4091 try:
4092 r = subprocess.run(
4093 ['nmcli', '-t', '-f', 'SSID,SIGNAL,SECURITY,ACTIVE',
4094 'device', 'wifi', 'list'],
4095 capture_output=True, text=True, timeout=5)
4096 for line in r.stdout.strip().split('\n'):
4097 parts = line.split(':')
4098 if len(parts) >= 4 and parts[0]:
4099 net = {
4100 'ssid': parts[0],
4101 'signal': int(parts[1] or 0),
4102 'security': parts[2],
4103 'active': parts[3] == 'yes',
4104 }
4105 networks.append(net)
4106 if net['active']:
4107 connected = net
4108 except Exception:
4109 pass
4110 try:
4111 r = subprocess.run(
4112 ['hostname', '-I'],
4113 capture_output=True, text=True, timeout=3)
4114 if r.stdout.strip():
4115 connected['ip'] = r.stdout.strip().split()[0]
4116 except Exception:
4117 pass
4118 return jsonify({'networks': networks[:20], 'connected': connected})
4120 @app.route('/api/shell/network/wifi/connect', methods=['POST'])
4121 def shell_wifi_connect():
4122 data = request.get_json(silent=True) or {}
4123 ssid = data.get('ssid', '').strip()
4124 password = data.get('password', '')
4125 if not ssid:
4126 return jsonify({'success': False, 'error': 'SSID required'}), 400
4127 try:
4128 cmd = ['nmcli', 'device', 'wifi', 'connect', ssid]
4129 if password:
4130 cmd += ['password', password]
4131 r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
4132 if r.returncode == 0:
4133 return jsonify({'success': True, 'message': f'Connected to {ssid}'})
4134 return jsonify({'success': False, 'error': r.stderr.strip() or 'Connection failed'}), 400
4135 except subprocess.TimeoutExpired:
4136 return jsonify({'success': False, 'error': 'Connection timed out'}), 504
4137 except Exception as e:
4138 return jsonify({'success': False, 'error': str(e)}), 500
4140 @app.route('/api/shell/network/wifi/disconnect', methods=['POST'])
4141 def shell_wifi_disconnect():
4142 try:
4143 r = subprocess.run(
4144 ['nmcli', 'device', 'disconnect', 'wlan0'],
4145 capture_output=True, text=True, timeout=10)
4146 # Try common interface names if wlan0 fails
4147 if r.returncode != 0:
4148 r = subprocess.run(
4149 ['nmcli', 'device', 'disconnect', 'wlp0s20f3'],
4150 capture_output=True, text=True, timeout=10)
4151 if r.returncode == 0:
4152 return jsonify({'success': True, 'message': 'Disconnected from WiFi'})
4153 return jsonify({'success': False, 'error': r.stderr.strip() or 'Disconnect failed'}), 400
4154 except Exception as e:
4155 return jsonify({'success': False, 'error': str(e)}), 500
4157 @app.route('/api/shell/network/status', methods=['GET'])
4158 def shell_network_status():
4159 status = {'interfaces': [], 'dns': [], 'gateway': ''}
4160 try:
4161 r = subprocess.run(
4162 ['nmcli', '-t', '-f', 'DEVICE,TYPE,STATE,CONNECTION',
4163 'device', 'status'],
4164 capture_output=True, text=True, timeout=5)
4165 for line in r.stdout.strip().split('\n'):
4166 parts = line.split(':')
4167 if len(parts) >= 4:
4168 status['interfaces'].append({
4169 'device': parts[0], 'type': parts[1],
4170 'state': parts[2], 'connection': parts[3],
4171 })
4172 except Exception:
4173 pass
4174 try:
4175 r = subprocess.run(
4176 ['ip', 'route', 'show', 'default'],
4177 capture_output=True, text=True, timeout=3)
4178 parts = r.stdout.strip().split()
4179 if 'via' in parts:
4180 status['gateway'] = parts[parts.index('via') + 1]
4181 except Exception:
4182 pass
4183 try:
4184 r = subprocess.run(
4185 ['resolvectl', 'status', '--no-pager'],
4186 capture_output=True, text=True, timeout=3)
4187 for line in r.stdout.split('\n'):
4188 if 'DNS Servers' in line:
4189 status['dns'] = line.split(':',1)[1].strip().split()
4190 break
4191 except Exception:
4192 pass
4193 return jsonify(status)
4195 # ── Shell APIs: Audio ──
4196 def _parse_volume(vol_info):
4197 """Extract volume percentage from pactl volume info dict."""
4198 if isinstance(vol_info, dict):
4199 for ch in vol_info.values():
4200 if isinstance(ch, dict) and 'value_percent' in ch:
4201 return int(ch['value_percent'].rstrip('%'))
4202 if isinstance(ch, dict) and 'value' in ch:
4203 # value is 0-65536 scale
4204 return round(int(ch['value']) / 655.36)
4205 return 100
4207 @app.route('/api/shell/audio', methods=['GET'])
4208 def shell_audio():
4209 sinks = []
4210 sources = []
4211 default_sink = ''
4212 try:
4213 r = subprocess.run(
4214 ['pactl', 'get-default-sink'],
4215 capture_output=True, text=True, timeout=3)
4216 default_sink = r.stdout.strip()
4217 except Exception:
4218 pass
4219 try:
4220 r = subprocess.run(
4221 ['pactl', '--format=json', 'list', 'sinks'],
4222 capture_output=True, text=True, timeout=5)
4223 if r.stdout.strip():
4224 raw = json.loads(r.stdout)
4225 sinks = [{
4226 'id': s.get('name', ''),
4227 'name': s.get('description', ''),
4228 'mute': s.get('mute', False),
4229 'volume': _parse_volume(s.get('volume', {})),
4230 'default': s.get('name', '') == default_sink,
4231 } for s in raw]
4232 except Exception:
4233 pass
4234 try:
4235 r = subprocess.run(
4236 ['pactl', '--format=json', 'list', 'sources'],
4237 capture_output=True, text=True, timeout=5)
4238 if r.stdout.strip():
4239 raw = json.loads(r.stdout)
4240 sources = [{
4241 'id': s.get('name', ''),
4242 'name': s.get('description', ''),
4243 'volume': _parse_volume(s.get('volume', {})),
4244 } for s in raw]
4245 except Exception:
4246 pass
4247 return jsonify({'sinks': sinks, 'sources': sources})
4249 @app.route('/api/shell/audio/volume', methods=['POST'])
4250 def shell_audio_volume():
4251 data = request.get_json(silent=True) or {}
4252 sink_id = data.get('sink_id', '')
4253 volume = data.get('volume')
4254 if not sink_id or volume is None:
4255 return jsonify({'success': False, 'error': 'sink_id and volume required'}), 400
4256 volume = max(0, min(150, int(volume)))
4257 try:
4258 r = subprocess.run(
4259 ['pactl', 'set-sink-volume', sink_id, f'{volume}%'],
4260 capture_output=True, text=True, timeout=5)
4261 if r.returncode == 0:
4262 return jsonify({'success': True, 'volume': volume})
4263 return jsonify({'success': False, 'error': r.stderr.strip()}), 400
4264 except Exception as e:
4265 return jsonify({'success': False, 'error': str(e)}), 500
4267 @app.route('/api/shell/audio/mute', methods=['POST'])
4268 def shell_audio_mute():
4269 data = request.get_json(silent=True) or {}
4270 sink_id = data.get('sink_id', '')
4271 muted = data.get('muted', True)
4272 if not sink_id:
4273 return jsonify({'success': False, 'error': 'sink_id required'}), 400
4274 try:
4275 val = '1' if muted else '0'
4276 r = subprocess.run(
4277 ['pactl', 'set-sink-mute', sink_id, val],
4278 capture_output=True, text=True, timeout=5)
4279 if r.returncode == 0:
4280 return jsonify({'success': True, 'muted': muted})
4281 return jsonify({'success': False, 'error': r.stderr.strip()}), 400
4282 except Exception as e:
4283 return jsonify({'success': False, 'error': str(e)}), 500
4285 @app.route('/api/shell/audio/default', methods=['POST'])
4286 def shell_audio_default():
4287 data = request.get_json(silent=True) or {}
4288 sink_id = data.get('sink_id', '')
4289 if not sink_id:
4290 return jsonify({'success': False, 'error': 'sink_id required'}), 400
4291 try:
4292 r = subprocess.run(
4293 ['pactl', 'set-default-sink', sink_id],
4294 capture_output=True, text=True, timeout=5)
4295 if r.returncode == 0:
4296 return jsonify({'success': True, 'default_sink': sink_id})
4297 return jsonify({'success': False, 'error': r.stderr.strip()}), 400
4298 except Exception as e:
4299 return jsonify({'success': False, 'error': str(e)}), 500
4301 @app.route('/api/shell/audio/source/volume', methods=['POST'])
4302 def shell_audio_source_volume():
4303 data = request.get_json(silent=True) or {}
4304 source_id = data.get('source_id', '')
4305 volume = data.get('volume')
4306 if not source_id or volume is None:
4307 return jsonify({'success': False, 'error': 'source_id and volume required'}), 400
4308 volume = max(0, min(150, int(volume)))
4309 try:
4310 r = subprocess.run(
4311 ['pactl', 'set-source-volume', source_id, f'{volume}%'],
4312 capture_output=True, text=True, timeout=5)
4313 if r.returncode == 0:
4314 return jsonify({'success': True, 'volume': volume})
4315 return jsonify({'success': False, 'error': r.stderr.strip()}), 400
4316 except Exception as e:
4317 return jsonify({'success': False, 'error': str(e)}), 500
4319 # ── Shell APIs: Bluetooth ──
4320 @app.route('/api/shell/bluetooth', methods=['GET'])
4321 def shell_bluetooth():
4322 devices = []
4323 try:
4324 r = subprocess.run(
4325 ['bluetoothctl', 'devices'],
4326 capture_output=True, text=True, timeout=5)
4327 for line in r.stdout.strip().split('\n'):
4328 parts = line.split(None, 2)
4329 if len(parts) == 3:
4330 devices.append({'mac': parts[1], 'name': parts[2]})
4331 except Exception:
4332 pass
4333 return jsonify({'devices': devices})
4335 # ── Shell APIs: Power/Battery ──
4336 @app.route('/api/shell/power', methods=['GET'])
4337 def shell_power():
4338 info = {
4339 'on_battery': False, 'percent': 100,
4340 'time_remaining': '', 'state': 'unknown',
4341 }
4342 try:
4343 r = subprocess.run(
4344 ['upower', '-i',
4345 '/org/freedesktop/UPower/devices/battery_BAT0'],
4346 capture_output=True, text=True, timeout=5)
4347 for line in r.stdout.split('\n'):
4348 line = line.strip()
4349 if 'percentage:' in line:
4350 info['percent'] = int(
4351 line.split(':')[1].strip().replace('%', ''))
4352 elif 'state:' in line:
4353 info['state'] = line.split(':')[1].strip()
4354 info['on_battery'] = info['state'] == 'discharging'
4355 elif 'time to empty:' in line:
4356 info['time_remaining'] = line.split(':', 1)[1].strip()
4357 except Exception:
4358 pass
4359 return jsonify(info)
4361 # ── Shell APIs: Display ──
4362 @app.route('/api/shell/display', methods=['GET'])
4363 def shell_display():
4364 displays = []
4365 try:
4366 r = subprocess.run(
4367 ['xrandr', '--current'],
4368 capture_output=True, text=True, timeout=5)
4369 current_display = None
4370 for line in r.stdout.split('\n'):
4371 if ' connected' in line:
4372 parts = line.split()
4373 # Find resolution: skip 'primary' keyword if present
4374 res = 'unknown'
4375 for p in parts[2:]:
4376 if 'x' in p and p[0].isdigit():
4377 res = p.split('+')[0] # strip offset
4378 break
4379 current_display = {
4380 'name': parts[0],
4381 'resolution': res,
4382 'modes': [],
4383 }
4384 displays.append(current_display)
4385 elif current_display and line.startswith(' '):
4386 # Mode line: " 1920x1080 60.00*+ 50.00"
4387 mode_parts = line.strip().split()
4388 if mode_parts:
4389 mode = mode_parts[0]
4390 rates = []
4391 active = False
4392 for p in mode_parts[1:]:
4393 clean = p.replace('*', '').replace('+', '')
4394 if '*' in p:
4395 active = True
4396 try:
4397 rates.append(float(clean))
4398 except ValueError:
4399 pass
4400 current_display['modes'].append({
4401 'resolution': mode,
4402 'rates': rates,
4403 'active': active,
4404 })
4405 elif not line.startswith(' '):
4406 current_display = None
4407 except Exception:
4408 pass
4409 return jsonify({'displays': displays})
4411 @app.route('/api/shell/display/resolution', methods=['POST'])
4412 def shell_display_resolution():
4413 data = request.get_json(silent=True) or {}
4414 output = data.get('output', '')
4415 resolution = data.get('resolution', '')
4416 rate = data.get('rate')
4417 if not output or not resolution:
4418 return jsonify({'success': False, 'error': 'output and resolution required'}), 400
4419 try:
4420 cmd = ['xrandr', '--output', output, '--mode', resolution]
4421 if rate:
4422 cmd += ['--rate', str(rate)]
4423 r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
4424 if r.returncode == 0:
4425 return jsonify({'success': True, 'output': output, 'resolution': resolution})
4426 return jsonify({'success': False, 'error': r.stderr.strip() or 'Failed to set resolution'}), 400
4427 except Exception as e:
4428 return jsonify({'success': False, 'error': str(e)}), 500
4430 @app.route('/api/shell/display/brightness', methods=['POST'])
4431 def shell_display_brightness():
4432 data = request.get_json(silent=True) or {}
4433 output = data.get('output', '')
4434 brightness = data.get('brightness')
4435 if not output or brightness is None:
4436 return jsonify({'success': False, 'error': 'output and brightness required'}), 400
4437 brightness = max(0.1, min(1.0, float(brightness)))
4438 try:
4439 r = subprocess.run(
4440 ['xrandr', '--output', output, '--brightness', str(brightness)],
4441 capture_output=True, text=True, timeout=5)
4442 if r.returncode == 0:
4443 return jsonify({'success': True, 'brightness': brightness})
4444 return jsonify({'success': False, 'error': r.stderr.strip()}), 400
4445 except Exception as e:
4446 return jsonify({'success': False, 'error': str(e)}), 500
4448 @app.route('/api/shell/display/scale', methods=['POST'])
4449 def shell_display_scale():
4450 data = request.get_json(silent=True) or {}
4451 output = data.get('output', '')
4452 scale = data.get('scale')
4453 if not output or scale is None:
4454 return jsonify({'success': False, 'error': 'output and scale required'}), 400
4455 scale = max(0.5, min(3.0, float(scale)))
4456 try:
4457 # xrandr scale is inverse: scale 2.0 means 0.5x transform
4458 transform = str(round(1.0 / scale, 4))
4459 r = subprocess.run(
4460 ['xrandr', '--output', output, '--scale', f'{transform}x{transform}'],
4461 capture_output=True, text=True, timeout=5)
4462 if r.returncode == 0:
4463 return jsonify({'success': True, 'scale': scale})
4464 return jsonify({'success': False, 'error': r.stderr.strip()}), 400
4465 except Exception as e:
4466 return jsonify({'success': False, 'error': str(e)}), 500
4468 # ── Shell APIs: System Metrics ──
4469 @app.route('/api/shell/system/metrics', methods=['GET'])
4470 def shell_system_metrics():
4471 metrics = {}
4472 try:
4473 import psutil
4474 metrics['cpu_percent'] = psutil.cpu_percent(interval=0.5)
4475 metrics['cpu_count'] = psutil.cpu_count()
4476 mem = psutil.virtual_memory()
4477 metrics['ram'] = {
4478 'total_gb': round(mem.total / (1024**3), 1),
4479 'used_gb': round(mem.used / (1024**3), 1),
4480 'percent': mem.percent,
4481 }
4482 disks = []
4483 for part in psutil.disk_partitions():
4484 try:
4485 usage = psutil.disk_usage(part.mountpoint)
4486 disks.append({
4487 'mount': part.mountpoint,
4488 'device': part.device,
4489 'total_gb': round(usage.total / (1024**3), 1),
4490 'used_gb': round(usage.used / (1024**3), 1),
4491 'percent': usage.percent,
4492 })
4493 except (PermissionError, OSError):
4494 pass
4495 metrics['disks'] = disks
4496 net = psutil.net_io_counters()
4497 metrics['network'] = {
4498 'bytes_sent': net.bytes_sent,
4499 'bytes_recv': net.bytes_recv,
4500 }
4501 metrics['load_avg'] = list(psutil.getloadavg()) if hasattr(psutil, 'getloadavg') else []
4502 metrics['uptime_seconds'] = int(
4503 __import__('time').time() - psutil.boot_time())
4504 # Temperatures if available
4505 try:
4506 temps = psutil.sensors_temperatures()
4507 if temps:
4508 metrics['temperatures'] = {
4509 name: [{'label': s.label, 'current': s.current}
4510 for s in sensors[:3]]
4511 for name, sensors in temps.items()
4512 }
4513 except (AttributeError, Exception):
4514 pass
4515 except ImportError:
4516 metrics['error'] = 'psutil not installed'
4517 # GPU via VRAMManager
4518 try:
4519 from integrations.service_tools.vram_manager import VRAMManager
4520 gpu = VRAMManager.detect_gpu()
4521 if gpu and gpu.get('name'):
4522 metrics['gpu'] = gpu
4523 except Exception:
4524 pass
4525 return jsonify(metrics)
4527 @app.route('/api/shell/system/processes', methods=['GET'])
4528 def shell_system_processes():
4529 procs = []
4530 try:
4531 import psutil
4532 for p in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
4533 try:
4534 info = p.info
4535 if info.get('cpu_percent', 0) > 0 or info.get('memory_percent', 0) > 0.1:
4536 procs.append({
4537 'pid': info['pid'],
4538 'name': info['name'],
4539 'cpu': round(info.get('cpu_percent', 0), 1),
4540 'mem': round(info.get('memory_percent', 0), 1),
4541 })
4542 except (psutil.NoSuchProcess, psutil.AccessDenied):
4543 pass
4544 procs.sort(key=lambda p: p['cpu'], reverse=True)
4545 except ImportError:
4546 pass
4547 return jsonify({'processes': procs[:30]})
4549 # ── Shell APIs: Log Viewer ──
4550 @app.route('/api/shell/system/logs', methods=['GET'])
4551 def shell_system_logs():
4552 unit = request.args.get('unit', 'hart-*')
4553 lines = int(request.args.get('lines', 100))
4554 priority = request.args.get('priority', '')
4555 since = request.args.get('since', '')
4556 grep_pattern = request.args.get('grep', '')
4557 lines = max(1, min(1000, lines))
4558 try:
4559 cmd = ['journalctl', '--output=json', '--no-pager',
4560 '-u', unit, '-n', str(lines)]
4561 if priority:
4562 cmd += ['-p', priority]
4563 if since:
4564 cmd += ['--since', since]
4565 if grep_pattern:
4566 cmd += ['-g', grep_pattern]
4567 r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
4568 entries = []
4569 for line in r.stdout.strip().split('\n'):
4570 if not line:
4571 continue
4572 try:
4573 entry = json.loads(line)
4574 entries.append({
4575 'timestamp': entry.get('__REALTIME_TIMESTAMP', ''),
4576 'unit': entry.get('_SYSTEMD_UNIT', ''),
4577 'priority': entry.get('PRIORITY', ''),
4578 'message': entry.get('MESSAGE', ''),
4579 })
4580 except json.JSONDecodeError:
4581 pass
4582 return jsonify({'entries': entries, 'count': len(entries)})
4583 except FileNotFoundError:
4584 return jsonify({'entries': [], 'count': 0,
4585 'error': 'journalctl not available'}), 200
4586 except Exception as e:
4587 return jsonify({'entries': [], 'error': str(e)}), 500
4589 @app.route('/api/shell/system/logs/stream', methods=['GET'])
4590 def shell_system_logs_stream():
4591 unit = request.args.get('unit', 'hart-*')
4592 def generate():
4593 try:
4594 proc = subprocess.Popen(
4595 ['journalctl', '--output=json', '--no-pager',
4596 '-f', '-u', unit],
4597 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
4598 text=True)
4599 for line in proc.stdout:
4600 line = line.strip()
4601 if not line:
4602 continue
4603 try:
4604 entry = json.loads(line)
4605 data = json.dumps({
4606 'timestamp': entry.get('__REALTIME_TIMESTAMP', ''),
4607 'unit': entry.get('_SYSTEMD_UNIT', ''),
4608 'message': entry.get('MESSAGE', ''),
4609 })
4610 yield f'data: {data}\n\n'
4611 except json.JSONDecodeError:
4612 pass
4613 except Exception:
4614 yield 'data: {"error": "stream unavailable"}\n\n'
4615 return Response(generate(), mimetype='text/event-stream',
4616 headers={'Cache-Control': 'no-cache',
4617 'X-Accel-Buffering': 'no'})
4619 # ── Shell APIs: Recent Files ──
4620 @app.route('/api/shell/files/recent', methods=['GET'])
4621 def shell_recent_files():
4622 files = []
4623 xbel_path = os.path.expanduser(
4624 '~/.local/share/recently-used.xbel')
4625 if os.path.isfile(xbel_path):
4626 try:
4627 import xml.etree.ElementTree as ET
4628 tree = ET.parse(xbel_path)
4629 for bookmark in list(tree.getroot())[-20:]:
4630 href = bookmark.get('href', '')
4631 if href.startswith('file://'):
4632 path = href.replace('file://', '')
4633 name = os.path.basename(path)
4634 modified = bookmark.get('modified', '')
4635 files.append({
4636 'name': name, 'path': path,
4637 'modified': modified,
4638 })
4639 except Exception:
4640 pass
4641 return jsonify({'files': files[-10:]})
4643 # ── Agent Action SSE Stream (ALL component types, not just notifications) ──
4644 @app.route('/api/notifications/stream', methods=['GET'])
4645 def notification_stream():
4646 import time as _time
4648 def generate():
4649 last_check = _time.time()
4650 while True:
4651 _time.sleep(2) # 2s for snappier live updates
4652 events = []
4653 for agent_id, comps in list(
4654 self._agent_components.items()):
4655 for c in comps:
4656 ts = c.get('_ts', 0)
4657 if ts > last_check:
4658 # Push ALL component types — not just notifications
4659 event = dict(c)
4660 event['agent'] = agent_id
4661 events.append(event)
4662 last_check = _time.time()
4663 if events:
4664 yield f"data: {json.dumps(events)}\n\n"
4665 return Response(
4666 generate(), mimetype='text/event-stream',
4667 headers={
4668 'Cache-Control': 'no-cache',
4669 'X-Accel-Buffering': 'no',
4670 })
4672 @app.route('/health', methods=['GET'])
4673 def health():
4674 return jsonify({
4675 'status': 'ok', 'service': 'liquid-ui-shell',
4676 'model_available': self._model_available,
4677 'renderer': self.renderer,
4678 })
4680 # Register OS management APIs (shell file manager, terminal,
4681 # desktop settings, system monitoring, app installer)
4682 try:
4683 from integrations.agent_engine.shell_os_apis import (
4684 register_shell_os_routes)
4685 from integrations.agent_engine.shell_desktop_apis import (
4686 register_shell_desktop_routes)
4687 from integrations.agent_engine.shell_system_apis import (
4688 register_shell_system_routes)
4689 from integrations.agent_engine.app_installer import (
4690 register_app_install_routes)
4691 register_shell_os_routes(app)
4692 register_shell_desktop_routes(app)
4693 register_shell_system_routes(app)
4694 register_app_install_routes(app)
4695 except Exception as e:
4696 logger.warning("Shell APIs registration: %s", e)
4698 # Register OpenClaw + floating assistant APIs
4699 try:
4700 from integrations.openclaw.shell_openclaw_apis import (
4701 register_openclaw_routes)
4702 register_openclaw_routes(app)
4703 except Exception as e:
4704 logger.warning("OpenClaw APIs registration: %s", e)
4706 # Register HART onboarding ceremony APIs
4707 try:
4708 from integrations.agent_engine.onboarding_routes import (
4709 register_onboarding_routes)
4710 register_onboarding_routes(app)
4711 except Exception as e:
4712 logger.warning("Onboarding APIs registration: %s", e)
4714 return app
4716 # ─── Serve ────────────────────────────────────────────────
4718 def serve_forever(self):
4719 """Start the glass desktop shell service."""
4720 self._running = True
4722 # Ensure platform substrate is ready (EventBus, AppRegistry, Extensions)
4723 try:
4724 from core.platform.boot_service import ensure_platform
4725 ensure_platform()
4726 except Exception as e:
4727 logger.warning("Platform boot: %s", e)
4729 def _model_check_loop():
4730 from core.http_pool import pooled_get
4731 while self._running:
4732 try:
4733 resp = pooled_get(
4734 f'http://localhost:{self.model_bus_port}/v1/status',
4735 timeout=3)
4736 self._model_available = (
4737 resp.status_code == 200 and
4738 resp.json().get('backend_count', 0) > 0)
4739 except Exception:
4740 self._model_available = False
4741 time.sleep(10)
4743 threading.Thread(target=_model_check_loop, daemon=True).start()
4745 app = self._create_flask_app()
4746 logger.info("LiquidUI Glass Shell starting on port %d", self.port)
4748 # Auto-scale threads by hardware tier
4749 try:
4750 from security.system_requirements import get_tier_name
4751 tier = get_tier_name()
4752 except Exception:
4753 tier = 'standard'
4754 threads = 1 if tier in ('embedded', 'observer') else 2 if tier == 'lite' else 4
4756 try:
4757 from waitress import serve
4758 serve(app, host='0.0.0.0', port=self.port, threads=threads)
4759 except ImportError:
4760 app.run(host='0.0.0.0', port=self.port, threaded=True)