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

1""" 

2HART OS LiquidUI Service — Glass Desktop Shell. 

3 

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): 

6 

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) 

12 

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") 

17 

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 

23 

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 

37 

38logger = logging.getLogger('hevolve.liquid_ui') 

39 

40# ═══════════════════════════════════════════════════════════════ 

41# UI Component Schema (A2UI protocol) 

42# ═══════════════════════════════════════════════════════════════ 

43 

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} 

79 

80# ═══════════════════════════════════════════════════════════════ 

81# Context Engine 

82# ═══════════════════════════════════════════════════════════════ 

83 

84 

85class ContextEngine: 

86 """Aggregates context signals for UI generation.""" 

87 

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() 

93 

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 

106 

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 

138 

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} 

151 

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': []} 

168 

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 

197 

198 

199# ═══════════════════════════════════════════════════════════════ 

200# LiquidUI Service — Glass Desktop Shell 

201# ═══════════════════════════════════════════════════════════════ 

202 

203 

204class LiquidUIService: 

205 """Glass desktop shell — the OS desktop itself.""" 

206 

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 

228 

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 

234 

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'))) 

242 

243 logger.info( 

244 "LiquidUIService initialized: port=%d, renderer=%s, " 

245 "voice=%s, haptic=%s", port, renderer, voice_enabled, haptic_enabled) 

246 

247 # ─── UI Generation (preserved) ──────────────────────────── 

248 

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) 

256 

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) 

288 

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 } 

330 

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 ) 

349 

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 ) 

358 

359 # ─── Agent UI Protocol (A2UI) — preserved ───────────────── 

360 

361 def agent_ui_update(self, agent_id: str, component: dict) -> bool: 

362 """Push a UI component from an agent to all connected frontends. 

363 

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 

375 

376 import time as _time 

377 component['_ts'] = _time.time() 

378 component['_agent_id'] = agent_id 

379 

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:] 

388 

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 

402 

403 logger.info("A2UI: agent %s pushed %s component", agent_id, comp_type) 

404 return True 

405 

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} 

417 

418 # ─── Voice I/O — preserved ──────────────────────────────── 

419 

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'} 

436 

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'} 

453 

454 # ─── Glass Desktop Shell Render ─────────────────────────── 

455 

456 def render_desktop_shell(self) -> str: 

457 """Render the complete glass desktop shell HTML. 

458 

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 = {} 

471 

472 # Performance tier detection 

473 perf = theme.get('performance', {}) 

474 is_potato = perf.get('disable_blur', False) 

475 

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'] 

480 

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 = '[]' 

492 

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}' 

506 

507 # ── HART Design System (Material Design 3 inspired) ── 

508 _CSS_DESIGN_SYSTEM = ''' 

509/* ═══ HART Design System ═══ */ 

510/* Content-first · Purposeful motion · 4dp grid */ 

511 

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; 

516 

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; 

522 

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); 

530 

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); 

538 

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); 

546 

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); 

552 

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; 

556 

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} 

561 

562/* ── Body font override: Inter for body, JetBrains Mono for code ── */ 

563html, body { font-family: var(--ds-font-body); line-height: 1.5 } 

564 

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)} 

582 

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)} 

590 

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} 

618 

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}} 

623 

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)} 

639 

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)} 

650 

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)} 

663 

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)} 

673 

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)} 

683 

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)} 

688 

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}} 

699 

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)} 

717 

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%}} 

735 

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} 

743 

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} 

756 

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)} 

762 

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} 

766 

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} 

774 

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)} 

779 

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} 

793 

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''' 

800 

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)}} 

815 

816/* ── Wallpaper ── */ 

817.wallpaper{{position:fixed;inset:0;z-index:0;background:{wp_css}}} 

818 

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)}} 

823 

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)}} 

845 

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}} 

850 

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}} 

870 

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}} 

898 

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}} 

913 

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}} 

959 

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}} 

968 

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)}} 

980 

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)}} 

986 

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}} 

999 

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 ''} 

1008 

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 ''} 

1013 

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> 

1021 

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> 

1043 

1044<!-- Panel Container --> 

1045<div class="panel-container" id="panels"></div> 

1046 

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> 

1053 

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> 

1072 

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> 

1084 

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> 

1093 

1094<!-- Taskbar (open panels as chips) --> 

1095<div class="taskbar glass" id="taskbar"></div> 

1096 

1097<!-- Toast Notifications --> 

1098<div class="toast-container" id="toast-container"></div> 

1099 

1100<!-- Context Menu --> 

1101<div class="ctx-menu glass" id="ctx-menu" style="display:none"></div> 

1102 

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/#'; 

1111 

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}}; 

1121 

1122// ═══ State ═══ 

1123let panels = {{}}; 

1124let panelZ = 100; 

1125let startOpen = false; 

1126let focusedPanel = null; 

1127 

1128// ═══════════════════════════════════════════════ 

1129// HART Design System — Component Library 

1130// ═══════════════════════════════════════════════ 

1131 

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}} 

1146 

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,'&quot;'))+'">' + 

1157 (icon ? '<span class="mi material-icons-round">'+icon+'</span>' : '') + 

1158 '<span>'+label+'</span></button>'; 

1159}} 

1160 

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,'&quot;')+'"':'') + 

1177 (onkeydown?' onkeydown="'+onkeydown.replace(/"/g,'&quot;')+'"':'') + '>'; 

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}} 

1183 

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,'&quot;')+'"':'')+'>'; 

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}} 

1202 

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,'&quot;')+';':'')+ 

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}} 

1225 

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}} 

1245 

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}} 

1261 

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}} 

1274 

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,'&quot;')+'"':'')+'>'+content+'</div>'; 

1281}} 

1282 

1283// ── Modal System ── 

1284let _dsModalOverlay = null; 

1285function dsModal(opts) {{ 

1286 opts = opts || {{}}; 

1287 // Remove existing modal 

1288 if(_dsModalOverlay) {{ _dsModalOverlay.remove(); _dsModalOverlay = null; }} 

1289 

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>'; 

1296 

1297 document.body.appendChild(overlay); 

1298 _dsModalOverlay = overlay; 

1299 

1300 // Close on overlay click (not modal body) 

1301 overlay.addEventListener('click', function(e){{ 

1302 if(e.target === overlay) dsModalClose(); 

1303 }}); 

1304 

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); 

1310 

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 }} 

1322 

1323 // Trigger open animation (next frame) 

1324 requestAnimationFrame(function(){{ 

1325 requestAnimationFrame(function(){{ overlay.classList.add('ds-open'); }}); 

1326 }}); 

1327 

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); 

1333 

1334 return overlay; 

1335}} 

1336 

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}} 

1344 

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 || ''; 

1351 

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}} 

1373 

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}} 

1389 

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}} 

1404 

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}} 

1438 

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}} 

1453 

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}} 

1472 

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(); 

1487 

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(); 

1503 

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(); 

1533 

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}} 

1540 

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}} 

1549 

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]; 

1570 

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); 

1576 

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); 

1581 

1582 const title = opts.title || def.title || id; 

1583 const icon = def.icon || 'web_asset'; 

1584 

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>'; 

1596 

1597 document.getElementById('panels').appendChild(panel); 

1598 panel.addEventListener('mousedown', ()=>bringToFront(id)); 

1599 

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 }} 

1616 

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}} 

1622 

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}} 

1634 

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}} 

1657 

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}} 

1666 

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}} 

1682 

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}} 

1701 

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; }}); 

1734 

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); 

1741 

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}} 

1795 

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); }} 

1799 

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}} 

1818 

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}} 

1835 

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}} 

1867 

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?' &middot; 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}} 

1923 

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}} 

1952 

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}} 

1997 

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}} 

2043 

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}} 

2080 

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+' &middot; CPU '+((p.cpu_percent||0).toFixed(1))+'% &middot; 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}} 

2113 

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}} 

2133 

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}} 

2153 

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}} 

2172 

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}} 

2187 

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}} 

2200 

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?'':' &middot; '+(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}} 

2246 

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}} 

2273 

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}} 

2287 

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}} 

2304 

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}} 

2317 

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 &amp; 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}} 

2331 

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 &amp; 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}} 

2346 

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 &amp; 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}} 

2372 

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}} 

2398 

2399// ═══ Screenshot & Recording ═══ 

2400function loadScreenshotPanel(el) {{ 

2401 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Screenshot &amp; 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}} 

2409 

2410// ═══ Firewall ═══ 

2411function loadFirewallPanel(el) {{ 

2412 el.innerHTML = '<div class="ds-panel-grid ds-fade-in"><div class="ds-panel-title">Firewall &amp; 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}} 

2419 

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}} 

2437 

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}} 

2454 

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}} 

2475 

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?' &middot; 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}} 

2495 

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 &amp; 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}} 

2511 

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}} 

2534 

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 &amp; 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}} 

2554 

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}} 

2564 

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}} 

2582 

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}} 

2607 

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}} 

2615 

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}} 

2635 

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||'')+' &middot; '+(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}} 

2660 

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}} 

2675 

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?' &middot; '+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}} 

2692 

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+' &middot; '+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}} 

2723 

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}} 

2743 

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}} 

2763 

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}} 

2772 

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}} 

2786 

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}} 

2795 

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}} 

2810 

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 &amp; 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}} 

2824 

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}} 

2849 

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}} 

2888 

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}} 

2899 

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?' &middot; '+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}} 

2918 

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}} 

2934 

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}} 

2964 

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}} 

2987 

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 || []; 

2996 

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>'; 

3000 

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>'; 

3006 

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>'; 

3017 

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)+' &mdash; '+s.mode, s.state, 'var(--hart-active)'); 

3023 }} 

3024 html += '</div>'; 

3025 }} 

3026 

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 }} 

3035 

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>'; 

3041 

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}} 

3046 

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'); 

3060 

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 }} 

3075 

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}} 

3084 

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}}; 

3102 

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(''); 

3111 

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}} 

3133 

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}} 

3150 

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}} 

3157 

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}} 

3164 

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}} 

3175 

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 = ''; 

3182 

3183 acAddMsg('user', text); 

3184 

3185 // Show typing indicator 

3186 const typing = acAddMsg('assistant', 'Thinking...'); 

3187 typing.classList.add('typing'); 

3188 

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 }} 

3208 

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}} 

3222 

3223function acVoiceInput() {{ 

3224 toggleVoice(); 

3225}} 

3226 

3227// Init on load 

3228setTimeout(initAssistantChat, 500); 

3229 

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; }} 

3243 

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}} 

3251 

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}} 

3260 

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';}}); 

3285 

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>'; }} 

3291 

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}}); 

3325 

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}} 

3344 

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}} 

3351 

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}}); 

3359 

3360// ═══ Voice I/O (push-to-talk + TTS) ═══ 

3361let mediaRecorder = null; 

3362let audioChunks = []; 

3363let isRecording = false; 

3364 

3365function toggleVoice() {{ 

3366 if(isRecording) {{ stopRecording(); return; }} 

3367 startRecording(); 

3368}} 

3369 

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}} 

3404 

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}} 

3411 

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}} 

3435 

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}} 

3459 

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}} 

3470 

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;}} 

3475 

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'; 

3502 

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>'; 

3512 

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>'; 

3519 

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>'; 

3524 

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>'; 

3529 

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>'; 

3537 

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>'; 

3544 

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>'; 

3550 

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>'; 

3555 

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 }} 

3594 

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</code></pre>'; 

3601 

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); 

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">&#8226; $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>'; 

3617 

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>'; 

3631 

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>'; 

3641 

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>'; 

3663 

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+'>'; 

3679 

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>'; 

3689 

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>'; 

3702 

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 }} 

3708 

3709 overlay.innerHTML = html; 

3710 document.body.appendChild(overlay); 

3711 _overlayStack.push(id); 

3712 

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}} 

3722 

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}})(); 

3742 

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>''' 

3761 

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>' 

3807 

3808 # ─── HTTP Server (Glass Shell + Shell APIs) ─────────────── 

3809 

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 

3813 

3814 app = Flask(__name__) 

3815 

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') 

3820 

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) 

3827 

3828 @app.route('/app/') 

3829 def nunba_index(): 

3830 return send_from_directory(nunba_dir, 'index.html') 

3831 

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 }) 

3844 

3845 @app.route('/api/context', methods=['GET']) 

3846 def api_context(): 

3847 return jsonify(self.context_engine.get_context()) 

3848 

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}) 

3859 

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) 

3868 

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 }) 

3908 

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) 

3921 

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'}) 

3927 

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)}) 

3947 

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}) 

3968 

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]}) 

3991 

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 

4007 

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 

4028 

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}) 

4047 

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({}) 

4059 

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 

4071 

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]}) 

4085 

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}) 

4119 

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 

4139 

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 

4156 

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) 

4194 

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 

4206 

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}) 

4248 

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 

4266 

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 

4284 

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 

4300 

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 

4318 

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}) 

4334 

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) 

4360 

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}) 

4410 

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 

4429 

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 

4447 

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 

4467 

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) 

4526 

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]}) 

4548 

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 

4588 

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'}) 

4618 

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:]}) 

4642 

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 

4647 

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 }) 

4671 

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 }) 

4679 

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) 

4697 

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) 

4705 

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) 

4713 

4714 return app 

4715 

4716 # ─── Serve ──────────────────────────────────────────────── 

4717 

4718 def serve_forever(self): 

4719 """Start the glass desktop shell service.""" 

4720 self._running = True 

4721 

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) 

4728 

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) 

4742 

4743 threading.Thread(target=_model_check_loop, daemon=True).start() 

4744 

4745 app = self._create_flask_app() 

4746 logger.info("LiquidUI Glass Shell starting on port %d", self.port) 

4747 

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 

4755 

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)