Files
jarvis/public_html/index.html
T
myron 9f92e4d5e4 Add JARVIS improvements: mobile UI, sparklines, suggestions, multi-step commands, Arc Reactor health, tier badges
- Mobile UI: 3-button bottom nav with panel switcher
- Chat history search: search modal with keyword query
- News filtering: category filter with localStorage persistence
- Proactive reminders: planner/appointment alerts at login and every 5 min
- Proactive alerts: polls every 60s, speaks new critical/warning alerts
- Agent sparklines: 2h CPU+MEM sparkline on each online agent card
- Tier source badge: KB/GROQ/CLAUDE/OLLAMA pill shown after each reply
- VM suggestions: 24h resource analysis via voice command
- HA scene control: fuzzy-match scene activation via voice
- Jellyfin control: pause/stop/next/previous via voice and KB
- Pattern suggestions: usage_patterns table + proactive chips every 30 min
- Multi-step commands: compound "X and Y" command parsing (Tier 0.5)
- Arc Reactor health: warning=amber/1.2s, critical=red/0.6s pulse encoding
- Cross-session history: last 6 turns loaded from prior session
- Restart agent: voice command to restart any JARVIS agent
- New endpoints: history.php, metrics.php, suggestions.php, jellyfin.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 02:49:05 +00:00

5393 lines
261 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>JARVIS — Integrated Defense and Logistics System</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Rajdhani:wght@300;400;500;600&family=Share+Tech+Mono&display=swap" rel="stylesheet"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#000810;
--bg2:#000d1a;
--cyan:#00d4ff;
--cyan2:#00a8cc;
--cyan3:rgba(0,212,255,0.15);
--orange:#ff6600;
--orange2:#ff4400;
--green:#00ff88;
--red:#ff2244;
--yellow:#ffd700;
--dim:rgba(0,212,255,0.4);
--dimmer:rgba(0,212,255,0.12);
--grid:rgba(0,180,255,0.07);
--text:#c8e6ff;
--text-dim:rgba(200,230,255,0.5);
--panel-bg:rgba(0,15,35,0.85);
--panel-border:rgba(0,212,255,0.2);
--font-display:'Orbitron',monospace;
--font-body:'Rajdhani',sans-serif;
--font-mono:'Share Tech Mono',monospace;
--r:8px;
}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:var(--font-body)}
/* ── GRID BACKGROUND — animated drift ─────────────────────────────── */
@keyframes gridDrift{from{background-position:0 0,0 0}to{background-position:40px 40px,40px 40px}}
body::before{
content:'';
position:fixed;inset:0;
background-image:
linear-gradient(var(--grid) 1px,transparent 1px),
linear-gradient(90deg,var(--grid) 1px,transparent 1px);
background-size:40px 40px;
z-index:0;
pointer-events:none;
animation:gridDrift 18s linear infinite;
}
body::after{
content:'';
position:fixed;inset:0;
background:radial-gradient(ellipse at 50% 50%,rgba(0,80,160,0.08) 0%,transparent 70%);
z-index:0;
pointer-events:none;
}
/* ── SCAN LINES ───────────────────────────────────────────────────── */
.scanlines{
position:fixed;inset:0;z-index:1;pointer-events:none;
background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px);
animation:scanMove 8s linear infinite;
}
@keyframes scanMove{0%{background-position:0 0}100%{background-position:0 100%}}
/* ── SCANLINE SWEEP ───────────────────────────────────────────────── */
.scanline-sweep{
position:fixed;top:0;left:0;right:0;height:120px;
background:linear-gradient(180deg,transparent 0%,rgba(0,212,255,0.04) 40%,rgba(0,212,255,0.12) 50%,rgba(0,212,255,0.04) 60%,transparent 100%);
pointer-events:none;z-index:2;
animation:sweepDown 7s linear infinite;
box-shadow:0 0 12px rgba(0,212,255,0.15);
}
@keyframes sweepDown{
0%{transform:translateY(-120px);opacity:0}
3%{opacity:1}
97%{opacity:0.7}
100%{transform:translateY(100vh);opacity:0}
}
/* ── PARTICLE CANVAS ──────────────────────────────────────────────── */
#particleCanvas{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:0.7}
/* ── PANEL FLOAT + GLOW ───────────────────────────────────────────── */
@keyframes panelFloat{
0%,100%{transform:translateY(var(--pty,0px)) rotateX(var(--prx,0deg)) rotateY(var(--pry,0deg));box-shadow:0 4px 20px rgba(0,0,0,0.4),0 0 0px rgba(0,212,255,0)}
50%{transform:translateY(calc(var(--pty,0px) - 7px)) rotateX(var(--prx,0deg)) rotateY(var(--pry,0deg));box-shadow:0 16px 40px rgba(0,0,0,0.5),0 0 30px rgba(0,212,255,0.06),0 0 60px rgba(0,212,255,0.02)}
}
/* Panel flash when data updates */
@keyframes panelFlash{0%{box-shadow:0 0 0 1px rgba(0,212,255,0.8),0 0 20px rgba(0,212,255,0.3)}100%{box-shadow:none}}
.panel.data-flash{animation:panelFlash 0.5s ease-out,panelFloat 7s ease-in-out infinite}
/* Alert pulse — red ambient glow on body when alerts active */
@keyframes alertPulse{0%,100%{opacity:0}50%{opacity:1}}
#alertOverlay{position:fixed;inset:0;pointer-events:none;z-index:1;background:radial-gradient(ellipse at 50% 50%,rgba(255,30,60,0.07) 0%,transparent 70%);animation:alertPulse 3s ease-in-out infinite;display:none}
/* Glitch keyframes */
@keyframes glitch1{
0%,100%{clip-path:inset(0 0 100% 0);transform:translate(0)}
10%{clip-path:inset(10% 0 60% 0);transform:translate(-3px,1px)}
20%{clip-path:inset(40% 0 30% 0);transform:translate(3px,-1px)}
30%{clip-path:inset(70% 0 10% 0);transform:translate(-2px,2px)}
40%{clip-path:inset(0 0 100% 0);transform:translate(0)}
}
@keyframes glitch2{
0%,100%{clip-path:inset(0 0 100% 0);transform:translate(0)}
10%{clip-path:inset(60% 0 10% 0);transform:translate(3px,-1px)}
25%{clip-path:inset(20% 0 50% 0);transform:translate(-3px,1px)}
35%{clip-path:inset(80% 0 5% 0);transform:translate(2px,2px)}
45%{clip-path:inset(0 0 100% 0);transform:translate(0)}
}
.tb-logo-text{position:relative;display:inline-block}
.tb-logo-text::before,.tb-logo-text::after{
content:attr(data-text);position:absolute;top:0;left:0;
color:var(--cyan);font-family:var(--font-display);font-size:inherit;font-weight:inherit;letter-spacing:inherit;
pointer-events:none;opacity:0;
}
.tb-logo-text::before{color:rgba(255,0,80,0.8);text-shadow:2px 0 rgba(255,0,80,0.5)}
.tb-logo-text::after{color:rgba(0,255,255,0.8);text-shadow:-2px 0 rgba(0,255,255,0.5)}
.tb-logo-text.glitching::before{animation:glitch1 0.25s steps(1) forwards;opacity:1}
.tb-logo-text.glitching::after{animation:glitch2 0.25s steps(1) forwards;opacity:1}
/* Metric bar shimmer */
@keyframes barShimmer{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}
.metric-bar-fill::after{
content:'';position:absolute;top:0;left:0;width:30%;height:100%;
background:linear-gradient(90deg,transparent,rgba(255,255,255,0.25),transparent);
animation:barShimmer 2.5s ease-in-out infinite;
border-radius:inherit;
}
.metric-bar-fill{position:relative;overflow:hidden}
/* Panel hover rise */
.panel:hover{
transform:translateY(calc(var(--pty,0px) - 11px)) !important;
border-color:rgba(0,212,255,0.45) !important;
box-shadow:0 20px 50px rgba(0,0,0,0.55),0 0 40px rgba(0,212,255,0.1) !important;
transition:transform 0.3s ease,border-color 0.3s ease,box-shadow 0.3s ease;
}
/* Sparkline canvas */
.sparkline-wrap{margin:4px 0 8px;height:32px;position:relative}
.sparkline-wrap canvas{display:block;width:100%;height:32px}
/* ── HUD CORNER BRACKETS ──────────────────────────────────────────── */
/* ── MINI ARC REACTOR ─────────────────────────────────────────────── */
.tb-reactor{width:30px;height:30px;position:relative;flex-shrink:0}
.tbr-ring{position:absolute;border-radius:50%;top:50%;left:50%;transform:translate(-50%,-50%)}
.tbr-r1{width:30px;height:30px;border:1px solid rgba(0,212,255,0.35);animation:spinRing 9s linear infinite}
.tbr-r2{width:20px;height:20px;border:1px solid var(--orange);box-shadow:0 0 6px var(--orange);animation:spinRing 4s linear infinite reverse}
.tbr-core{position:absolute;width:8px;height:8px;border-radius:50%;background:radial-gradient(circle,#fff 0%,var(--cyan) 50%,var(--cyan2) 100%);box-shadow:0 0 10px var(--cyan),0 0 20px rgba(0,212,255,0.5);top:50%;left:50%;transform:translate(-50%,-50%);animation:corePulse 2s ease-in-out infinite}
/* ── LOGIN SCREEN ─────────────────────────────────────────────────── */
#loginScreen{
position:fixed;inset:0;z-index:1000;
display:flex;align-items:center;justify-content:center;flex-direction:column;
background:var(--bg);
}
.login-reactor{
width:160px;height:160px;position:relative;margin-bottom:40px;cursor:pointer;
}
.login-reactor .ring{
position:absolute;border-radius:50%;border:2px solid var(--cyan);
top:50%;left:50%;transform:translate(-50%,-50%);
box-shadow:0 0 8px var(--cyan),inset 0 0 8px rgba(0,212,255,0.1);
animation:spinRing var(--spd,4s) linear infinite;
}
.login-reactor .r1{width:160px;height:160px;--spd:8s;border-color:rgba(0,212,255,0.3)}
.login-reactor .r2{width:130px;height:130px;--spd:6s;animation-direction:reverse}
.login-reactor .r3{width:100px;height:100px;--spd:4s;border-color:var(--orange);box-shadow:0 0 12px var(--orange)}
.login-reactor .r4{width:70px;height:70px;--spd:3s;animation-direction:reverse}
.login-reactor .core{
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:40px;height:40px;border-radius:50%;
background:radial-gradient(circle,#ffffff 0%,var(--cyan) 40%,var(--cyan2) 70%,transparent 100%);
box-shadow:0 0 20px var(--cyan),0 0 40px var(--cyan),0 0 80px rgba(0,212,255,0.3);
animation:corePulse 2s ease-in-out infinite;
}
@keyframes spinRing{from{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(360deg)}}
@keyframes corePulse{0%,100%{opacity:0.8;transform:translate(-50%,-50%) scale(1)}50%{opacity:1;transform:translate(-50%,-50%) scale(1.1)}}
#loginScreen h1{
font-family:var(--font-display);font-size:2.5rem;font-weight:900;letter-spacing:8px;
color:var(--cyan);text-shadow:0 0 20px var(--cyan),0 0 40px rgba(0,212,255,0.4);
margin-bottom:8px;
}
#loginScreen p{color:var(--text-dim);font-size:0.85rem;letter-spacing:4px;text-transform:uppercase;margin-bottom:40px}
.login-form{display:flex;flex-direction:column;gap:14px;width:320px}
.login-form input{
background:rgba(0,212,255,0.05);
border:1px solid var(--panel-border);
border-radius:var(--r);
padding:12px 16px;
color:var(--cyan);
font-family:var(--font-mono);font-size:0.95rem;
outline:none;
transition:border-color 0.2s,box-shadow 0.2s;
letter-spacing:1px;
}
.login-form input:focus{border-color:var(--cyan);box-shadow:0 0 12px rgba(0,212,255,0.2)}
.login-form input::placeholder{color:var(--dim);letter-spacing:1px}
.login-form button{
background:linear-gradient(135deg,rgba(0,212,255,0.15),rgba(0,212,255,0.05));
border:1px solid var(--cyan);
border-radius:var(--r);
padding:14px;
color:var(--cyan);
font-family:var(--font-display);font-size:0.8rem;font-weight:700;
letter-spacing:3px;text-transform:uppercase;
cursor:pointer;
transition:all 0.2s;
box-shadow:0 0 12px rgba(0,212,255,0.1);
}
.login-form button:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 20px rgba(0,212,255,0.3)}
#loginError{color:var(--red);font-size:0.85rem;text-align:center;letter-spacing:1px;min-height:20px}
/* ── MAIN APP (hidden until login) ───────────────────────────────── */
#app{position:fixed;inset:0;z-index:2;display:none;flex-direction:column}
/* ── TOP NAV BAR ─────────────────────────────────────────────────── */
#topBar{
display:flex;align-items:center;justify-content:space-between;
padding:0 20px;height:48px;
background:rgba(0,8,22,0.9);
border-bottom:1px solid var(--panel-border);
flex-shrink:0;
}
.tb-logo{
font-family:var(--font-display);font-size:1rem;font-weight:900;letter-spacing:4px;
color:var(--cyan);text-shadow:0 0 10px var(--cyan);display:flex;align-items:center;gap:10px;
transition:filter 0.4s ease;
will-change:transform;
}
.tb-logo.face-tracking{
filter:drop-shadow(0 0 12px rgba(0,212,255,0.9)) drop-shadow(0 0 24px rgba(0,212,255,0.4));
}
/* Face scan crosshair overlay */
#faceScanOverlay{
position:fixed;pointer-events:none;z-index:9;
width:60px;height:60px;
display:none;
}
#faceScanOverlay::before,#faceScanOverlay::after{
content:'';position:absolute;
border-color:rgba(0,212,255,0.7);border-style:solid;
}
#faceScanOverlay::before{
top:0;left:0;width:16px;height:16px;
border-width:2px 0 0 2px;
}
#faceScanOverlay::after{
bottom:0;right:0;width:16px;height:16px;
border-width:0 2px 2px 0;
}
#faceScanOverlay .fso-tr{position:absolute;top:0;right:0;width:16px;height:16px;border:2px solid rgba(0,212,255,0.7);border-left:0;border-bottom:0}
#faceScanOverlay .fso-bl{position:absolute;bottom:0;left:0;width:16px;height:16px;border:2px solid rgba(0,212,255,0.7);border-right:0;border-top:0}
#faceScanOverlay .fso-dot{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:4px;border-radius:50%;background:rgba(0,212,255,0.6);box-shadow:0 0 6px var(--cyan)}
#faceScanOverlay .fso-label{position:absolute;bottom:-18px;left:50%;transform:translateX(-50%);font-family:var(--font-mono);font-size:0.45rem;color:rgba(0,212,255,0.7);letter-spacing:1px;white-space:nowrap}
@keyframes fsoSpin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
#faceScanOverlay .fso-ring{position:absolute;top:50%;left:50%;width:40px;height:40px;margin:-20px;border-radius:50%;border:1px solid rgba(0,212,255,0.3);border-top-color:rgba(0,212,255,0.7);animation:fsoSpin 1.2s linear infinite}
.tb-logo-dot{width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan);animation:corePulse 1.5s infinite}
.tb-center{
display:flex;gap:24px;align-items:center;
}
.tb-stat{font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim)}
.tb-stat span{color:var(--cyan)}
.tb-right{display:flex;align-items:center;gap:16px}
#clock{font-family:var(--font-mono);font-size:1rem;color:var(--cyan);letter-spacing:2px}
#date-display{font-family:var(--font-mono);font-size:0.7rem;color:var(--text-dim)}
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green);animation:blink 2s infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0.4}}
.btn-logout{
background:none;border:1px solid rgba(255,34,68,0.3);border-radius:4px;
color:rgba(255,34,68,0.7);font-family:var(--font-mono);font-size:0.7rem;
padding:4px 10px;cursor:pointer;letter-spacing:1px;transition:all 0.2s;
}
.btn-logout:hover{border-color:var(--red);color:var(--red);box-shadow:0 0 8px rgba(255,34,68,0.2)}
/* ── MAIN LAYOUT ─────────────────────────────────────────────────── */
#mainLayout{
flex:1;display:grid;
grid-template-columns:280px 1fr 280px;
grid-template-rows:1fr;
gap:10px;padding:10px;
overflow:hidden;
perspective:1200px;
transition:grid-template-columns 0.45s cubic-bezier(0.4,0,0.2,1);
}
#mainLayout.focus-mode{grid-template-columns:0px 1fr 0px}
#leftPanel,#rightPanel{
transition:opacity 0.35s ease,transform 0.45s cubic-bezier(0.4,0,0.2,1);
overflow:hidden;
}
#mainLayout.focus-mode #leftPanel{opacity:0;transform:translateX(-20px);pointer-events:none}
#mainLayout.focus-mode #rightPanel{opacity:0;transform:translateX(20px);pointer-events:none}
#leftPanel{grid-area:left}
#centerPanel{grid-area:center}
#rightPanel{grid-area:right}
#mainLayout{grid-template-areas:"left center right"}
#mainLayout.swapped{grid-template-areas:"right center left"}
#mainLayout.swapped.focus-mode #leftPanel{transform:translateX(20px)}
#mainLayout.swapped.focus-mode #rightPanel{transform:translateX(-20px)}
#btn-swap-panels{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:3px 8px;cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;letter-spacing:1px;transition:all 0.2s}
#btn-swap-panels:hover,#btn-swap-panels.active{color:var(--cyan);border-color:var(--cyan)}
/* ── MOBILE RESPONSIVE ──────────────────────────────────────────────── */
@media(max-width:900px){
#mainLayout{grid-template-columns:1fr!important;grid-template-rows:1fr!important;padding:6px;gap:6px}
#leftPanel,#rightPanel{display:none}
#leftPanel.mob-active,#rightPanel.mob-active{display:flex!important;flex-direction:column}
#centerPanel{display:none}
#centerPanel.mob-active{display:flex!important;flex-direction:column}
#topBar{padding:0 10px;height:42px}
.tb-center{display:none}
.tb-logo{font-size:0.85rem;letter-spacing:2px}
#clock{font-size:0.85rem}
#date-display{display:none}
#btn-swap-panels,.btn-panels{display:none}
#inputArea{padding:8px 6px}
#textInput{font-size:0.85rem;padding:8px 10px;min-height:40px}
#sendBtn{min-height:40px;padding:0 14px;font-size:0.65rem}
#micBtn{min-height:40px;min-width:40px;font-size:1.1rem}
#app{padding-bottom:48px}
#mobileNav{display:flex!important}
}
@media(min-width:901px){#mobileNav{display:none!important}}
#mobileNav{
display:none;position:fixed;bottom:0;left:0;right:0;z-index:100;
background:rgba(0,8,22,0.96);border-top:1px solid var(--panel-border);height:48px;
}
.mob-nav-btn{
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
font-family:var(--font-display);font-size:0.5rem;letter-spacing:1.5px;
color:var(--text-dim);cursor:pointer;gap:2px;border:none;background:none;transition:color 0.2s;
}
.mob-nav-btn .mob-icon{font-size:1rem;line-height:1}
.mob-nav-btn.active{color:var(--cyan)}
.mob-nav-btn.active .mob-icon{filter:drop-shadow(0 0 4px var(--cyan))}
/* Panel toggle button */
.btn-panels{
background:rgba(0,212,255,0.06);
border:1px solid rgba(0,212,255,0.25);
border-radius:4px;color:var(--cyan);
font-family:var(--font-display);font-size:0.55rem;
letter-spacing:1px;padding:4px 10px;cursor:pointer;
transition:all 0.2s;margin-right:6px;
}
.btn-panels:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)}
.btn-panels.focus-active{background:rgba(0,212,255,0.15);border-color:var(--cyan)}
/* Camera auto-mic button */
.btn-camera{
background:rgba(0,212,255,0.06);
border:1px solid rgba(0,212,255,0.25);
border-radius:4px;color:var(--cyan);
font-family:var(--font-display);font-size:0.55rem;
letter-spacing:1px;padding:4px 10px;cursor:pointer;
transition:all 0.2s;margin-right:6px;
}
.btn-camera:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)}
.btn-camera.cam-active{background:rgba(0,255,100,0.1);border-color:var(--green);color:var(--green)}
.btn-camera.cam-sensing{animation:camPulse 1.5s ease-in-out infinite}
.btn-agent{background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);font-family:var(--font-mono);font-size:0.62rem;letter-spacing:2px;padding:6px 10px;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all 0.2s}
.btn-agent:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)}
.btn-agent .agent-dot{width:7px;height:7px;border-radius:50%;background:var(--red);flex-shrink:0;transition:all 0.3s}
.btn-agent.agent-online .agent-dot{background:var(--green);box-shadow:0 0 6px var(--green)}
#agentModal{position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;display:none;align-items:center;justify-content:center}
#agentModal.open{display:flex}
.agent-modal-box{background:var(--panel-bg);border:1px solid var(--panel-border);padding:24px 32px;max-width:500px;width:90%;font-family:var(--font-mono)}
.agent-modal-box h3{color:var(--cyan);font-size:0.75rem;letter-spacing:4px;margin:0 0 16px}
.agent-modal-box pre{background:rgba(0,0,0,0.4);padding:12px;font-size:0.65rem;color:var(--green);overflow-x:auto;margin:8px 0;white-space:pre-wrap;word-break:break-all}
.agent-modal-close{float:right;background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;padding:4px 10px;letter-spacing:2px}
.agent-modal-close:hover{border-color:var(--red);color:var(--red)}
.agent-dl-btn{display:inline-block;margin-top:12px;background:rgba(0,212,255,0.1);border:1px solid var(--cyan);color:var(--cyan);font-family:var(--font-mono);font-size:0.65rem;letter-spacing:2px;padding:8px 16px;cursor:pointer;text-decoration:none;transition:all 0.2s}
.agent-dl-btn:hover{background:rgba(0,212,255,0.2)}
@keyframes camPulse{0%,100%{box-shadow:0 0 4px rgba(0,255,100,0.3)}50%{box-shadow:0 0 12px rgba(0,255,100,0.6)}}
/* ── PANELS ───────────────────────────────────────────────────────── */
.panel{
background:var(--panel-bg);
border:1px solid var(--panel-border);
border-radius:var(--r);
padding:14px;
overflow:hidden;
position:relative;
backdrop-filter:blur(4px);
animation:panelFloat 7s ease-in-out infinite;
will-change:transform;
}
.panel::before{
content:'';position:absolute;top:0;left:0;right:0;height:1px;
background:linear-gradient(90deg,transparent,var(--cyan),transparent);
opacity:0.4;
z-index:2;
}
/* HUD corner brackets */
.panel::after{
content:'';position:absolute;inset:0;pointer-events:none;z-index:2;
background:
linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top left / 14px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top left / 1px 14px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top right / 14px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top right / 1px 14px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom left / 14px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom left / 1px 14px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom right / 14px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom right / 1px 14px no-repeat;
opacity:0.55;
}
.panel-title{
font-family:var(--font-display);font-size:0.6rem;font-weight:700;
letter-spacing:3px;color:var(--cyan);text-transform:uppercase;
margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;
}
.panel-title .indicator{
width:6px;height:6px;border-radius:50%;background:var(--cyan);
box-shadow:0 0 6px var(--cyan);animation:blink 3s infinite;
}
.panel-title{cursor:pointer;user-select:none}
.panel-collapse-btn{
background:none;border:none;color:rgba(0,212,255,0.35);cursor:pointer;
font-size:0.65rem;padding:0;margin-left:6px;flex-shrink:0;line-height:1;
transition:transform 0.25s ease,color 0.2s;
}
.panel-collapse-btn:hover{color:var(--cyan)!important}
.panel.collapsed .panel-collapse-btn{transform:rotate(-90deg);color:rgba(0,212,255,0.5)}
.panel.collapsed > *:not(.panel-title){
display:none!important;
}
.panel.collapsed{
flex:0 0 auto!important;min-height:0!important;overflow:visible!important;
}
/* ── LEFT PANEL — SYSTEM ─────────────────────────────────────────── */
#leftPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto}
.metric-row{margin-bottom:10px}
.metric-label{
font-family:var(--font-mono);font-size:0.7rem;color:var(--text-dim);
display:flex;justify-content:space-between;margin-bottom:4px;
}
.metric-label span{color:var(--cyan)}
.metric-bar{
height:4px;border-radius:2px;
background:rgba(0,212,255,0.1);
overflow:hidden;position:relative;
}
.metric-bar-fill{
height:100%;border-radius:2px;
background:linear-gradient(90deg,var(--cyan2),var(--cyan));
box-shadow:0 0 6px var(--cyan);
transition:width 1s ease;
}
.metric-bar-fill.warn{background:linear-gradient(90deg,#cc6600,var(--orange));box-shadow:0 0 6px var(--orange)}
.metric-bar-fill.danger{background:linear-gradient(90deg,#cc0022,var(--red));box-shadow:0 0 6px var(--red)}
.service-row{
display:flex;align-items:center;justify-content:space-between;
padding:5px 0;border-bottom:1px solid rgba(0,212,255,0.06);
font-family:var(--font-mono);font-size:0.72rem;
}
.service-row:last-child{border-bottom:none}
.svc-name{color:var(--text-dim)}
.svc-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.svc-dot.on{background:var(--green);box-shadow:0 0 4px var(--green)}
.svc-dot.off{background:var(--red);box-shadow:0 0 4px var(--red)}
.do-section{margin-top:8px}
.val-row{
display:flex;justify-content:space-between;
font-family:var(--font-mono);font-size:0.72rem;padding:3px 0;
}
.val-row .lbl{color:var(--text-dim)}
.val-row .val{color:var(--cyan)}
.val-row .val.ok{color:var(--green)}
.val-row .val.warn{color:var(--orange)}
.val-row .val.danger{color:var(--red)}
/* ── CENTER PANEL ────────────────────────────────────────────────── */
#centerPanel{
display:flex;flex-direction:column;align-items:center;gap:10px;overflow:hidden;
}
/* ARC REACTOR ─────────────────────────────────────────────────────── */
#arcReactor{position:relative;width:220px;height:220px;flex-shrink:0}
.arc-ring{
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
border-radius:50%;border-style:solid;border-color:var(--cyan);
animation:spinRing var(--spd,6s) linear infinite var(--dir,normal);
}
.arc-ring.r1{width:220px;height:220px;border-width:1px;border-color:rgba(0,212,255,0.15);--spd:20s}
.arc-ring.r2{width:195px;height:195px;border-width:1px;border-color:rgba(0,212,255,0.25);--spd:15s;--dir:reverse}
.arc-ring.r3{
width:170px;height:170px;border-width:2px;--spd:10s;
border-top-color:transparent;border-right-color:transparent;
box-shadow:0 0 6px var(--cyan);
}
.arc-ring.r4{
width:145px;height:145px;border-width:1px;--spd:8s;--dir:reverse;
border-bottom-color:transparent;border-left-color:transparent;
}
.arc-ring.r5{
width:115px;height:115px;border-width:2px;--spd:5s;
border-color:var(--orange);border-right-color:transparent;
box-shadow:0 0 8px var(--orange);
}
.arc-ring.r6{width:88px;height:88px;border-width:1px;--spd:4s;--dir:reverse;border-color:rgba(0,212,255,0.6)}
.arc-ring.r7{
width:62px;height:62px;border-width:2px;--spd:3s;
border-left-color:transparent;border-bottom-color:transparent;
}
.arc-core{
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:36px;height:36px;border-radius:50%;
background:radial-gradient(circle,#fff 0%,var(--cyan) 35%,var(--cyan2) 65%,transparent 100%);
box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 60px rgba(0,212,255,0.4),0 0 100px rgba(0,212,255,0.15);
animation:corePulse 2s ease-in-out infinite;
}
/* HUD TICKS ───────────────────────────────────────────────────────── */
.hud-ticks{
position:absolute;inset:0;border-radius:50%;
}
.hud-ticks::before,.hud-ticks::after{
content:'';position:absolute;
background:var(--cyan);opacity:0.5;
}
.hud-ticks::before{left:50%;top:0;width:1px;height:12px;transform:translateX(-50%)}
.hud-ticks::after{left:0;top:50%;height:1px;width:12px;transform:translateY(-50%)}
/* ── CHAT AREA ───────────────────────────────────────────────────── */
#chatArea{
flex:1;width:100%;display:flex;flex-direction:column;gap:8px;overflow:hidden;
}
#chatLog{
flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;
padding:4px 0;
scrollbar-width:thin;scrollbar-color:var(--dim) transparent;
}
.msg{
padding:10px 14px;border-radius:var(--r);max-width:100%;
font-family:var(--font-body);font-size:0.9rem;line-height:1.5;
animation:msgIn 0.3s ease;
}
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.msg.user{
background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.15);
border-left:3px solid var(--cyan);
font-family:var(--font-mono);font-size:0.8rem;color:var(--text);
}
.msg.user::before{content:'';display:none}
.msg.jarvis{
background:rgba(0,40,80,0.4);border:1px solid rgba(0,212,255,0.1);
border-left:3px solid var(--orange);
}
.msg.jarvis::before{content:'◆ JARVIS ';color:var(--orange);font-weight:700;font-size:0.7rem;letter-spacing:2px}
.msg.system{
border:1px solid rgba(255,215,0,0.2);background:rgba(255,215,0,0.03);
font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;
border-radius:4px;
}
/* ── CONTEXT CHIP ───────────────────────────────────────────────── */
#contextChip{
display:none;flex-shrink:0;
padding:5px 10px;margin-bottom:6px;
background:rgba(0,212,255,0.07);
border:1px solid rgba(0,212,255,0.35);
border-radius:var(--r);
display:flex;align-items:center;gap:8px;
font-family:var(--font-display);font-size:0.58rem;letter-spacing:1px;
}
#contextChip.visible{display:flex}
#contextLabel{color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#contextType{color:var(--text-dim)}
#contextClear{
background:none;border:none;color:var(--dim);cursor:pointer;
font-size:1rem;line-height:1;padding:0 2px;flex-shrink:0;
}
#contextClear:hover{color:var(--red)}
/* Planner mini panel */
/* Clickable panel items */
.vm-card{cursor:pointer;transition:background 0.15s,border-color 0.15s}
.vm-card:hover{background:rgba(0,212,255,0.07);border-color:rgba(0,212,255,0.4)}
.vm-card.ctx-active{border-color:var(--cyan);background:rgba(0,212,255,0.1)}
.device-item{cursor:pointer;transition:background 0.15s}
.device-item:hover{background:rgba(0,212,255,0.05)}
.device-item.ctx-active{background:rgba(0,212,255,0.1);border-color:rgba(0,212,255,0.4)}
.alert-item{cursor:pointer}
.alert-item.ctx-active{border-color:var(--cyan) !important;box-shadow:0 0 8px rgba(0,212,255,0.15)}
.tier-badge{
display:inline-block;font-family:var(--font-display);font-size:0.42rem;
letter-spacing:1.5px;padding:1px 5px;border-radius:2px;margin-top:4px;
opacity:0.7;border:1px solid;vertical-align:middle;
}
.tier-badge.kb {color:#00d4ff;border-color:rgba(0,212,255,0.4);background:rgba(0,212,255,0.06)}
.tier-badge.groq {color:#f5a623;border-color:rgba(245,166,35,0.4);background:rgba(245,166,35,0.06)}
.tier-badge.claude{color:#b57cf5;border-color:rgba(181,124,245,0.4);background:rgba(181,124,245,0.06)}
.tier-badge.ollama{color:#7ef55a;border-color:rgba(126,245,90,0.4);background:rgba(126,245,90,0.06)}
.news-item{cursor:pointer;transition:background 0.15s}
.news-item:hover{background:rgba(0,212,255,0.04)}
.news-item.ctx-active{background:rgba(0,212,255,0.08);border-color:rgba(0,212,255,0.4)}
.ha-ask-btn{
background:none;border:1px solid rgba(0,212,255,0.2);border-radius:3px;
color:var(--text-dim);font-size:0.55rem;padding:1px 5px;cursor:pointer;
font-family:var(--font-display);letter-spacing:1px;flex-shrink:0;
transition:all 0.15s;
}
.ha-ask-btn:hover{border-color:var(--cyan);color:var(--cyan);background:rgba(0,212,255,0.1)}
/* ── VOICE INPUT ─────────────────────────────────────────────────── */
#inputArea{
display:flex;gap:10px;align-items:center;flex-shrink:0;
}
#textInput{
flex:1;background:rgba(0,212,255,0.04);
border:1px solid var(--panel-border);border-radius:var(--r);
padding:10px 14px;color:var(--text);
font-family:var(--font-mono);font-size:0.85rem;outline:none;
transition:border-color 0.2s;
}
#textInput:focus{border-color:var(--cyan);box-shadow:0 0 10px rgba(0,212,255,0.1)}
#textInput::placeholder{color:var(--dim)}
#sendBtn{
background:rgba(0,212,255,0.1);border:1px solid var(--dim);
border-radius:var(--r);padding:10px 16px;
color:var(--cyan);font-family:var(--font-display);font-size:0.65rem;font-weight:700;
letter-spacing:2px;cursor:pointer;transition:all 0.2s;white-space:nowrap;
}
#sendBtn:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 12px rgba(0,212,255,0.2)}
/* MIC BUTTON ──────────────────────────────────────────────────────── */
#micBtn{
width:48px;height:48px;border-radius:50%;
background:radial-gradient(circle,rgba(255,102,0,0.15),rgba(0,8,22,0.9));
border:2px solid var(--orange);
display:flex;align-items:center;justify-content:center;
cursor:pointer;transition:all 0.2s;flex-shrink:0;
box-shadow:0 0 10px rgba(255,102,0,0.2);
}
#micBtn:hover{box-shadow:0 0 20px rgba(255,102,0,0.4);transform:scale(1.05)}
#micBtn.listening{
border-color:var(--red);background:radial-gradient(circle,rgba(255,34,68,0.2),rgba(0,8,22,0.9));
box-shadow:0 0 25px rgba(255,34,68,0.5);
animation:micPulse 0.8s ease-in-out infinite;
}
@keyframes micPulse{0%,100%{box-shadow:0 0 25px rgba(255,34,68,0.5)}50%{box-shadow:0 0 40px rgba(255,34,68,0.8),0 0 60px rgba(255,34,68,0.3)}}
#micBtn.muted{border-color:var(--text-dim);background:radial-gradient(circle,rgba(200,230,255,0.05),rgba(0,8,22,0.9));box-shadow:0 0 8px rgba(200,230,255,0.1);}
#micIcon{font-size:20px}
/* WAVEFORM ─────────────────────────────────────────────────────────── */
#waveform{
display:none;align-items:center;justify-content:center;gap:3px;height:30px;
}
#waveform.active{display:flex}
.wave-bar{
width:3px;border-radius:2px;background:var(--red);
animation:waveBounce var(--d,0.6s) ease-in-out infinite alternate;
box-shadow:0 0 4px var(--red);
}
@keyframes waveBounce{from{height:4px}to{height:24px}}
/* ── RIGHT PANEL ─────────────────────────────────────────────────── */
#rightPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto}
/* NETWORK DEVICE LIST ─────────────────────────────────────────────── */
.device-item{
display:flex;align-items:center;gap:8px;padding:6px 0;
border-bottom:1px solid rgba(0,212,255,0.06);
font-family:var(--font-mono);font-size:0.72rem;
}
.device-item:last-child{border-bottom:none}
.device-status{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.device-status.on{background:var(--green);box-shadow:0 0 4px var(--green)}
.device-status.off{background:var(--red);box-shadow:0 0 4px var(--red)}
.device-status.unk{background:var(--yellow);box-shadow:0 0 4px var(--yellow);opacity:0.6}
.device-info{flex:1;min-width:0}
.device-name{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.device-ip{color:var(--text-dim);font-size:0.65rem}
/* VM CARDS ─────────────────────────────────────────────────────────── */
.vm-card{
background:rgba(0,212,255,0.04);
border:1px solid rgba(0,212,255,0.12);border-radius:6px;
padding:8px 10px;margin-bottom:6px;
font-family:var(--font-mono);font-size:0.72rem;
}
.vm-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
.vm-name{color:var(--cyan);font-weight:600}
.vm-id{color:var(--text-dim);font-size:0.65rem}
.vm-metrics{display:grid;grid-template-columns:1fr 1fr;gap:4px}
.vm-metric{color:var(--text-dim)}
.vm-metric span{color:var(--text)}
/* HA DEVICES ────────────────────────────────────────────────────────── */
.ha-table{width:100%;border-collapse:collapse}
.ha-thead th{
font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;
color:var(--text-dim);padding:4px 2px 6px;border-bottom:1px solid rgba(0,212,255,0.15);
text-align:left;
}
.ha-thead th:nth-child(3){text-align:center}
.ha-thead th:nth-child(4){text-align:center}
.ha-row{transition:background 0.12s}
.ha-row:hover{background:rgba(0,212,255,0.05)}
.ha-row td{
padding:4px 2px;border-bottom:1px solid rgba(0,212,255,0.05);
font-family:var(--font-mono);font-size:0.70rem;vertical-align:middle;
}
.ha-col-domain{font-size:0.85rem;text-align:center;width:20px;padding-right:4px!important}
.ha-col-name{color:var(--text);max-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ha-col-state{text-align:center;width:30px;font-size:0.62rem;font-weight:700;white-space:nowrap}
.ha-col-state.on{color:var(--green)}
.ha-col-state.off{color:var(--text-dim)}
.ha-col-ctrl{text-align:center;width:36px}
/* toggle switch */
.ha-toggle{
position:relative;display:inline-block;width:30px;height:15px;cursor:pointer;
}
.ha-toggle input{opacity:0;width:0;height:0;position:absolute}
.ha-slider{
position:absolute;inset:0;border-radius:8px;
background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.14);
transition:background 0.18s,border-color 0.18s;
}
.ha-slider::before{
content:'';position:absolute;left:2px;top:2px;
width:9px;height:9px;border-radius:50%;
background:var(--text-dim);transition:transform 0.18s,background 0.18s;
}
.ha-toggle input:checked + .ha-slider{background:rgba(0,255,100,0.22);border-color:var(--green)}
.ha-toggle input:checked + .ha-slider::before{transform:translateX(15px);background:var(--green)}
/* scene activate button */
.ha-scene-btn{
background:transparent;border:1px solid var(--cyan);border-radius:3px;
color:var(--cyan);font-size:0.58rem;padding:1px 4px;cursor:pointer;
font-family:var(--font-mono);transition:background 0.15s;
}
.ha-scene-btn:hover{background:rgba(0,212,255,0.15)}
/* ALERTS BADGE ─────────────────────────────────────────────────────── */
.alert-item{
padding:7px 10px;border-radius:6px;
font-family:var(--font-mono);font-size:0.72rem;
margin-bottom:6px;border-left:3px solid var(--yellow);
background:rgba(255,215,0,0.05);
display:flex;justify-content:space-between;align-items:center;
}
.alert-item.critical{border-color:var(--red);background:rgba(255,34,68,0.05)}
.alert-item.info{border-color:var(--cyan);background:rgba(0,212,255,0.04)}
/* ── BOTTOM STATUS BAR ──────────────────────────────────────────── */
#bottomBar{
height:32px;flex-shrink:0;
background:rgba(0,8,22,0.9);
border-top:1px solid var(--panel-border);
display:flex;align-items:center;padding:0 20px;gap:24px;
font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);
}
#bottomBar span{color:var(--cyan)}
.bb-item{display:flex;align-items:center;gap:6px}
.bb-dot{width:5px;height:5px;border-radius:50%}
.bb-dot.online{background:var(--green);box-shadow:0 0 4px var(--green)}
.bb-dot.offline{background:var(--red)}
/* ── THINKING INDICATOR ──────────────────────────────────────────── */
.thinking{display:flex;gap:4px;align-items:center;padding:8px 14px}
.thinking-dot{
width:6px;height:6px;border-radius:50%;background:var(--orange);
animation:thinkBounce 0.6s ease-in-out infinite;
}
.thinking-dot:nth-child(2){animation-delay:0.15s}
.thinking-dot:nth-child(3){animation-delay:0.3s}
@keyframes thinkBounce{0%,100%{transform:translateY(0);opacity:0.5}50%{transform:translateY(-6px);opacity:1}}
/* ── ARC REACTOR HEALTH STATES ──────────────────────────────────── */
#arcReactor.health-warning .arc-ring.r3{border-color:rgba(245,166,35,0.8);box-shadow:0 0 8px #f5a623}
#arcReactor.health-warning .arc-ring.r5{border-color:rgba(245,166,35,0.6)}
#arcReactor.health-warning .arc-ring.r7{border-color:rgba(245,166,35,0.5)}
#arcReactor.health-warning .arc-core{
background:radial-gradient(circle,#fff 0%,#f5a623 35%,#c97b00 65%,transparent 100%);
box-shadow:0 0 15px #f5a623,0 0 30px #f5a623,0 0 60px rgba(245,166,35,0.4);
animation:corePulse 1.2s ease-in-out infinite;
}
#arcReactor.health-critical .arc-ring.r3{border-color:rgba(255,34,68,0.9);box-shadow:0 0 10px var(--red)}
#arcReactor.health-critical .arc-ring.r5{border-color:rgba(255,34,68,0.7)}
#arcReactor.health-critical .arc-ring.r7{border-color:rgba(255,34,68,0.6)}
#arcReactor.health-critical .arc-core{
background:radial-gradient(circle,#fff 0%,var(--red) 35%,#8b0000 65%,transparent 100%);
box-shadow:0 0 15px var(--red),0 0 30px var(--red),0 0 60px rgba(255,34,68,0.5);
animation:corePulse 0.6s ease-in-out infinite;
}
/* ── SPEAKING ANIMATION ──────────────────────────────────────────── */
@keyframes speakPulse{
0%,100%{opacity:0.85;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 50px rgba(0,212,255,0.3)}
50%{opacity:1;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 25px var(--cyan),0 0 55px var(--cyan),0 0 100px rgba(0,212,255,0.6),0 0 150px rgba(0,212,255,0.25)}
}
#arcReactor.speaking .arc-core{
animation:speakPulse 0.45s ease-in-out infinite;
}
#arcReactor.speaking .arc-ring.r3{border-color:rgba(0,212,255,0.7)}
#arcReactor.speaking .arc-ring.r5{border-color:rgba(255,165,0,0.6)}
/* ── SCROLLBAR ───────────────────────────────────────────────────── */
::-webkit-scrollbar{width:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--dim);border-radius:2px}
/* ── TABS (for right panel) ────────────────────────────────────── */
.tab-bar{display:flex;gap:0;margin-bottom:10px;border-bottom:1px solid var(--panel-border);flex-wrap:wrap}
.forecast-card{background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.15);border-radius:4px;padding:5px 3px;text-align:center;min-width:0}
.forecast-card .fc-day{font-family:var(--font-display);font-size:0.55rem;color:var(--text-dim);letter-spacing:1px;font-weight:700}
.forecast-card .fc-temps{font-size:0.6rem;color:var(--cyan);font-family:var(--font-display);margin-top:2px}
.forecast-card .fc-rain{font-size:0.5rem;color:#4fc3f7;min-height:0.7rem}
.news-item{padding:6px 0;border-bottom:1px solid rgba(0,212,255,0.08)}
.news-item:last-child{border-bottom:none}
.news-source{font-size:0.5rem;color:var(--cyan);font-family:var(--font-display);letter-spacing:1px}
.news-title{font-size:0.62rem;color:var(--text-primary);line-height:1.3;margin-top:2px}
.news-time{font-size:0.5rem;color:var(--text-dim);margin-top:2px}
.news-cat-header{font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--cyan);margin:8px 0 4px;border-bottom:1px solid rgba(0,212,255,0.2);padding-bottom:3px}
.tab{
font-family:var(--font-display);font-size:0.55rem;font-weight:700;letter-spacing:2px;
padding:6px 10px;cursor:pointer;color:var(--text-dim);
border-bottom:2px solid transparent;margin-bottom:-1px;transition:all 0.2s;
}
.tab.active{color:var(--cyan);border-bottom-color:var(--cyan)}
.tab-pane{display:none}
.tab-pane.active{display:block}
/* ── UTILITY ─────────────────────────────────────────────────────── */
.loading-shimmer{
background:linear-gradient(90deg,rgba(0,212,255,0.04) 0%,rgba(0,212,255,0.12) 50%,rgba(0,212,255,0.04) 100%);
background-size:200% 100%;
animation:shimmer 1.5s infinite;border-radius:4px;height:12px;
}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
.text-cyan{color:var(--cyan)}
.text-green{color:var(--green)}
.text-orange{color:var(--orange)}
.text-red{color:var(--red)}
.text-dim{color:var(--text-dim)}
/* ① HUD CORNER RINGS */
#hudCornersCanvas{position:fixed;inset:0;z-index:3;pointer-events:none}
/* ② DATA STREAM COLUMNS */
#dataStreamCanvas{position:fixed;inset:0;z-index:0;pointer-events:none}
/* ③ NETWORK TOPOLOGY */
#topoCanvas{display:block;width:100%;flex-shrink:0;cursor:default;border-bottom:1px solid var(--panel-border);margin-bottom:6px}
/* ④ BOOT SEQUENCE */
@keyframes bootLeft{0%{opacity:0;transform:translateX(-70px)}100%{opacity:1;transform:none}}
@keyframes bootRight{0%{opacity:0;transform:translateX(70px)}100%{opacity:1;transform:none}}
@keyframes bootDown{0%{opacity:0;transform:translateY(-18px)}100%{opacity:1;transform:none}}
@keyframes bootCenter{0%{opacity:0;transform:scale(0.94) translateY(14px)}100%{opacity:1;transform:none}}
.boot-left{animation:bootLeft 0.55s cubic-bezier(0.4,0,0.2,1) both}
.boot-right{animation:bootRight 0.55s cubic-bezier(0.4,0,0.2,1) both}
.boot-top{animation:bootDown 0.4s ease both}
.boot-center{animation:bootCenter 0.65s cubic-bezier(0.4,0,0.2,1) both}
/* ⑤ BREATHING EDGE VIGNETTE */
#vignetteOverlay{position:fixed;inset:0;pointer-events:none;z-index:1;
background:radial-gradient(ellipse at 50% 50%,transparent 32%,rgba(0,2,18,0.6) 100%);
animation:vignettePulse 5s ease-in-out infinite}
#vignetteOverlay.alert-vignette{background:radial-gradient(ellipse at 50% 50%,transparent 32%,rgba(20,0,8,0.65) 100%)}
@keyframes vignettePulse{0%,100%{opacity:0.75}50%{opacity:1}}
/* ⑥ EKG HEARTBEAT */
#ekgWrap{flex:1;max-width:180px;display:flex;align-items:center;overflow:hidden}
#ekgCanvas{display:block;width:100%;height:22px;opacity:0.8}
/* ⑦ AUDIO RING */
.tb-reactor{position:relative}
#audioRingCanvas{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:60px;height:60px;pointer-events:none;z-index:4}
/* ⑧ TYPEWRITER CURSOR */
@keyframes cursorBlink{0%,100%{opacity:1}49%{opacity:1}50%,99%{opacity:0}}
.type-cursor{display:inline-block;width:6px;height:0.82em;background:var(--cyan);margin-left:1px;
vertical-align:text-bottom;animation:cursorBlink 0.7s step-end infinite}
/* ⑨ STATIC NOISE BURST */
@keyframes staticBurst{0%{opacity:0}10%{opacity:1}90%{opacity:1}100%{opacity:0}}
.panel-noise-layer{position:absolute;inset:0;pointer-events:none;z-index:20;border-radius:var(--r);
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.1'/%3E%3C/svg%3E");
background-size:100% 100%;mix-blend-mode:screen;animation:staticBurst 0.28s ease forwards}
/* ── NETWORK MAP OVERLAY ─────────────────────────────────────────────── */
#netMapOverlay{position:fixed;top:0;left:0;width:100vw;height:100vh;
z-index:200;display:none;flex-direction:column;
background:rgba(0,4,18,0.96);border:1px solid rgba(0,212,255,0.28);
border-top:none;border-left:none;transform-origin:0 0;backdrop-filter:blur(14px);
overflow:hidden;box-shadow:6px 6px 40px rgba(0,0,0,0.75),0 0 50px rgba(0,212,255,0.05)}
#netMapOverlay::after{content:'';position:absolute;inset:0;pointer-events:none;z-index:1;
background:
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 18px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 1px 18px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 18px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 1px 18px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 18px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 1px 18px no-repeat,
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 18px 1px no-repeat,
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 1px 18px no-repeat}
#netMapOverlay.nm-open{display:flex;animation:nmExplode 0.45s cubic-bezier(0.4,0,0.2,1) forwards}
#netMapOverlay.nm-closing{animation:nmCollapse 0.3s cubic-bezier(0.4,0,0.2,1) forwards}
@keyframes nmExplode{0%{transform:scale(0.04,0.06);opacity:0}60%{opacity:1}100%{transform:scale(1);opacity:1}}
@keyframes nmCollapse{0%{transform:scale(1);opacity:1}100%{transform:scale(0.04,0.06);opacity:0}}
#nmHeader{display:flex;align-items:center;justify-content:space-between;padding:7px 16px;
flex-shrink:0;border-bottom:1px solid rgba(0,212,255,0.16);background:rgba(0,8,28,0.6);z-index:2;position:relative}
#nmTitle{font-family:var(--font-display);font-size:0.62rem;letter-spacing:4px;color:var(--cyan);display:flex;align-items:center;gap:10px}
#nmTitle .nm-pulse{width:6px;height:6px;border-radius:50%;background:var(--cyan);box-shadow:0 0 7px var(--cyan);animation:corePulse 1.5s infinite}
#nmStats{font-family:var(--font-mono);font-size:0.58rem;color:var(--text-dim);display:flex;gap:16px}
#nmStats span{color:var(--cyan)}
#nmClose{background:none;border:1px solid rgba(0,212,255,0.3);color:var(--text-dim);font-family:var(--font-mono);font-size:0.56rem;padding:3px 10px;cursor:pointer;letter-spacing:2px;transition:all 0.2s}
#nmClose:hover{border-color:var(--red);color:var(--red)}
#nmCanvas{flex:1;display:block;z-index:2;position:relative}
#nmLegend{display:flex;gap:16px;align-items:center;padding:5px 16px;flex-shrink:0;
border-top:1px solid rgba(0,212,255,0.1);font-family:var(--font-mono);font-size:0.54rem;
color:var(--text-dim);background:rgba(0,8,28,0.6);z-index:2;position:relative}
.nm-leg-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:4px;flex-shrink:0}
#nmNodeInfo{position:absolute;pointer-events:none;z-index:10;background:rgba(0,8,30,0.95);
border:1px solid rgba(0,212,255,0.4);padding:7px 11px;font-family:var(--font-mono);
font-size:0.6rem;display:none;min-width:150px;box-shadow:0 0 18px rgba(0,212,255,0.12)}
#nmNodeInfo .ni-title{color:var(--cyan);font-size:0.62rem;letter-spacing:2px;margin-bottom:3px}
#nmNodeInfo .ni-row{color:var(--text-dim);margin:2px 0}
/* ── SLEEP MODE ──────────────────────────────────────────────────────── */
#sleepOverlay{
position:fixed;inset:0;z-index:500;display:none;
flex-direction:column;align-items:center;justify-content:center;
background:rgba(0,2,10,0.94);
backdrop-filter:blur(6px);
}
#sleepOverlay.active{display:flex}
@keyframes sleepPulse{0%,100%{opacity:0.25;transform:scale(1)}50%{opacity:0.6;transform:scale(1.06)}}
@keyframes sleepCoreGlow{0%,100%{box-shadow:0 0 30px rgba(0,212,255,0.15),0 0 60px rgba(0,212,255,0.05)}50%{box-shadow:0 0 50px rgba(0,212,255,0.3),0 0 100px rgba(0,212,255,0.1)}}
.sleep-reactor{position:relative;width:120px;height:120px;margin-bottom:40px}
.sleep-ring{position:absolute;border-radius:50%;border:1px solid rgba(0,212,255,0.2);top:50%;left:50%;transform:translate(-50%,-50%)}
.sleep-ring.sr1{width:120px;height:120px;animation:spinRing 18s linear infinite;border-color:rgba(0,212,255,0.12)}
.sleep-ring.sr2{width:85px;height:85px;animation:spinRing 12s linear infinite reverse;border-color:rgba(0,212,255,0.18)}
.sleep-ring.sr3{width:52px;height:52px;animation:spinRing 8s linear infinite;border-color:rgba(0,80,160,0.3)}
.sleep-core{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:24px;height:24px;border-radius:50%;
background:radial-gradient(circle,rgba(0,150,220,0.6) 0%,rgba(0,80,160,0.3) 60%,transparent 100%);
animation:sleepCoreGlow 4s ease-in-out infinite,sleepPulse 4s ease-in-out infinite}
.sleep-label{font-family:var(--font-display);font-size:0.6rem;letter-spacing:6px;
color:rgba(0,212,255,0.35);animation:sleepPulse 4s ease-in-out infinite;margin-bottom:12px}
.sleep-sub{font-family:var(--font-mono);font-size:0.55rem;letter-spacing:3px;
color:rgba(0,212,255,0.2);animation:sleepPulse 4s ease-in-out infinite;animation-delay:0.5s}
/* App dims on sleep */
#app.sleeping #mainLayout,#app.sleeping #topBar,#app.sleeping #bottomBar{
pointer-events:none;
filter:brightness(0.08) saturate(0.3);
transition:filter 1.2s ease;
}
#app.sleeping #sleepOverlay{display:flex}
/* ── GUARDIAN MODE ────────────────────────────────────────────────── */
.guardian-event{display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-bottom:1px solid var(--panel-border);cursor:pointer}
.guardian-event:hover{background:rgba(0,212,255,0.04)}
.guardian-event.critical{border-left:3px solid var(--red)}
.guardian-event.warning{border-left:3px solid #f5a623}
.guardian-event.info{border-left:3px solid rgba(0,212,255,0.3)}
.guardian-event.acked{opacity:0.45}
.guardian-sev{font-family:var(--font-mono);font-size:0.5rem;padding:2px 4px;border-radius:2px;flex-shrink:0;letter-spacing:1px;margin-top:1px}
.guardian-sev.critical{background:rgba(255,34,68,0.15);color:var(--red);border:1px solid rgba(255,34,68,0.3)}
.guardian-sev.warning{background:rgba(245,166,35,0.12);color:#f5a623;border:1px solid rgba(245,166,35,0.3)}
.guardian-sev.info{background:rgba(0,212,255,0.08);color:var(--cyan);border:1px solid rgba(0,212,255,0.2)}
.guardian-msg{flex:1;font-size:0.62rem;line-height:1.4;color:var(--text)}
.guardian-time{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);flex-shrink:0}
.guardian-ai{font-size:0.6rem;color:rgba(0,212,255,0.6);margin-top:3px;font-style:italic}
#bb-guardian-dot.all-clear{background:var(--green);box-shadow:0 0 5px var(--green)}
#bb-guardian-dot.warning{background:#f5a623;box-shadow:0 0 5px #f5a623}
#bb-guardian-dot.critical{background:var(--red);box-shadow:0 0 5px var(--red);animation:pulse 1.2s ease-in-out infinite}
.guardian-ack-btn{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:1px 5px;border-radius:2px;font-size:0.5rem;cursor:pointer;font-family:var(--font-mono);letter-spacing:1px;flex-shrink:0}
.guardian-ack-btn:hover{color:var(--cyan);border-color:var(--cyan)}
/* ── VISION PROTOCOL — screenshot lightbox ───────────────────────── */
#vision-lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:9999;flex-direction:column;align-items:center;justify-content:flex-start;padding:20px;overflow-y:auto}
#vision-lightbox.open{display:flex}
#vision-lb-header{width:100%;max-width:960px;display:flex;align-items:center;gap:10px;margin-bottom:12px}
#vision-lb-title{font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);flex:1}
#vision-lb-close{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--font-display);font-size:0.6rem}
#vision-lb-img{max-width:960px;width:100%;border:1px solid var(--panel-border);border-radius:4px;margin-bottom:12px}
#vision-lb-analysis{max-width:960px;width:100%;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:14px 16px;font-size:0.65rem;line-height:1.7;color:var(--text);white-space:pre-wrap}
#vision-lb-spinner{color:var(--cyan);font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;animation:pulse 1.5s ease-in-out infinite;margin:30px auto}
/* ── COMMS PROTOCOL — email triage cards ─────────────────────────── */
.comms-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
.comms-card-head{display:flex;align-items:center;gap:7px;padding:7px 10px;cursor:pointer;user-select:none}
.comms-card-head:hover{background:rgba(0,212,255,0.06)}
.comms-card-subject{font-family:var(--font-display);font-size:0.58rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.comms-card-cat{font-family:var(--font-mono);font-size:0.52rem;padding:2px 5px;border-radius:2px;flex-shrink:0;text-transform:uppercase;letter-spacing:1px}
.comms-card-cat.urgent{color:#ff2244;border:1px solid rgba(255,34,68,0.4);animation:pulse 1.5s ease-in-out infinite}
.comms-card-cat.action{color:#ffd700;border:1px solid rgba(255,215,0,0.4)}
.comms-card-cat.reply{color:var(--cyan);border:1px solid rgba(0,212,255,0.3)}
.comms-card-cat.meeting{color:#a78bfa;border:1px solid rgba(167,139,250,0.4)}
.comms-card-cat.info{color:var(--text-dim);border:1px solid rgba(255,255,255,0.1)}
.comms-card-cat.promo,.comms-card-cat.spam{color:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.08)}
.comms-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
.comms-card.open .comms-card-body{display:block}
.comms-card-from{font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);margin:7px 0 3px}
.comms-card-summary{font-size:0.62rem;line-height:1.5;color:var(--text);margin:5px 0}
.comms-draft-label{font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px}
.comms-draft{font-size:0.6rem;line-height:1.5;color:rgba(0,212,255,0.7);background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.15);border-radius:3px;padding:7px 9px;white-space:pre-wrap;max-height:160px;overflow-y:auto}
.comms-empty{text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px}
.comms-header-bar{display:flex;gap:5px;margin-bottom:7px;flex-wrap:wrap}
.comms-filter-btn{flex:1;min-width:50px;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:4px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer;text-align:center}
.comms-filter-btn.active,.comms-filter-btn:hover{background:rgba(0,212,255,0.12);color:var(--cyan);border-color:var(--cyan)}
.comms-triage-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
.comms-triage-btn:hover{background:rgba(0,212,255,0.12)}
.comms-prio{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);flex-shrink:0}
.comms-section-label{font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);border-bottom:1px solid var(--panel-border);padding:6px 0 4px;margin:8px 0 6px}
.comms-compose-btn{width:100%;background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:5px}
.comms-compose-btn:hover{background:rgba(0,212,255,0.15)}
.comms-send-btn{flex:1;background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.4);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer}
.comms-send-btn:hover{background:rgba(0,212,255,0.2)}
.comms-send-btn:disabled{opacity:0.4;cursor:not-allowed}
.comms-outbox-card{background:rgba(0,212,255,0.03);border:1px solid rgba(0,212,255,0.1);border-radius:3px;padding:6px 9px;margin-bottom:5px}
.comms-outbox-to{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)}
.comms-outbox-subj{font-family:var(--font-display);font-size:0.55rem;color:var(--cyan);margin:2px 0}
.comms-outbox-status{font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px}
.comms-outbox-status.sent{color:#00ff88;border:1px solid rgba(0,255,136,0.3)}
.comms-outbox-status.failed{color:#ff2244;border:1px solid rgba(255,34,68,0.3)}
.comms-outbox-status.queued{color:#ffd700;border:1px solid rgba(255,215,0,0.3)}
/* compose modal */
.comms-compose-modal{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9000}
.comms-compose-inner{background:#0a0e14;border:1px solid var(--cyan);border-radius:6px;padding:16px;width:min(90vw,480px);max-height:80vh;overflow-y:auto}
.comms-compose-title{font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);margin-bottom:12px}
.comms-compose-field{width:100%;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:3px;padding:6px 8px;color:var(--text);font-family:var(--font-mono);font-size:0.6rem;box-sizing:border-box;margin-bottom:7px}
.comms-compose-field:focus{outline:none;border-color:var(--cyan)}
.comms-compose-actions{display:flex;gap:6px;margin-top:8px}
/* ── DIRECTIVES HUD ──────────────────────────────────────────────── */
.dir-card{background:rgba(0,212,255,0.03);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
.dir-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
.dir-card-head:hover{background:rgba(0,212,255,0.06)}
.dir-card-title{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dir-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
.dir-card.open .dir-card-body{display:block}
.dir-progress-bar{height:5px;background:rgba(255,255,255,0.08);border-radius:3px;margin:6px 0}
.dir-progress-fill{height:100%;border-radius:3px;transition:width 0.4s ease}
.dir-kr-row{display:flex;align-items:center;gap:6px;margin:4px 0;font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)}
.dir-kr-bar{flex:1;height:3px;background:rgba(255,255,255,0.06);border-radius:2px}
.dir-kr-fill{height:100%;border-radius:2px;background:rgba(0,212,255,0.5)}
.dir-admin-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
.dir-admin-btn:hover{background:rgba(0,212,255,0.12)}
/* ── MISSION OPS HUD ─────────────────────────────────────────────── */
.mission-card{background:rgba(0,212,255,0.03);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
.mission-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
.mission-card-head:hover{background:rgba(0,212,255,0.06)}
.mission-card-name{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mission-card-trigger{font-family:var(--font-mono);font-size:0.5rem;padding:2px 5px;border-radius:2px;color:var(--text-dim);border:1px solid rgba(255,255,255,0.1)}
.mission-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
.mission-card.open .mission-card-body{display:block}
.mission-run-bar{display:flex;gap:5px;margin-top:8px}
.mission-run-btn{flex:1;background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer}
.mission-run-btn:hover{background:rgba(0,212,255,0.15)}
.mission-run-btn:disabled{opacity:0.4;cursor:not-allowed}
.mission-run-item{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem}
.mission-run-status.done{color:#00ff88}
.mission-run-status.failed{color:#ff2244}
.mission-run-status.running{color:#ffd700;animation:pulse 1.5s ease-in-out infinite}
.mission-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
.mission-new-btn:hover{background:rgba(0,212,255,0.12)}
/* ── CLEARANCE PROTOCOL ──────────────────────────────────────────── */
#clearance-banner{display:none;background:rgba(255,34,68,0.08);border:1px solid rgba(255,34,68,0.4);border-radius:var(--r);padding:6px 10px;margin:0 0 8px;font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:#ff6680;animation:borderPulse 2s ease-in-out infinite}
@keyframes borderPulse{0%,100%{border-color:rgba(255,34,68,0.4)}50%{border-color:rgba(255,34,68,0.9)}}
#clearance-banner.active{display:flex;align-items:center;gap:8px}
#clearance-banner .clr-count{background:rgba(255,34,68,0.3);border-radius:3px;padding:1px 5px;font-size:0.6rem;color:#ff2244}
#clearance-banner .clr-view{margin-left:auto;cursor:pointer;color:#ff6680;text-decoration:underline}
.clr-card{background:rgba(255,34,68,0.04);border:1px solid rgba(255,34,68,0.3);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
.clr-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
.clr-card-head:hover{background:rgba(255,34,68,0.06)}
.clr-card-type{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;flex:1;color:#ff8899}
.clr-card-risk{font-family:var(--font-mono);font-size:0.5rem;padding:2px 5px;border-radius:2px;border:1px solid}
.clr-card-risk.critical{color:#ff2244;border-color:rgba(255,34,68,0.5)}
.clr-card-risk.high{color:#ffd700;border-color:rgba(255,215,0,0.4)}
.clr-card-risk.medium{color:#ff9900;border-color:rgba(255,153,0,0.4)}
.clr-card-body{display:none;padding:8px 10px 10px;border-top:1px solid rgba(255,34,68,0.2)}
.clr-card.open .clr-card-body{display:block}
.clr-card-desc{font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);margin-bottom:8px;line-height:1.5;white-space:pre-wrap}
.clr-action-bar{display:flex;gap:6px;margin-top:8px}
.clr-approve-btn{flex:1;background:rgba(0,255,136,0.08);border:1px solid rgba(0,255,136,0.4);border-radius:3px;padding:4px 8px;color:#00ff88;font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;cursor:pointer}
.clr-approve-btn:hover{background:rgba(0,255,136,0.18)}
.clr-deny-btn{flex:1;background:rgba(255,34,68,0.08);border:1px solid rgba(255,34,68,0.4);border-radius:3px;padding:4px 8px;color:#ff2244;font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;cursor:pointer}
.clr-deny-btn:hover{background:rgba(255,34,68,0.18)}
.clr-history-row{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)}
.clr-status-approved{color:#00ff88}.clr-status-denied{color:#ff2244}.clr-status-pending{color:#ffd700}.clr-status-expired{color:rgba(255,255,255,0.3)}
.clr-rule-row{display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem}
.clr-rule-type{flex:1;color:var(--cyan)}
.clr-rule-toggle{cursor:pointer;padding:2px 6px;border-radius:2px;font-size:0.48rem;border:1px solid}
.clr-rule-enabled{color:#00ff88;border-color:rgba(0,255,136,0.4)}
.clr-rule-disabled{color:rgba(255,255,255,0.3);border-color:rgba(255,255,255,0.15)}
.clr-admin-btn{width:100%;background:rgba(255,34,68,0.06);border:1px solid rgba(255,34,68,0.3);border-radius:4px;padding:5px;color:#ff6680;font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
.clr-admin-btn:hover{background:rgba(255,34,68,0.12)}
/* ── INTEL PROTOCOL — research result cards ──────────────────────── */
.intel-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.intel-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
.intel-card-head:hover{background:rgba(0,212,255,0.06)}
.intel-card-query{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.intel-card-status{font-family:var(--font-mono);font-size:0.55rem;padding:2px 6px;border-radius:2px;flex-shrink:0}
.intel-card-status.running{color:#ffd700;border:1px solid rgba(255,215,0,0.4);animation:pulse 1.5s ease-in-out infinite}
.intel-card-status.done{color:var(--green);border:1px solid rgba(0,255,136,0.3)}
.intel-card-status.failed{color:var(--red);border:1px solid rgba(255,34,68,0.3)}
.intel-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
.intel-card.open .intel-card-body{display:block}
.intel-card-body .synthesis{font-size:0.65rem;line-height:1.6;color:var(--text);margin:8px 0;white-space:pre-wrap}
.intel-sources{margin-top:8px}
.intel-source{font-size:0.58rem;color:var(--text-dim);padding:2px 0;border-bottom:1px solid rgba(0,212,255,0.06)}
.intel-source a{color:var(--cyan2);text-decoration:none}
.intel-source a:hover{text-decoration:underline}
.intel-empty{text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px}
.intel-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;cursor:pointer;margin-bottom:8px}
.intel-new-btn:hover{background:rgba(0,212,255,0.12)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
</style>
</head>
<body>
<canvas id="particleCanvas"></canvas>
<canvas id="dataStreamCanvas"></canvas>
<canvas id="hudCornersCanvas"></canvas>
<div id="alertOverlay"></div>
<div id="vignetteOverlay"></div>
<div id="faceScanOverlay">
<div class="fso-ring"></div>
<div class="fso-tr"></div>
<div class="fso-bl"></div>
<div class="fso-dot"></div>
<div class="fso-label">TRACKING</div>
</div>
<div class="scanlines"></div>
<div class="scanline-sweep"></div>
<!-- ── LOGIN ────────────────────────────────────────────────────────── -->
<div id="loginScreen">
<div class="login-reactor">
<div class="ring r1"></div><div class="ring r2"></div>
<div class="ring r3"></div><div class="ring r4"></div>
<div class="core"></div>
<div class="hud-ticks"></div>
</div>
<h1>JARVIS</h1>
<p>Just A Rather Very Intelligent System</p>
<form class="login-form" id="loginForm">
<input type="text" id="loginUser" placeholder="IDENTIFICATION" autocomplete="username" value="myron"/>
<input type="password" id="loginPass" placeholder="ACCESS CODE" autocomplete="current-password"/>
<button type="submit">INITIALIZE SYSTEM</button>
<div id="loginError"></div>
</form>
</div>
<!-- ── MAIN APP ──────────────────────────────────────────────────────── -->
<div id="app">
<!-- Top Bar -->
<div id="topBar">
<div class="tb-logo">
<div class="tb-reactor"><div class="tbr-ring tbr-r1"></div><div class="tbr-ring tbr-r2"></div><div class="tbr-core"></div><canvas id="audioRingCanvas" width="60" height="60"></canvas></div>
<span class="tb-logo-text" data-text="JARVIS SYSTEM">JARVIS SYSTEM</span>
</div>
<div class="tb-center">
<div class="tb-stat">LOCAL&nbsp;<span id="tb-cpu">--</span>% CPU</div>
<div class="tb-stat">MEM&nbsp;<span id="tb-mem">--</span>%</div>
<div class="tb-stat">DO SERVER&nbsp;<span id="tb-do" class="text-dim">--</span></div>
<div class="tb-stat"><span id="tb-alerts" class="text-green">NO ALERTS</span></div>
<div class="tb-stat" id="tb-planner" style="display:none"><span id="tb-planner-text" class="text-yellow"></span></div>
</div>
<div class="tb-right">
<div>
<div id="clock">--:--:--</div>
<div id="date-display">LOADING...</div>
</div>
<div class="status-dot"></div>
<button id="cameraBtn" class="btn-camera" onclick="toggleCamera()" title="Auto-mic when face detected (hands-free)">◉ CAMERA</button>
<button id="panelToggleBtn" class="btn-panels" onclick="togglePanels()" title="Toggle side panels (or say 'focus mode')">◧ PANELS</button>
<button id="agentBtn" class="btn-agent" onclick="openAgentModal()" title="Install JARVIS Agent on this machine"><div class="agent-dot"></div>AGENT</button>
<button id="btn-swap-panels" onclick="swapPanels()" title="Swap side panels">⇄ SWAP</button>
<button class="btn-logout" onclick="logout()">LOGOUT</button>
</div>
</div>
<!-- Main Layout -->
<div id="mainLayout">
<!-- LEFT: System Stats -->
<div id="leftPanel">
<div class="panel">
<div class="panel-title">JARVIS SERVER <span style="font-size:0.5rem;color:var(--text-dim)">165.22.1.228</span><div class="indicator"></div></div>
<!-- Metric bars + sparklines -->
<div class="metric-row">
<div class="metric-label">CPU <span id="cpu-val">--%</span></div>
<div class="metric-bar"><div class="metric-bar-fill" id="cpu-bar" style="width:0%"></div></div>
<div class="sparkline-wrap"><canvas id="spark-cpu"></canvas></div>
</div>
<div class="metric-row">
<div class="metric-label">MEMORY <span id="mem-val">--%</span></div>
<div class="metric-bar"><div class="metric-bar-fill" id="mem-bar" style="width:0%"></div></div>
<div class="sparkline-wrap"><canvas id="spark-mem"></canvas></div>
</div>
<div class="metric-row">
<div class="metric-label">DISK <span id="disk-val">--%</span></div>
<div class="metric-bar"><div class="metric-bar-fill" id="disk-bar" style="width:0%"></div></div>
<div class="sparkline-wrap"><canvas id="spark-disk"></canvas></div>
</div>
<div class="val-row"><div class="lbl">UPTIME</div><div class="val" id="uptime-val">--</div></div>
<div class="val-row"><div class="lbl">LOAD</div><div class="val" id="load-val">--</div></div>
<div class="val-row"><div class="lbl">HOST</div><div class="val" id="host-val">--</div></div>
<!-- Services -->
<div style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:10px 0 5px">SERVICES</div>
<div id="services-list">
<div class="loading-shimmer" style="margin-bottom:4px"></div>
</div>
<!-- Site health -->
<div style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:10px 0 5px">WEBSITES</div>
<div id="sites-list">
<div class="loading-shimmer" style="margin-bottom:4px"></div>
</div>
<!-- Top processes -->
<div style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:10px 0 5px">PROCESSES</div>
<div id="procs-list">
<div class="loading-shimmer" style="margin-bottom:4px"></div>
</div>
</div>
</div>
<!-- CENTER: Arc Reactor + Chat -->
<div id="centerPanel">
<div id="arcReactor">
<div class="arc-ring r1"></div><div class="arc-ring r2"></div>
<div class="arc-ring r3"></div><div class="arc-ring r4"></div>
<div class="arc-ring r5"></div><div class="arc-ring r6"></div>
<div class="arc-ring r7"></div>
<div class="arc-core"></div>
<div class="hud-ticks"></div>
</div>
<div id="chatArea">
<div id="chatLog">
<div class="msg system">◈ JARVIS ONLINE — AWAITING INSTRUCTIONS ◈</div>
</div>
<div id="waveform">
<div class="wave-bar" style="--d:0.4s"></div>
<div class="wave-bar" style="--d:0.5s"></div>
<div class="wave-bar" style="--d:0.35s"></div>
<div class="wave-bar" style="--d:0.6s"></div>
<div class="wave-bar" style="--d:0.45s"></div>
<div class="wave-bar" style="--d:0.55s"></div>
<div class="wave-bar" style="--d:0.4s"></div>
<div class="wave-bar" style="--d:0.5s"></div>
<div class="wave-bar" style="--d:0.38s"></div>
<div class="wave-bar" style="--d:0.52s"></div>
</div>
<div id="contextChip">
<span id="contextType">CONTEXT</span>
<span id="contextLabel"></span>
<button id="contextClear" onclick="clearContext()" title="Clear context">×</button>
</div>
<div id="inputArea">
<button id="micBtn" onclick="toggleVoice()" title="Voice Command">
<span id="micIcon">🎤</span>
</button>
<input type="text" id="textInput" placeholder="Enter command or speak to JARVIS..."
autocomplete="off" onkeydown="if(event.key==='Enter')sendMessage()"/>
<button id="sendBtn" onclick="sendMessage()">TRANSMIT</button>
<button id="searchBtn" onclick="openSearchModal()" title="Search chat history" style="background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);font-size:1rem;padding:0 10px;cursor:pointer;transition:all 0.2s;min-height:36px" onmouseover="this.style.color='var(--cyan)';this.style.borderColor='var(--cyan)'" onmouseout="this.style.color='var(--text-dim)';this.style.borderColor='var(--panel-border)'">🔍</button>
</div>
</div>
</div>
<!-- RIGHT: Network + VMs + HA -->
<div id="rightPanel">
<!-- Weather Widget -->
<div class="panel" style="flex:0 0 auto">
<div class="panel-title">WEATHER <span id="weather-loc" style="font-size:0.55rem;color:var(--text-dim)">FORT WORTH, TX</span></div>
<div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:8px">
<div style="flex:1">
<div style="display:flex;align-items:baseline;gap:8px">
<span style="font-size:1.8rem;font-family:var(--font-display);color:var(--cyan);line-height:1" id="weather-temp">--</span>
<span style="font-size:0.75rem;color:var(--text-dim)">°F</span>
</div>
<div style="font-size:0.7rem;color:var(--text-primary);margin-top:2px;font-family:var(--font-display);letter-spacing:1px" id="weather-desc">LOADING...</div>
<div style="font-size:0.58rem;color:var(--text-dim);margin-top:3px" id="weather-details"></div>
</div>
<div style="text-align:right;flex-shrink:0">
<div style="font-size:0.52rem;color:var(--text-dim);letter-spacing:1px">FEELS LIKE</div>
<div style="font-size:1rem;font-family:var(--font-display);color:var(--cyan)" id="weather-feels">--°F</div>
<div style="font-size:0.52rem;color:var(--text-dim);margin-top:4px;letter-spacing:1px">HUMIDITY</div>
<div style="font-size:0.75rem;font-family:var(--font-display);color:var(--text-primary)" id="weather-humidity">--%</div>
</div>
</div>
<div id="weather-forecast" style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px"></div>
</div>
<!-- Network Status -->
<div class="panel" style="flex:0 1 auto;max-height:35%;display:flex;flex-direction:column;min-height:100px">
<div class="panel-title">NETWORK STATUS <div class="indicator"></div><span id="net-agent-count" style="font-size:0.6rem;color:var(--cyan);margin-left:auto"></span><button onclick="addNetworkDevice()" title="Add device" style="background:none;border:none;color:var(--cyan);cursor:pointer;font-size:1rem;padding:0 4px;margin-left:4px;line-height:1">+</button></div>
<canvas id="topoCanvas" height="100"></canvas>
<div id="network-list" style="overflow-y:auto;flex:1;padding-right:2px">
<div class="loading-shimmer" style="margin-bottom:6px"></div>
<div class="loading-shimmer" style="margin-bottom:6px"></div>
<div class="loading-shimmer"></div>
</div>
<button onclick="scanNetwork()" style="margin-top:8px;flex-shrink:0;width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:4px;color:var(--cyan);font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;cursor:pointer" id="scanBtn">RUN NETWORK SCAN</button>
</div>
<!-- Tab Panel -->
<div class="panel" style="flex:1;overflow:hidden;display:flex;flex-direction:column">
<!-- Clearance Alert Banner -->
<div id="clearance-banner">
<span>◈ CLEARANCE REQUIRED —</span>
<span class="clr-count" id="clr-banner-count">0</span>
<span>PENDING AUTHORIZATION</span>
<span class="clr-view" onclick="switchTab('clearance')">VIEW →</span>
</div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('ha')">HOME</div>
<div class="tab" onclick="switchTab('alerts')">ALERTS</div>
<div class="tab" onclick="switchTab('news')">NEWS</div>
<div class="tab" onclick="switchTab('agents')">AGENTS</div>
<div class="tab" onclick="switchTab('sites')">SITES</div>
<div class="tab" id="tab-btn-intel" onclick="switchTab('intel')">INTEL</div>
<div class="tab" id="tab-btn-comms" onclick="switchTab('comms')">COMMS</div>
<div class="tab" id="tab-btn-guardian" onclick="switchTab('guardian')">GUARDIAN</div>
<div class="tab" id="tab-btn-missions" onclick="switchTab('missions')">MISSIONS</div>
<div class="tab" id="tab-btn-directives" onclick="switchTab('directives')">DIRECTIVES</div>
<div class="tab" id="tab-btn-clearance" onclick="switchTab('clearance')">CLEARANCE <span id="clr-tab-badge" style="display:none;background:#ff2244;color:#fff;border-radius:3px;padding:0 4px;font-size:0.5rem;margin-left:2px"></span></div>
</div>
<div id="tab-vms" class="tab-pane" style="overflow-y:auto;flex:1">
<div id="vm-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-ha" class="tab-pane active" style="overflow-y:auto;flex:1">
<div id="ha-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-alerts" class="tab-pane" style="overflow-y:auto;flex:1">
<div id="alerts-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-news" class="tab-pane" style="overflow-y:auto;flex:1">
<div style="display:flex;align-items:center;justify-content:flex-end;margin-bottom:4px">
<button onclick="toggleNewsFilter()" title="Filter news sources" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:0.85rem;padding:2px 4px;transition:color 0.2s" onmouseover="this.style.color='var(--cyan)'" onmouseout="this.style.color='var(--text-dim)'"></button>
</div>
<div id="news-filter-panel" style="display:none;margin-bottom:8px;padding:8px;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px">
<div style="font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;color:var(--cyan);margin-bottom:6px">SHOW CATEGORIES</div>
<div id="news-filter-checkboxes" style="display:flex;flex-direction:column;gap:4px"></div>
</div>
<div id="news-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-agents" class="tab-pane" style="overflow-y:auto;flex:1">
<div id="agents-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-intel" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="intel-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-comms" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="comms-list"><div class="loading-shimmer"></div></div>
<div class="comms-section-label" style="margin:12px 4px 6px">◈ OUTBOX — SENT &amp; QUEUED</div>
<div id="comms-outbox"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-guardian" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="guardian-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-missions" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="missions-hud"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-directives" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="directives-hud"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-clearance" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="clearance-hud"><div class="loading-shimmer"></div></div>
</div>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div id="bottomBar">
<div class="bb-item">
<div class="bb-dot online"></div>
<span>JARVIS CORE</span> ONLINE
</div>
<div class="bb-item">
<div class="bb-dot" id="bb-do-dot"></div>
<span>DO SERVER</span> <span id="bb-do-status">CHECKING</span>
</div>
<div class="bb-item">
<div class="bb-dot" id="bb-pve-dot"></div>
<span>PROXMOX</span> <span id="bb-pve-status">CHECKING</span>
</div>
<div class="bb-item">
<div class="bb-dot" id="bb-ha-dot"></div>
<span>HOME ASSISTANT</span> <span id="bb-ha-status">CHECKING</span>
</div>
<div class="bb-item">
<div class="bb-dot" id="bb-agent-dot"></div>
<span>AGENTS</span> <span id="bb-agent-status">--</span>
</div>
<div class="bb-item">
<div class="bb-dot" id="bb-arc-dot"></div>
<span>ARC REACTOR</span> <span id="bb-arc-status">OFFLINE</span>
</div>
<div class="bb-item" id="bb-guardian-item" style="cursor:pointer" onclick="switchGuardianTab()">
<div class="bb-dot" id="bb-guardian-dot" style="background:var(--text-dim)"></div>
<span>GUARDIAN</span> <span id="bb-guardian-status" style="color:var(--text-dim)">INIT</span>
<span id="bb-guardian-badge" style="display:none;background:var(--red);color:#fff;font-size:0.45rem;padding:1px 4px;border-radius:2px;font-family:var(--font-mono);letter-spacing:0">0</span>
</div>
<div class="bb-item" style="cursor:pointer" onclick="switchTab('clearance')" id="bb-memory-item">
<div class="bb-dot" id="bb-memory-dot" style="background:rgba(0,212,255,0.3)"></div>
<span>MEMORY</span> <span id="bb-memory-count" style="color:var(--text-dim)">--</span>
</div>
<div id="ekgWrap" style="margin-left:auto"><canvas id="ekgCanvas"></canvas></div>
<div style="font-size:0.65rem;flex-shrink:0">
JARVIS v2.0 · SECURITY LEVEL ALPHA · UPDATED <span id="last-refresh">--:--:--</span>
</div>
</div>
</div>
<!-- NETWORK MAP OVERLAY -->
<div id="netMapOverlay">
<div id="nmHeader">
<div id="nmTitle"><div class="nm-pulse"></div>◈ NETWORK TOPOLOGY — LIVE</div>
<div id="nmStats">NODES <span id="nm-node-count"></span> &nbsp;·&nbsp; ONLINE <span id="nm-online-count"></span> &nbsp;·&nbsp; AGENTS <span id="nm-agent-count"></span></div>
<div style="display:flex;align-items:center;gap:12px">
<span style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);letter-spacing:1px">SAY "CLOSE MAP"</span>
<button id="nmClose" onclick="closeNetMap()">✕ CLOSE</button>
</div>
</div>
<canvas id="nmCanvas"></canvas>
<div id="nmLegend">
<span><span class="nm-leg-dot" style="background:#00ff88;box-shadow:0 0 4px #00ff88"></span>PROXMOX</span>
<span><span class="nm-leg-dot" style="background:#ffd700;box-shadow:0 0 4px #ffd700"></span>SERVICES</span>
<span><span class="nm-leg-dot" style="background:#00beff;box-shadow:0 0 4px #00beff"></span>AGENTS</span>
<span><span class="nm-leg-dot" style="background:rgba(0,160,200,0.9)"></span>DEVICES</span>
<span><span class="nm-leg-dot" style="background:rgba(0,110,170,0.9)"></span>NETWORK</span>
<span><span class="nm-leg-dot" style="background:#ff2244;box-shadow:0 0 4px #ff2244"></span>OFFLINE</span>
<span style="margin-left:auto;opacity:0.4;font-size:0.5rem">CYAN = DATA IN · ORANGE = CMD OUT</span>
</div>
<div id="nmNodeInfo"><div class="ni-title" id="ni-name"></div><div class="ni-row" id="ni-ip"></div><div class="ni-row" id="ni-status"></div><div class="ni-row" id="ni-type"></div></div>
</div>
<!-- SLEEP OVERLAY -->
<div id="sleepOverlay">
<div class="sleep-reactor">
<div class="sleep-ring sr1"></div>
<div class="sleep-ring sr2"></div>
<div class="sleep-ring sr3"></div>
<div class="sleep-core"></div>
</div>
<div class="sleep-label">JARVIS — STANDBY</div>
<div class="sleep-sub">SAY "WAKE UP JARVIS" TO RESUME</div>
</div>
<div id="sitesModal" style="position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:9999;display:none;align-items:flex-start;justify-content:center;padding:24px;overflow-y:auto">
<div style="background:var(--panel-bg);border:1px solid var(--panel-border);width:100%;max-width:960px;font-family:var(--font-mono)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid var(--panel-border)">
<div style="color:var(--cyan);font-size:0.75rem;letter-spacing:4px">◈ SITES MANAGER — EMAIL SETTINGS</div>
<button onclick="closeSitesModal()" style="background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;padding:4px 12px;letter-spacing:2px">✕ CLOSE</button>
</div>
<div style="padding:20px 24px">
<!-- Global API Key -->
<div style="background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.2);padding:16px;margin-bottom:20px">
<div style="color:var(--cyan);font-size:0.62rem;letter-spacing:3px;margin-bottom:10px">▸ CYBERMAIL API KEY — PUSH TO ALL SITES</div>
<div style="display:flex;gap:10px;align-items:center">
<input id="global-api-key" type="password"
style="flex:1;background:#0a0f1a;border:1px solid rgba(0,212,255,0.25);color:var(--text);font-family:var(--font-mono);font-size:0.7rem;padding:8px 12px;outline:none"
placeholder="sk_live_...">
<button onclick="pushApiKey()"
style="background:rgba(0,212,255,0.12);border:1px solid var(--cyan);color:var(--cyan);font-family:var(--font-mono);font-size:0.6rem;letter-spacing:2px;padding:8px 18px;cursor:pointer;white-space:nowrap">
PUSH TO ALL
</button>
</div>
<div id="push-status" style="font-size:0.6rem;color:var(--text-dim);margin-top:6px;min-height:16px"></div>
</div>
<!-- Site Cards Grid -->
<div id="sites-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.65rem">LOADING...</div>
</div>
</div>
</div>
</div>
<div id="agentModal">
<div class="agent-modal-box">
<button class="agent-modal-close" onclick="document.getElementById('agentModal').classList.remove('open')">&#x2715; CLOSE</button>
<h3 id="agentModalTitle">&#9679; JARVIS AGENT</h3>
<div id="agentModalContent"></div>
</div>
</div>
<!-- Hidden camera feed for face detection -->
<video id="faceVideo" autoplay muted playsinline
style="position:fixed;top:-9999px;left:-9999px;width:320px;height:240px"></video>
<script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js" crossorigin="anonymous"></script>
<script>
// ── PARTICLE CANVAS ───────────────────────────────────────────────────
(function initParticles() {
const canvas = document.getElementById('particleCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const N = 65;
const CONNECT_DIST = 130;
let W, H, particles = [];
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
function spawn() {
particles = [];
for (let i = 0; i < N; i++) {
particles.push({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * 0.25,
vy: (Math.random() - 0.5) * 0.25,
r: Math.random() * 1.2 + 0.4,
a: Math.random() * 0.35 + 0.08,
});
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
for (let i = 0; i < N; i++) {
const p = particles[i];
for (let j = i + 1; j < N; j++) {
const q = particles[j];
const dx = p.x - q.x, dy = p.y - q.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < CONNECT_DIST) {
ctx.strokeStyle = `rgba(0,180,255,${0.09 * (1 - d / CONNECT_DIST)})`;
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke();
}
}
ctx.fillStyle = `rgba(0,200,255,${p.a})`;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
p.x += p.vx; p.y += p.vy;
if (p.x < 0) p.x = W; if (p.x > W) p.x = 0;
if (p.y < 0) p.y = H; if (p.y > H) p.y = 0;
}
requestAnimationFrame(draw);
}
resize(); spawn(); draw();
window.addEventListener('resize', () => { resize(); spawn(); });
})();
// ── PANEL FLOAT STAGGER — different phase per panel ───────────────────
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.panel').forEach((p, i) => {
p.style.animationDelay = `-${(i * 1.37).toFixed(2)}s`;
});
});
// ── MOUSE PARALLAX — panels tilt toward cursor ────────────────────────
(function initParallax() {
const MAX_TILT = 3.5; // degrees
let mouseX = 0, mouseY = 0, raf = null;
window.addEventListener('mousemove', e => {
mouseX = e.clientX / window.innerWidth - 0.5; // -0.5 to 0.5
mouseY = e.clientY / window.innerHeight - 0.5;
if (!raf) raf = requestAnimationFrame(applyTilt);
});
function applyTilt() {
raf = null;
const rx = mouseY * MAX_TILT;
const ry = -mouseX * MAX_TILT;
document.querySelectorAll('.panel').forEach(p => {
p.style.setProperty('--prx', rx.toFixed(2) + 'deg');
p.style.setProperty('--pry', ry.toFixed(2) + 'deg');
});
// Column-level parallax (skip in focus mode)
if (!document.getElementById('mainLayout')?.classList.contains('focus-mode')) {
const lp = document.getElementById('leftPanel');
const rp = document.getElementById('rightPanel');
const cp = document.getElementById('centerPanel');
if (lp) lp.style.transform = `translateX(${mouseX * 5}px) translateY(${mouseY * 3}px)`;
if (rp) rp.style.transform = `translateX(${-mouseX * 5}px) translateY(${mouseY * 3}px)`;
if (cp) cp.style.transform = `translateX(${mouseX * 2}px)`;
}
}
})();
// ── SPARKLINES ────────────────────────────────────────────────────────
const _sparkData = {cpu: [], mem: [], disk: []};
const SPARK_MAX = 25;
function pushSparkData(key, val) {
_sparkData[key].push(val);
if (_sparkData[key].length > SPARK_MAX) _sparkData[key].shift();
}
function drawSparkline(canvasId, data, color) {
const canvas = document.getElementById(canvasId);
if (!canvas || !data.length) return;
const wrap = canvas.parentElement;
canvas.width = wrap.clientWidth || 240;
canvas.height = 32;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const W = canvas.width, H = canvas.height;
const min = 0, max = 100;
const step = W / (SPARK_MAX - 1);
// Fill area under line
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, color.replace(')', ',0.35)').replace('rgb','rgba'));
grad.addColorStop(1, color.replace(')', ',0)').replace('rgb','rgba'));
ctx.beginPath();
data.forEach((v, i) => {
const x = i * step;
const y = H - ((v - min) / (max - min)) * H;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo((data.length - 1) * step, H);
ctx.lineTo(0, H);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// Line
ctx.beginPath();
data.forEach((v, i) => {
const x = i * step;
const y = H - ((v - min) / (max - min)) * H;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
// Current value dot
if (data.length > 1) {
const last = data[data.length - 1];
const x = (data.length - 1) * step;
const y = H - ((last - min) / (max - min)) * H;
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
// ── PANEL DATA FLASH ──────────────────────────────────────────────────
function flashPanel(panelEl) {
if (!panelEl) return;
panelEl.classList.remove('data-flash');
void panelEl.offsetWidth; // reflow to restart animation
panelEl.classList.add('data-flash');
setTimeout(() => panelEl.classList.remove('data-flash'), 600);
}
// ── ALERT PULSE ───────────────────────────────────────────────────────
function setAlertState(hasAlerts) {
const ov = document.getElementById('alertOverlay');
if (ov) ov.style.display = hasAlerts ? 'block' : 'none';
const vg = document.getElementById('vignetteOverlay');
if (vg) vg.classList.toggle('alert-vignette', hasAlerts);
}
function setSystemHealth(level) {
// level: 'ok' | 'warning' | 'critical'
const reactor = document.getElementById('arcReactor');
if (!reactor) return;
reactor.classList.remove('health-warning', 'health-critical');
if (level === 'warning') reactor.classList.add('health-warning');
if (level === 'critical') reactor.classList.add('health-critical');
// Also update topbar logo dot
const dot = document.querySelector('.tb-logo-dot');
if (dot) {
dot.style.background = level === 'critical' ? 'var(--red)' : level === 'warning' ? '#f5a623' : 'var(--cyan)';
dot.style.boxShadow = level === 'critical' ? '0 0 8px var(--red)' : level === 'warning' ? '0 0 8px #f5a623' : '0 0 8px var(--cyan)';
}
}
// ── FACE TRACKING — reactor follows face position ─────────────────────
let _faceTargetX = 0, _faceTargetY = 0; // normalized -0.5 to 0.5
let _faceCurrX = 0, _faceCurrY = 0;
let _faceVisible = false;
let _faceTrackRaf = null;
const FACE_MAX_X = 48; // max px left/right travel
const FACE_MAX_Y = 10; // max px up/down travel
const FACE_LERP = 0.07; // smoothing (lower = slower/smoother)
function _faceTrackLoop() {
_faceTrackRaf = requestAnimationFrame(_faceTrackLoop);
// Lerp toward target (or back to 0 when no face)
const targetX = _faceVisible ? _faceTargetX : 0;
const targetY = _faceVisible ? _faceTargetY : 0;
_faceCurrX += (targetX - _faceCurrX) * FACE_LERP;
_faceCurrY += (targetY - _faceCurrY) * FACE_LERP;
const tx = _faceCurrX * FACE_MAX_X;
const ty = _faceCurrY * FACE_MAX_Y;
const logo = document.querySelector('.tb-logo');
if (logo) logo.style.transform = `translateX(${tx.toFixed(2)}px) translateY(${ty.toFixed(2)}px)`;
}
function updateFaceTarget(box, videoW, videoH) {
// Face center, normalized — flip X because front camera is mirrored
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
_faceTargetX = 0.5 - (cx / videoW); // flipped: move right when face is left
_faceTargetY = (cy / videoH) - 0.5; // positive = face low = logo drops slightly
_faceVisible = true;
// Position the scan overlay over the detected face in the viewport.
// The video feed is 320×240 but hidden; map to viewport coords.
const scaleX = window.innerWidth / videoW;
const scaleY = window.innerHeight / videoH;
const faceVx = box.x * scaleX;
const faceVy = box.y * scaleY;
const faceVw = box.width * scaleX;
const faceVh = box.height * scaleY;
const ov = document.getElementById('faceScanOverlay');
if (ov) {
ov.style.display = 'block';
// Center overlay on face, flipped X to match mirror
const ovX = window.innerWidth - faceVx - faceVw / 2 - 30;
const ovY = faceVy + faceVh / 2 - 30;
ov.style.left = Math.max(0, Math.min(window.innerWidth - 60, ovX)) + 'px';
ov.style.top = Math.max(0, Math.min(window.innerHeight - 80, ovY)) + 'px';
}
}
function clearFaceTarget() {
_faceVisible = false;
const ov = document.getElementById('faceScanOverlay');
if (ov) ov.style.display = 'none';
}
function startFaceTracking() {
const logo = document.querySelector('.tb-logo');
if (logo) logo.classList.add('face-tracking');
if (!_faceTrackRaf) _faceTrackLoop();
}
function stopFaceTracking() {
clearFaceTarget();
const logo = document.querySelector('.tb-logo');
if (logo) { logo.classList.remove('face-tracking'); logo.style.transform = ''; }
// Let the loop coast to zero naturally rather than snapping — cancel after settling
setTimeout(() => {
if (!_faceVisible && _faceTrackRaf) {
cancelAnimationFrame(_faceTrackRaf);
_faceTrackRaf = null;
}
}, 1500);
}
// ── GLITCH EFFECT ─────────────────────────────────────────────────────
(function initGlitch() {
function triggerGlitch() {
const el = document.querySelector('.tb-logo-text');
if (!el) return;
el.classList.add('glitching');
setTimeout(() => el.classList.remove('glitching'), 280);
setTimeout(triggerGlitch, 35000 + Math.random() * 25000);
}
setTimeout(triggerGlitch, 20000);
})();
// ① HUD CORNER RINGS ──────────────────────────────────────────────────
(function initHudCorners() {
const canvas = document.getElementById('hudCornersCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let W, H, t = 0;
function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
function drawCorner(cx, cy, a0, a1) {
const R = 62, R2 = R + 16;
// Edge lines
ctx.strokeStyle = 'rgba(0,212,255,0.35)'; ctx.lineWidth = 1;
const edgeLen = 28;
// two short lines along the screen edges from the corner point
const midA = (a0 + a1) / 2;
const ax = Math.cos(a0), ay = Math.sin(a0);
const bx = Math.cos(a1), by = Math.sin(a1);
ctx.beginPath(); ctx.moveTo(cx + ax*(R+6), cy + ay*(R+6)); ctx.lineTo(cx + ax*(R+6+edgeLen), cy + ay*(R+6+edgeLen)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx + bx*(R+6), cy + by*(R+6)); ctx.lineTo(cx + bx*(R+6+edgeLen), cy + by*(R+6+edgeLen)); ctx.stroke();
// Primary arc
ctx.beginPath(); ctx.arc(cx, cy, R, a0, a1);
ctx.strokeStyle = 'rgba(0,212,255,0.5)'; ctx.lineWidth = 1.2; ctx.stroke();
// Outer arc
ctx.beginPath(); ctx.arc(cx, cy, R2, a0 + 0.12, a1 - 0.12);
ctx.strokeStyle = 'rgba(0,212,255,0.18)'; ctx.lineWidth = 0.6; ctx.stroke();
// Tick marks
const ticks = 14;
for (let i = 0; i <= ticks; i++) {
const a = a0 + (a1 - a0) * (i / ticks);
const big = i % 7 === 0;
const len = big ? 9 : (i % 2 === 0 ? 5 : 3);
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * (R - 2), cy + Math.sin(a) * (R - 2));
ctx.lineTo(cx + Math.cos(a) * (R - 2 - len), cy + Math.sin(a) * (R - 2 - len));
ctx.strokeStyle = big ? 'rgba(0,212,255,0.8)' : 'rgba(0,212,255,0.3)';
ctx.lineWidth = big ? 1 : 0.5; ctx.stroke();
}
// Animated scanning dot
const dotA = a0 + ((a1 - a0) * ((t * 0.35) % 1));
ctx.beginPath(); ctx.arc(cx + Math.cos(dotA) * R, cy + Math.sin(dotA) * R, 2.5, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,212,255,1)';
ctx.shadowColor = 'rgba(0,212,255,0.9)'; ctx.shadowBlur = 8; ctx.fill(); ctx.shadowBlur = 0;
// Small numeric labels
ctx.font = '7px Share Tech Mono,monospace'; ctx.fillStyle = 'rgba(0,212,255,0.55)';
ctx.fillText(Math.round((Math.abs(a0) / (Math.PI*2)) * 360) + '°',
cx + Math.cos(a0) * (R + 20), cy + Math.sin(a0) * (R + 20));
}
function draw() {
ctx.clearRect(0, 0, W, H); t += 0.01;
drawCorner(0, 0, 0, Math.PI*0.5);
drawCorner(W, 0, Math.PI*0.5, Math.PI);
drawCorner(0, H, Math.PI*1.5, Math.PI*2);
drawCorner(W, H, Math.PI, Math.PI*1.5);
requestAnimationFrame(draw);
}
resize(); draw();
window.addEventListener('resize', resize);
})();
// ② DATA STREAM COLUMNS ───────────────────────────────────────────────
(function initDataStream() {
const canvas = document.getElementById('dataStreamCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let W, H;
const CHARS = '0123456789ABCDEF◈◉▸▹⟩⟨⬡░▒';
const COL_COUNT = 22;
let cols = [];
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
cols = [];
for (let i = 0; i < COL_COUNT; i++) {
const x = (i / COL_COUNT) * W + Math.random() * (W / COL_COUNT) * 0.6;
cols.push({ x, y: Math.random() * H, speed: Math.random() * 0.7 + 0.25,
len: Math.floor(Math.random() * 14 + 5), chars: [],
alpha: Math.random() * 0.035 + 0.015, tick: 0 });
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.font = '11px Share Tech Mono,monospace';
for (const c of cols) {
c.y += c.speed;
if (c.y - c.len * 14 > H) { c.y = -c.len * 14; c.alpha = Math.random() * 0.035 + 0.015; }
if (++c.tick > 7) { c.tick = 0; c.chars[0] = CHARS[Math.floor(Math.random() * CHARS.length)]; }
for (let i = 0; i < c.len; i++) {
if (!c.chars[i]) c.chars[i] = CHARS[Math.floor(Math.random() * CHARS.length)];
const cy = c.y - i * 14;
if (cy < -14 || cy > H + 14) continue;
const a = c.alpha * (1 - i / c.len);
ctx.fillStyle = i === 0 ? `rgba(180,240,255,${Math.min(a*4,0.15)})` : `rgba(0,185,225,${a})`;
ctx.fillText(c.chars[i], c.x, cy);
}
}
requestAnimationFrame(draw);
}
resize(); draw();
window.addEventListener('resize', resize);
})();
// ③ NETWORK TOPOLOGY ──────────────────────────────────────────────────
let _topoNodes = [], _topoT = 0, _topoRunning = false;
function renderTopology(devices) {
const canvas = document.getElementById('topoCanvas');
if (!canvas) return;
const W = canvas.parentElement?.clientWidth || 260;
canvas.width = W; canvas.height = 118;
_topoNodes = devices.slice(0, 18).map((d, i, arr) => {
const angle = (i / arr.length) * Math.PI * 2 - Math.PI / 2;
const rx = W * 0.36, ry = 36;
return {
x: W/2 + Math.cos(angle) * rx * (0.6 + (i%3)*0.18),
y: 52 + Math.sin(angle) * ry * (0.65 + (i%2)*0.25),
label: (d.name || d.ip || '?').split('.')[0].substring(0, 9),
on: !!(d.alive || d.status === 'online'),
agent: d.source === 'agent',
phase: Math.random() * Math.PI * 2,
};
});
if (!_topoRunning) { _topoRunning = true; _drawTopo(); }
}
function _drawTopo() {
requestAnimationFrame(_drawTopo);
const canvas = document.getElementById('topoCanvas');
if (!canvas || !_topoNodes.length) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
_topoT += 0.018;
ctx.clearRect(0, 0, W, H);
const floatY = n => Math.sin(_topoT * 0.9 + n.phase) * 2.5;
// Connections
for (let i = 0; i < _topoNodes.length; i++) {
for (let j = i+1; j < _topoNodes.length; j++) {
const a = _topoNodes[i], b = _topoNodes[j];
if (!a.on || !b.on) continue;
const ax = a.x, ay = a.y + floatY(a), bx = b.x, by = b.y + floatY(b);
const dist = Math.hypot(bx-ax, by-ay);
if (dist > W * 0.55) continue;
const lg = ctx.createLinearGradient(ax, ay, bx, by);
const col = a.agent ? '0,255,136' : '0,212,255';
lg.addColorStop(0, `rgba(${col},0.25)`); lg.addColorStop(0.5, `rgba(${col},0.1)`); lg.addColorStop(1, `rgba(${col},0.25)`);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by);
ctx.strokeStyle = lg; ctx.lineWidth = 0.8; ctx.stroke();
// travelling pulse
const p = (_topoT * 0.4 + a.phase) % 1;
ctx.beginPath(); ctx.arc(ax+(bx-ax)*p, ay+(by-ay)*p, 1.8, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,212,255,0.85)'; ctx.fill();
}
}
// Bubble nodes
for (const n of _topoNodes) {
const pulse = 0.5 + Math.sin(_topoT * 1.4 + n.phase) * 0.3;
const col = n.on ? (n.agent ? '0,255,136' : '0,212,255') : '255,50,80';
const r = n.agent ? 10 : 7;
const nx = n.x, ny = n.y + floatY(n);
if (n.on) {
// Ambient bloom
const bloom = ctx.createRadialGradient(nx, ny, r*0.4, nx, ny, r*3);
bloom.addColorStop(0, `rgba(${col},${(pulse*0.18).toFixed(3)})`);
bloom.addColorStop(1, `rgba(${col},0)`);
ctx.beginPath(); ctx.arc(nx, ny, r*3, 0, Math.PI*2); ctx.fillStyle = bloom; ctx.fill();
}
// Frosted glass fill
const fg = ctx.createRadialGradient(nx, ny - r*0.3, 0, nx, ny, r);
const fa = n.on ? 0.18 + pulse*0.1 : 0.07;
fg.addColorStop(0, `rgba(${col},${(fa*1.8).toFixed(3)})`);
fg.addColorStop(0.65, `rgba(${col},${fa.toFixed(3)})`);
fg.addColorStop(1, `rgba(${col},${(fa*0.2).toFixed(3)})`);
ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2); ctx.fillStyle = fg; ctx.fill();
// Border
ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2);
ctx.strokeStyle = `rgba(${col},${n.on ? (0.5 + pulse*0.32).toFixed(3) : '0.2'})`;
ctx.lineWidth = 1; ctx.stroke();
// Label below bubble
ctx.font = '6px Share Tech Mono,monospace';
ctx.textAlign = 'center';
ctx.fillStyle = n.on ? `rgba(${col},0.82)` : 'rgba(255,100,100,0.5)';
ctx.fillText(n.label, nx, ny + r + 7);
ctx.textAlign = 'left';
}
}
// ④ EKG HEARTBEAT ────────────────────────────────────────────────────
(function initEKG() {
const canvas = document.getElementById('ekgCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let W, H, data = [], phase = 0;
function resize() {
const wrap = document.getElementById('ekgWrap');
W = canvas.width = wrap ? Math.max(100, wrap.clientWidth) : 180;
H = canvas.height = 22;
data = new Array(W).fill(0);
}
function ekgVal(p) {
const c = p % 1;
if (c < 0.28) return 0;
if (c < 0.38) return Math.sin((c-0.28)/0.10*Math.PI) * 0.22; // P bump
if (c < 0.43) return 0; // PR flat
if (c < 0.45) return -(c-0.43)/0.02 * 0.18; // Q dip
if (c < 0.465){ const f=(c-0.45)/0.015; return f<0.5?f*2*0.95:(2-f*2)*0.95; } // R spike
if (c < 0.49) return -(c-0.465)/0.025 * 0.22; // S dip
if (c < 0.52) return -(0.22-(c-0.49)/0.03*0.22); // back to baseline
if (c < 0.70) return Math.sin((c-0.52)/0.18*Math.PI) * 0.28; // T wave
return 0;
}
function draw() {
phase += 0.0038;
data.shift(); data.push(ekgVal(phase));
ctx.clearRect(0, 0, W, H);
// Glow layer
ctx.beginPath();
data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); });
ctx.strokeStyle='rgba(0,220,100,0.2)'; ctx.lineWidth=4; ctx.stroke();
// Line
ctx.beginPath();
data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); });
ctx.strokeStyle='rgba(0,255,120,0.85)'; ctx.lineWidth=1.2; ctx.stroke();
requestAnimationFrame(draw);
}
resize(); draw();
window.addEventListener('resize', resize);
})();
// ⑤ AUDIO WAVEFORM RING ───────────────────────────────────────────────
(function initAudioRing() {
const canvas = document.getElementById('audioRingCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const CX = 30, CY = 30, BARS = 28, INNER = 20;
let t = 0;
function draw() {
t += 0.055;
ctx.clearRect(0, 0, 60, 60);
const listening = window.isListening;
const speaking = window.isSpeaking;
if (!listening && !speaking) { requestAnimationFrame(draw); return; }
for (let i = 0; i < BARS; i++) {
const angle = (i / BARS) * Math.PI * 2;
let amp;
if (speaking) {
amp = 0.35 + Math.abs(Math.sin(t*4.1+i*0.9))*0.5 + Math.abs(Math.sin(t*9+i*1.7))*0.15;
} else {
amp = 0.08 + Math.abs(Math.sin(t*1.1+i*0.6))*0.18;
}
const barLen = 3 + amp * 11;
ctx.beginPath();
ctx.moveTo(CX+Math.cos(angle)*INNER, CY+Math.sin(angle)*INNER);
ctx.lineTo(CX+Math.cos(angle)*(INNER+barLen), CY+Math.sin(angle)*(INNER+barLen));
const alpha = speaking ? 0.55+amp*0.45 : 0.25+amp*0.35;
ctx.strokeStyle = speaking ? `rgba(0,255,175,${alpha})` : `rgba(0,212,255,${alpha})`;
ctx.lineWidth = 1.8; ctx.stroke();
}
requestAnimationFrame(draw);
}
draw();
})();
// ⑥ STATIC NOISE BURSTS ───────────────────────────────────────────────
(function initStaticBursts() {
function burst() {
const panels = document.querySelectorAll('#app .panel');
if (panels.length) {
const p = panels[Math.floor(Math.random() * panels.length)];
const n = document.createElement('div');
n.className = 'panel-noise-layer';
p.appendChild(n);
setTimeout(() => n.remove(), 320);
}
setTimeout(burst, 75000 + Math.random() * 55000);
}
setTimeout(burst, 40000 + Math.random() * 30000);
})();
// ⑦ AMBIENT COLOR CYCLE ───────────────────────────────────────────────
(function initAmbientColor() {
let t = 0;
const root = document.documentElement;
function tick() {
t += 0.00025;
const g = Math.round(210 + Math.sin(t) * 22);
const b = Math.round(248 + Math.cos(t * 1.15) * 28);
root.style.setProperty('--cyan', `rgb(0,${g},${b})`);
root.style.setProperty('--cyan2', `rgb(0,${Math.round(g*.82)},${Math.round(b*.87)})`);
requestAnimationFrame(tick);
}
tick();
})();
// ── SLEEP MODE ────────────────────────────────────────────────────────────────
var isAsleep = false;
var _sleepRefreshTimer = null;
var SLEEP_CMDS = /\b(good\s*night(\s*jarvis)?|go\s*to\s*sleep|sleep\s*mode|shut\s*(down|off)\s*(jarvis|for\s*the\s*night)|go\s*offline|going\s*offline|jarvis\s*(go\s*)?(offline|sleep|shutdown)|stand\s*by\s*mode|power\s*down(\s*jarvis)?|signing\s*off)\b/i;
function enterSleepMode() {
if (isAsleep) return;
isAsleep = true;
// Pause voice mode
voiceMode = false;
voiceMuted = false;
updateMicBtn();
// Slow or pause the refresh loop — keep mic alive for wake word
clearInterval(refreshTimer);
refreshTimer = null;
// Light polling every 2 min just to stay alive
_sleepRefreshTimer = setInterval(function() {
// heartbeat only — keep session alive without hammering APIs
try { fetch('/api/auth', {method:'GET', headers:{'Authorization':'Bearer '+sessionToken}}); } catch(e) {}
}, 120000);
// Dim the UI
var app = document.getElementById('app');
if (app) app.classList.add('sleeping');
// Flash title to confirm
document.title = 'JARVIS — STANDBY';
addMessage('jarvis', 'Understood. Going offline. Say "wake up JARVIS" when you need me.');
}
function wakeFromSleep() {
if (!isAsleep) return;
isAsleep = false;
// Restore full polling
clearInterval(_sleepRefreshTimer);
_sleepRefreshTimer = null;
refreshAll();
refreshTimer = setInterval(refreshAll, 10000);
// Remove dim overlay
var app = document.getElementById('app');
if (app) app.classList.remove('sleeping');
document.title = 'JARVIS — Integrated Defense and Logistics System';
// Boot sequence
var topBar=document.getElementById('topBar'), lp=document.getElementById('leftPanel');
var rp=document.getElementById('rightPanel'), cp=document.getElementById('centerPanel');
[topBar,lp,rp,cp].forEach(function(el){if(el){el.style.opacity='0';}});
requestAnimationFrame(function(){
setTimeout(function(){if(topBar){topBar.style.opacity='';topBar.classList.add('boot-top');}},0);
setTimeout(function(){if(lp){lp.style.opacity='';lp.classList.add('boot-left');}},140);
setTimeout(function(){if(rp){rp.style.opacity='';rp.classList.add('boot-right');}},200);
setTimeout(function(){if(cp){cp.style.opacity='';cp.classList.add('boot-center');}},260);
setTimeout(function(){[topBar,lp,rp,cp].forEach(function(el){if(el)el.classList.remove('boot-top','boot-left','boot-right','boot-center');});},1400);
});
// Enter voice mode and greet
enterVoiceMode('wake');
}
function _focusWindow() {
// Attempt to bring browser window to front
try { window.focus(); } catch(e) {}
// Flash title to grab attention if tab is backgrounded
var _origTitle = 'JARVIS — Integrated Defense and Logistics System';
var _flashCount = 0;
var _titleFlash = setInterval(function() {
document.title = _flashCount % 2 === 0 ? '⚡ JARVIS — ONLINE' : _origTitle;
if (++_flashCount >= 8) { clearInterval(_titleFlash); document.title = _origTitle; }
}, 400);
// Notify if already have permission — never request during voice activity
if ('Notification' in window && Notification.permission === 'granted') {
try { new Notification('JARVIS', { body: 'Wake word detected.', tag: 'jarvis-wake', requireInteraction: false }); } catch(e) {}
}
}
// ── NETWORK MAP ──────────────────────────────────────────────────────────────
var _nmNodes=[], _nmEdges=[], _nmParticles=[], _nmRaf=null, _nmT=0, _nmHoverNode=null;
var _nmRot=[0,0,0,0,0,0];
var NM_RINGS=[
{name:'hub', label:'', rFrac:0, speed:0, rgb:'0,212,255', nodeR:30, cap:1 },
{name:'proxmox', label:'PROXMOX', rFrac:0.16, speed:0.006, rgb:'0,255,136', nodeR:22, cap:4 },
{name:'services',label:'SERVICES', rFrac:0.30, speed:-0.004, rgb:'255,215,0', nodeR:20, cap:7 },
{name:'agents', label:'AGENTS', rFrac:0.45, speed:0.0025, rgb:'0,190,255', nodeR:17, cap:12 },
{name:'devices', label:'DEVICES', rFrac:0.62, speed:-0.002, rgb:'0,160,200', nodeR:14, cap:14 },
{name:'network', label:'NETWORK', rFrac:0.82, speed:0.0015, rgb:'0,110,170', nodeR:11, cap:28 },
];
var NM_OPEN_RE = /\b(show|open|display|launch|pull\s*up|bring\s*up)\b.*\b(network(\s*(map|topology|viz|visual|graph|panel|status|scan|view|overlay))?|topology|node\s*map)\b|\bnetwork\s*(map|topology|viz|visual|graph)\b|\b(show|open|display|launch|pull\s*up|bring\s*up)\s+(?:me\s+)?(?:the\s+)?network\b/i;
var NM_CLOSE_RE = /\b(close|hide|dismiss|exit|collapse)\b.*\b(network|map|topology|overlay)\b|\b(close|hide|dismiss)\s*map\b/i;
function _nmClassify(d){
var h=(d.name||'').toLowerCase();
if(d.source==='agent'){
if(d.agent_type==='proxmox'||h.indexOf('pve')>=0||h.indexOf('proxmox')>=0) return 'proxmox';
if(d.agent_type==='homeassistant'||h.indexOf('homeassist')>=0||h.indexOf('_ha')>=0||
h.indexOf('ollama')>=0||h.indexOf('ai')>=0||h.indexOf('fusion')>=0||h.indexOf('pbx')>=0||
h.indexOf('jellyfin')>=0||h.indexOf('homebridge')>=0) return 'services';
return 'agents';
}
// Named/pinned DB devices and static hosts get the inner device ring
if(d.source==='db'||d.source==='static') return 'devices';
// Netscan-discovered devices go to the outer network ring
return 'network';
}
function _nmRgb(n){
if(!n.online) return '255,50,80';
if(n.ringIdx===1) return '0,255,136';
if(n.ringIdx===2) return '255,215,0';
if(n.ringIdx===3) return '0,190,255';
if(n.ringIdx===4) return '0,160,200';
if(n.ringIdx===5) return '0,110,170';
return '0,212,255';
}
function _nmNodePos(n,W,H){
if(n.ringIdx===0) return {x:W/2, y:H/2};
var rd=NM_RINGS[n.ringIdx], rot=_nmRot[n.ringIdx]||0;
var r=Math.min(W/2,H/2)*rd.rFrac;
return {x:W/2+Math.cos(n.angle+rot)*r, y:H/2+Math.sin(n.angle+rot)*r};
}
async function openNetMap(){
var ov=document.getElementById('netMapOverlay'); if(!ov) return;
ov.classList.remove('nm-closing'); ov.classList.add('nm-open');
var devices=[];
try{ var n=await api('network'); devices=n.devices||[]; }catch(e){}
_nmBuild(devices); _nmDraw();
}
function closeNetMap(){
var ov=document.getElementById('netMapOverlay'); if(!ov) return;
ov.classList.add('nm-closing');
setTimeout(function(){ ov.classList.remove('nm-open','nm-closing'); }, 350);
if(_nmRaf){ cancelAnimationFrame(_nmRaf); _nmRaf=null; }
}
function _nmBuild(devices){
_nmNodes=[]; _nmEdges=[]; _nmParticles=[];
// Hub
_nmNodes.push({id:'jarvis',label:'JARVIS',sub:'165.22.1.228',online:true,agent:true,ringIdx:0,angle:0,r:NM_RINGS[0].nodeR,pulse:0});
// Bucket
var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]};
for(var i=0;i<devices.length;i++) buckets[_nmClassify(devices[i])].push(devices[i]);
// Sort netscan devices: online first, then those with meaningful hostnames
buckets.network.sort(function(a,b){
var sa=a.alive?1:0, sb=b.alive?1:0;
if(sb!==sa) return sb-sa;
var ha=(a.name&&a.name!==a.ip&&a.name.indexOf('10.48')!==0)?1:0;
var hb=(b.name&&b.name!==b.ip&&b.name.indexOf('10.48')!==0)?1:0;
return hb-ha;
});
var rings=['proxmox','services','agents','devices','network'];
for(var ri=0;ri<rings.length;ri++){
var rname=rings[ri], rd=NM_RINGS[ri+1], list=buckets[rname].slice(0,rd.cap);
for(var j=0;j<list.length;j++){
var d=list[j];
var baseA=(ri%2===0?-Math.PI/2:-Math.PI/3);
var angle=baseA+(j/Math.max(list.length,1))*Math.PI*2;
_nmNodes.push({
id:d.agent_id||d.ip||(rname+j),
label:(d.name||d.ip||'?').replace(/_[a-f0-9]{6,}$/,'').substring(0,11),
sub:d.ip||'', online:!!(d.alive||d.status==='online'),
agent:d.source==='agent', ringIdx:ri+1, angle:angle,
r:rd.nodeR, pulse:Math.random()*Math.PI*2,
});
}
}
// Edges + particles
for(var i=1;i<_nmNodes.length;i++){
var n=_nmNodes[i], str=n.agent?0.9:0.45;
_nmEdges.push({from:i,to:0,strength:str});
if(n.online){
var cnt=n.agent?3:2;
for(var p=0;p<cnt;p++) _nmParticles.push({edge:_nmEdges.length-1,t:Math.random(),dir:'in',speed:0.002+Math.random()*0.0025,r:1.8+Math.random()*0.9});
if(n.agent&&Math.random()>0.45) _nmParticles.push({edge:_nmEdges.length-1,t:Math.random(),dir:'out',speed:0.0013+Math.random()*0.0017,r:1.5+Math.random()*0.7});
}
}
// Stats
var online=_nmNodes.filter(function(n){return n.online;}).length;
var agt=_nmNodes.filter(function(n){return n.agent;}).length;
function sg(id){return document.getElementById(id);}
if(sg('nm-node-count')) sg('nm-node-count').textContent=_nmNodes.length;
if(sg('nm-online-count')) sg('nm-online-count').textContent=online;
if(sg('nm-agent-count')) sg('nm-agent-count').textContent=agt;
}
function _nmDraw(){
if(_nmRaf) cancelAnimationFrame(_nmRaf);
var canvas=document.getElementById('nmCanvas'); if(!canvas) return;
var ov=document.getElementById('netMapOverlay');
canvas.width = ov ? ov.clientWidth : 820;
canvas.height= ov ? ov.clientHeight-48 : 490;
var W=canvas.width, H=canvas.height, cx=W/2, cy=H/2, minR=Math.min(cx,cy);
var ctx=canvas.getContext('2d');
function frame(){
if(!document.getElementById('netMapOverlay').classList.contains('nm-open')) return;
_nmRaf=requestAnimationFrame(frame);
_nmT+=0.016;
for(var i=0;i<NM_RINGS.length;i++) _nmRot[i]=(_nmRot[i]||0)+NM_RINGS[i].speed;
ctx.clearRect(0,0,W,H);
// Dot grid
ctx.fillStyle='rgba(0,180,255,0.05)';
for(var gx=26;gx<W;gx+=38) for(var gy=26;gy<H;gy+=38){ctx.beginPath();ctx.arc(gx,gy,0.7,0,Math.PI*2);ctx.fill();}
// Ring tracks
for(var ri=1;ri<NM_RINGS.length;ri++){
var rd=NM_RINGS[ri], r=minR*rd.rFrac;
var hasOn=false;
for(var i=0;i<_nmNodes.length;i++) if(_nmNodes[i].ringIdx===ri&&_nmNodes[i].online){hasOn=true;break;}
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.strokeStyle=hasOn?'rgba('+rd.rgb+',0.12)':'rgba(60,60,80,0.08)';
ctx.lineWidth=0.8; ctx.setLineDash([3,9]); ctx.stroke(); ctx.setLineDash([]);
// Ticks
for(var a=0;a<Math.PI*2;a+=Math.PI/6){
ctx.beginPath();ctx.moveTo(cx+Math.cos(a)*(r-3),cy+Math.sin(a)*(r-3));ctx.lineTo(cx+Math.cos(a)*(r+3),cy+Math.sin(a)*(r+3));
ctx.strokeStyle='rgba('+rd.rgb+',0.18)';ctx.lineWidth=0.6;ctx.stroke();
}
// Label at 3-o'clock
var zOn=0,zTot=0;
for(var i=0;i<_nmNodes.length;i++){if(_nmNodes[i].ringIdx===ri){zTot++;if(_nmNodes[i].online)zOn++;}}
ctx.font='700 8px Share Tech Mono,monospace'; ctx.textAlign='left';
ctx.fillStyle='rgba('+rd.rgb+',0.4)'; ctx.fillText(rd.label,cx+r+5,cy+2);
if(zTot>0){ctx.font='6.5px Share Tech Mono,monospace';ctx.fillStyle='rgba('+rd.rgb+',0.28)';ctx.fillText(zOn+'/'+zTot,cx+r+5,cy+11);}
ctx.textAlign='left';
}
// Compute node positions
var pos=[];
for(var i=0;i<_nmNodes.length;i++) pos.push(_nmNodePos(_nmNodes[i],W,H));
// Spokes
for(var ei=0;ei<_nmEdges.length;ei++){
var e=_nmEdges[ei], pa=pos[e.from], pb=pos[e.to]; if(!pa||!pb) continue;
var n=_nmNodes[e.from], rgb=_nmRgb(n);
// Apply float offset to spoke endpoints
var fya=Math.sin(_nmT*0.85+_nmNodes[e.from].pulse)*4;
var fyb=Math.sin(_nmT*0.85+_nmNodes[e.to].pulse)*4;
var lg=ctx.createLinearGradient(pa.x,pa.y+fya,pb.x,pb.y+fyb);
if(n.online){lg.addColorStop(0,'rgba('+rgb+',0.22)');lg.addColorStop(0.5,'rgba('+rgb+',0.08)');lg.addColorStop(1,'rgba(0,212,255,0.15)');}
else{lg.addColorStop(0,'rgba(80,20,30,0.07)');lg.addColorStop(1,'rgba(80,20,30,0.07)');}
ctx.beginPath();ctx.moveTo(pa.x,pa.y+fya);ctx.lineTo(pb.x,pb.y+fyb);
ctx.strokeStyle=lg;ctx.lineWidth=e.strength*1.1;ctx.stroke();
}
// Particles
for(var pi=0;pi<_nmParticles.length;pi++){
var p=_nmParticles[pi]; p.t=(p.t+p.speed)%1;
var e=_nmEdges[p.edge]; if(!e) continue;
var pa=pos[e.from],pb=pos[e.to]; if(!pa||!pb) continue;
if(!_nmNodes[e.from].online) continue;
var t=p.dir==='in'?p.t:1-p.t;
var px=pa.x+(pb.x-pa.x)*t, py=pa.y+(pb.y-pa.y)*t;
var fade=Math.min(t*8,(1-t)*8,1);
ctx.beginPath();ctx.arc(px,py,p.r,0,Math.PI*2);
if(p.dir==='in'){ctx.fillStyle='rgba(0,210,255,'+(((0.6+Math.sin(_nmT*3+p.t*10)*0.3)*fade).toFixed(3))+')';ctx.shadowColor='rgba(0,200,255,0.7)';}
else{ctx.fillStyle='rgba(255,130,0,'+(((0.5+Math.sin(_nmT*4+p.t*8)*0.25)*fade).toFixed(3))+')';ctx.shadowColor='rgba(255,110,0,0.6)';}
ctx.shadowBlur=6;ctx.fill();ctx.shadowBlur=0;
}
// Bubble nodes
for(var ni=0;ni<_nmNodes.length;ni++){
var n=_nmNodes[ni], p=pos[ni], rgb=_nmRgb(n);
var pulse=0.5+Math.sin(_nmT*1.4+n.pulse)*0.3;
var isHub=n.ringIdx===0, isHov=_nmHoverNode===ni;
// Float offset — each node drifts on Y with unique phase
var fy=Math.sin(_nmT*0.85+n.pulse)*4;
var px=p.x, py=p.y+fy;
var baseR=n.r*(isHov?1.28:1.0);
// Ambient glow bloom
if(n.online){
var bloomR=baseR*2.6+Math.sin(_nmT*1.1+n.pulse)*3;
var bloom=ctx.createRadialGradient(px,py,baseR*0.4,px,py,bloomR);
bloom.addColorStop(0,'rgba('+rgb+','+(pulse*0.2).toFixed(3)+')');
bloom.addColorStop(1,'rgba('+rgb+',0)');
ctx.beginPath();ctx.arc(px,py,bloomR,0,Math.PI*2);ctx.fillStyle=bloom;ctx.fill();
// Sonar ping — expanding ring that fades out
var pingR=baseR+(((_nmT*0.6+n.pulse)%1)*baseR*2.5);
var pingA=(1-(pingR-baseR)/(baseR*2.5))*0.3;
ctx.beginPath();ctx.arc(px,py,pingR,0,Math.PI*2);
ctx.strokeStyle='rgba('+rgb+','+pingA.toFixed(3)+')';
ctx.lineWidth=0.7;ctx.stroke();
}
// Frosted glass fill
var fg=ctx.createRadialGradient(px,py-baseR*0.28,0,px,py,baseR);
var fa=n.online?0.17+pulse*0.1:0.06;
fg.addColorStop(0,'rgba('+rgb+','+(fa*2.0).toFixed(3)+')');
fg.addColorStop(0.55,'rgba('+rgb+','+fa.toFixed(3)+')');
fg.addColorStop(1,'rgba('+rgb+','+(fa*0.15).toFixed(3)+')');
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=fg;ctx.fill();
// Glassy highlight sheen (top-left arc)
if(n.online){
var sh=ctx.createRadialGradient(px-baseR*0.3,py-baseR*0.35,0,px,py,baseR);
sh.addColorStop(0,'rgba(255,255,255,0.12)');sh.addColorStop(0.45,'rgba(255,255,255,0.03)');sh.addColorStop(1,'rgba(255,255,255,0)');
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=sh;ctx.fill();
}
// Border
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);
ctx.strokeStyle='rgba('+rgb+','+(n.online?(0.45+pulse*0.35).toFixed(3):'0.18')+')';
ctx.lineWidth=isHub?1.8:1.1;ctx.stroke();
// Hub crosshairs (softer)
if(isHub){
ctx.strokeStyle='rgba('+rgb+',0.15)';ctx.lineWidth=0.6;
var ext=50;
var hlines=[[px-ext,py,px-baseR-3,py],[px+baseR+3,py,px+ext,py],[px,py-ext,px,py-baseR-3],[px,py+baseR+3,px,py+ext]];
for(var li=0;li<hlines.length;li++){ctx.beginPath();ctx.moveTo(hlines[li][0],hlines[li][1]);ctx.lineTo(hlines[li][2],hlines[li][3]);ctx.stroke();}
}
// Status dot
if(!isHub){
ctx.beginPath();ctx.arc(px+baseR*0.62,py-baseR*0.62,2.5,0,Math.PI*2);
ctx.fillStyle=n.online?'rgba(0,255,120,0.95)':'rgba(255,50,80,0.95)';ctx.fill();
}
// Label — centered below bubble
var lblY=py+baseR+11;
ctx.font=(isHub?'700 11':'8')+'px Share Tech Mono,monospace';
ctx.textAlign='center';
ctx.fillStyle=n.online?'rgba('+rgb+',0.95)':'rgba(220,90,90,0.7)';
ctx.fillText(n.label,px,lblY);
if(n.sub&&(isHub||isHov)){
ctx.font='6.5px Share Tech Mono,monospace';
ctx.fillStyle='rgba(140,195,215,0.55)';
ctx.fillText(n.sub,px,lblY+10);
}
ctx.textAlign='left';
}
}
frame();
canvas.onmousemove=function(e){
var rect=canvas.getBoundingClientRect(), mx=e.clientX-rect.left, my=e.clientY-rect.top;
var found=-1;
for(var i=0;i<_nmNodes.length;i++){var p=_nmNodePos(_nmNodes[i],W,H);if(Math.sqrt((p.x-mx)*(p.x-mx)+(p.y-my)*(p.y-my))<_nmNodes[i].r+10){found=i;break;}}
_nmHoverNode=found>=0?found:null;
var info=document.getElementById('nmNodeInfo'); if(!info) return;
if(found>=0){
var n=_nmNodes[found],rgb=_nmRgb(n);
document.getElementById('ni-name').textContent=n.label; document.getElementById('ni-name').style.color='rgb('+rgb+')';
document.getElementById('ni-ip').textContent='IP: '+(n.sub||'—');
document.getElementById('ni-status').textContent='STATUS: '+(n.online?'ONLINE':'OFFLINE');
document.getElementById('ni-type').textContent='RING: '+(NM_RINGS[n.ringIdx]?NM_RINGS[n.ringIdx].name.toUpperCase():'HUB');
info.style.display='block'; info.style.left=(mx+14)+'px'; info.style.top=(my-6)+'px';
} else { info.style.display='none'; }
};
canvas.onmouseleave=function(){_nmHoverNode=null;var i=document.getElementById('nmNodeInfo');if(i)i.style.display='none';};
}
// ── GLOBALS ──────────────────────────────────────────────────────────
let sessionToken = '';
let sessionUser = '';
let sessionId = 'session_' + Date.now();
let isListening = false;
let recognition = null;
let synth = window.speechSynthesis;
let selectedVoice = null;
let refreshTimer = null;
let isSpeaking = false;
let panelsVisible = true;
let cameraActive = false;
let faceLoopId = null;
let lastFaceSeen = 0;
let autoMicCooldown = 0;
let faceApiReady = false;
let lastActivity = Date.now();
const IDLE_RELOAD_MS = 5 * 60 * 1000; // 5 min inactivity → full reload
let voiceMode = false; // true = JARVIS awake (listening for commands)
let voiceMuted = false; // true = awake but mic muted
let voiceLastCmd = 0;
const VOICE_SLEEP_MS = 30 * 60 * 1000; // 30 min voice inactivity → sleep
const VOICE_ACTIVE_MS = 17000; // 17s active window after each command
let voiceActive = 0; // timestamp of last issued command
// Phase 1: full phrase required to wake from sleep
const WAKE_PHRASES = ["wake up jarvis", "daddy's home", "wake up, jarvis", "daddys home"];
// Phase 2: command prefix — "jarvis <command>"; then 17s free-listen window
const CMD_PREFIX = 'jarvis';
const FACE_MODEL_URL = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights';
// ── INIT ─────────────────────────────────────────────────────────────
function initCollapsiblePanels() {
document.querySelectorAll('.panel').forEach(panel => {
const title = panel.querySelector('.panel-title');
if (!title) return;
const btn = document.createElement('button');
btn.className = 'panel-collapse-btn';
btn.textContent = '▾';
btn.title = 'Collapse / expand';
title.appendChild(btn);
const key = 'pnl_' + (title.textContent||'').trim().substring(0,24).replace(/\s+/g,'_').toLowerCase().replace(/[^a-z0-9_]/g,'');
if (localStorage.getItem(key) === '1') panel.classList.add('collapsed');
title.addEventListener('click', e => {
if (e.target.closest('button:not(.panel-collapse-btn),a,input,select')) return;
const col = panel.classList.toggle('collapsed');
localStorage.setItem(key, col ? '1' : '0');
});
});
}
window.addEventListener("load", () => {
["mousemove","keydown","touchstart","click"].forEach(e =>
window.addEventListener(e, () => { lastActivity = Date.now(); }, {passive:true})
);
updateClock();
setInterval(updateClock, 1000);
initVoice();
loadVoices();
// Check if already logged in — prefer PHP-injected global, fall back to sessionStorage
const saved = (typeof __jarvisToken !== 'undefined' ? __jarvisToken : null)
|| sessionStorage.getItem('jarvis_token');
const savedUser = (typeof __jarvisUser !== 'undefined' ? __jarvisUser : null)
|| sessionStorage.getItem('jarvis_user') || '';
const autoReload = sessionStorage.getItem('jarvis_autoreload') === '1';
sessionStorage.removeItem('jarvis_autoreload');
if (saved) {
sessionToken = saved;
sessionUser = savedUser;
try { sessionStorage.setItem('jarvis_token', saved); sessionStorage.setItem('jarvis_user', savedUser); } catch(e) {}
if (localStorage.getItem('jarvis_panels_swapped') === '1') swapPanels();
showApp(savedUser, null, autoReload);
}
});
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent =
now.toLocaleTimeString('en-US',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
document.getElementById('date-display').textContent =
now.toLocaleDateString('en-US',{weekday:'short',year:'numeric',month:'short',day:'numeric'}).toUpperCase();
}
// ── LOGIN ─────────────────────────────────────────────────────────────
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const user = document.getElementById('loginUser').value;
const pass = document.getElementById('loginPass').value;
const errEl = document.getElementById('loginError');
errEl.textContent = '';
try {
const res = await api('auth', 'POST', {username:user, password:pass});
if (res.success) {
sessionToken = res.token;
sessionUser = res.display_name;
sessionStorage.setItem('jarvis_token', sessionToken);
sessionStorage.setItem('jarvis_user', sessionUser);
showApp(sessionUser, res.greeting);
} else {
errEl.textContent = 'ACCESS DENIED';
}
} catch(err) {
errEl.textContent = 'CONNECTION FAILED';
}
});
function showApp(name, greeting, silent = false) {
document.getElementById('loginScreen').style.display = 'none';
const app = document.getElementById('app');
app.style.display = 'flex';
// HUD boot sequence — staggered slide-in
const topBar = document.getElementById('topBar');
const leftPanel = document.getElementById('leftPanel');
const rightPanel = document.getElementById('rightPanel');
const centerPanel= document.getElementById('centerPanel');
[topBar, leftPanel, rightPanel, centerPanel].forEach(el => el && (el.style.opacity = '0'));
requestAnimationFrame(() => {
if (topBar) { topBar.style.opacity=''; topBar.style.animationDelay='0s'; topBar.classList.add('boot-top'); }
setTimeout(()=>{ if(leftPanel) { leftPanel.style.opacity=''; leftPanel.style.animationDelay='0s'; leftPanel.classList.add('boot-left'); }}, 120);
setTimeout(()=>{ if(rightPanel) { rightPanel.style.opacity=''; rightPanel.style.animationDelay='0s'; rightPanel.classList.add('boot-right'); }}, 180);
setTimeout(()=>{ if(centerPanel){ centerPanel.style.opacity='';centerPanel.style.animationDelay='0s';centerPanel.classList.add('boot-center');}}, 240);
setTimeout(()=>{ [topBar,leftPanel,rightPanel,centerPanel].forEach(el=>el?.classList.remove('boot-top','boot-left','boot-right','boot-center')); }, 1200);
});
if (!silent) {
if (greeting) {
addMessage('jarvis', greeting);
speak(greeting);
} else {
const g = `Welcome back, ${name}. All systems online and standing by.`;
addMessage('jarvis', g);
speak(g);
}
}
// Start data refresh
initCollapsiblePanels();
refreshAll();
refreshTimer = setInterval(refreshAll, 10000); // every 10s
setInterval(() => {
if (!isAsleep && Date.now() - lastActivity > IDLE_RELOAD_MS) {
sessionStorage.setItem('jarvis_autoreload', '1');
location.reload();
}
}, 30000);
setInterval(() => {
if (voiceMode && voiceLastCmd > 0 && Date.now() - voiceLastCmd > VOICE_SLEEP_MS) {
exitVoiceMode();
}
}, 60000);
// Watchdog: reset isSpeaking if stuck; heartbeat keeps mic alive
setInterval(() => {
if (isSpeaking && !_ttsAudio && !window.speechSynthesis?.speaking) {
isSpeaking = false;
if (isListening) _scheduleRecStart(200);
}
}, 4000);
// Heartbeat: if mic should be on but recognition has gone quiet, nudge it
setInterval(() => {
if (isListening && !isSpeaking) {
try {
recognition.start(); // throws if already running — that's fine
} catch(_) {}
}
}, 12000);
startListening();
loadNetwork();
loadHA();
checkAgentStatus();
checkArcStatus().catch(() => {});
loadAgents();
loadAlerts();
loadWeather();
loadNews();
initMobile();
setTimeout(checkPlannerReminder, 3000);
setInterval(checkUpcomingAppts, 300000);
setTimeout(pollAlertsProactive, 8000);
setTimeout(checkSuggestions, 15000);
setInterval(checkSuggestions, 1800000); // every 30 min // baseline on load
setInterval(pollAlertsProactive, 60000); // poll every 60s
// Guardian Mode — badge refresh + proactive chat
setTimeout(() => {
_refreshGuardianBadge();
_pollProactiveChat();
startGuardianPolling();
setInterval(_pollProactiveChat, 30000);
}, 5000);
// Clearance banner — poll every 30s
setTimeout(() => {
updateClearanceBanner();
setInterval(updateClearanceBanner, 30000);
}, 6000);
// Memory Core — poll count every 60s
setTimeout(() => {
updateMemoryCount();
setInterval(updateMemoryCount, 60000);
}, 8000);
}
async function logout() {
clearInterval(refreshTimer);
await api('auth', 'DELETE', {});
sessionStorage.clear();
location.reload();
}
// ── API HELPER ────────────────────────────────────────────────────────
async function api(endpoint, method='GET', body=null) {
const opts = {
method,
headers: {'Content-Type':'application/json','X-Session-Token':sessionToken},
credentials:'include',
};
if (body && method !== 'GET') opts.body = JSON.stringify(body);
const res = await fetch('/api/' + endpoint, opts);
if (res.status === 401) { logout(); return {}; }
return res.json();
}
// ── PANEL TOGGLE ─────────────────────────────────────────────────────
function swapPanels() {
const layout = document.getElementById('mainLayout');
const btn = document.getElementById('btn-swap-panels');
const isSwapped = layout.classList.toggle('swapped');
btn.classList.toggle('active', isSwapped);
localStorage.setItem('jarvis_panels_swapped', isSwapped ? '1' : '0');
}
function togglePanels(silent) {
panelsVisible = !panelsVisible;
const layout = document.getElementById('mainLayout');
const btn = document.getElementById('panelToggleBtn');
if (panelsVisible) {
layout.classList.remove('focus-mode');
btn.classList.remove('focus-active');
btn.textContent = '◧ PANELS';
if (!silent) speak('Full view restored.');
} else {
layout.classList.add('focus-mode');
btn.classList.add('focus-active');
btn.textContent = '◫ FOCUS';
if (!silent) speak('Focus mode activated. Side panels hidden.');
}
}
// Keyboard shortcut: backslash to toggle panels
document.addEventListener('keydown', (e) => {
if (e.key === '\\' && document.activeElement.id !== 'textInput') {
togglePanels();
}
});
// ── CAMERA FACE DETECTION / AUTO-MIC ─────────────────────────────────
async function loadFaceApi() {
if (faceApiReady) return true;
try {
if (typeof faceapi === 'undefined') {
addMessage('system', 'Face detection library not available.');
return false;
}
await faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URL);
faceApiReady = true;
return true;
} catch(e) {
addMessage('system', 'Could not load face detection model: ' + e.message);
return false;
}
}
async function startCamera() {
if (cameraActive) return;
const btn = document.getElementById('cameraBtn');
btn.textContent = '◉ LOADING…';
try {
const stream = await navigator.mediaDevices.getUserMedia(
{video:{facingMode:'user', width:{ideal:320}, height:{ideal:240}}, audio:false}
);
const video = document.getElementById('faceVideo');
video.srcObject = stream;
await video.play();
const ok = await loadFaceApi();
if (!ok) { stopCamera(); return; }
cameraActive = true;
btn.classList.add('cam-active');
btn.textContent = '◉ SENSING';
startFaceTracking();
addMessage('system', 'Face detection active — reactor tracking engaged.');
faceLoopId = setInterval(async () => {
if (!cameraActive) return;
// Run detection even while speaking — needed for tracking + prevents lastFaceSeen staling out
try {
const detection = await faceapi.detectSingleFace(
document.getElementById('faceVideo'),
new faceapi.TinyFaceDetectorOptions({inputSize:160, scoreThreshold:0.45})
);
const now = Date.now();
if (detection) {
lastFaceSeen = now;
const ratio = (detection.box.width * detection.box.height) / (320 * 240);
// Always drive the reactor
updateFaceTarget(detection.box, 320, 240);
// Only auto-trigger voice when not already speaking/active, cooldown passed
if (ratio > 0.03 && !voiceMode && !isSpeaking && now > autoMicCooldown) {
autoMicCooldown = now + 9000;
document.getElementById('cameraBtn').classList.add('cam-sensing');
enterVoiceMode();
}
} else {
// While JARVIS is speaking, keep lastFaceSeen fresh so the exit timer doesn't tick down
if (isSpeaking) { lastFaceSeen = now; }
else { clearFaceTarget(); }
document.getElementById('cameraBtn').classList.remove('cam-sensing');
// Exit voice mode only if: face gone >12s AND no command in that same window AND not speaking
const noFaceMs = now - lastFaceSeen;
const noCommandMs = now - (voiceLastCmd || 0);
if (voiceMode && !isSpeaking && noFaceMs > 12000 && noCommandMs > 12000) {
exitVoiceMode();
}
}
} catch(_) {}
}, 600);
} catch(e) {
btn.textContent = '◉ CAMERA';
if (e.name === 'NotAllowedError') {
addMessage('system', 'Camera permission denied. Grant camera access in browser settings to enable hands-free mode.');
} else {
addMessage('system', 'Camera unavailable: ' + e.message);
}
}
}
function stopCamera() {
cameraActive = false;
clearInterval(faceLoopId);
faceLoopId = null;
const video = document.getElementById('faceVideo');
if (video && video.srcObject) {
video.srcObject.getTracks().forEach(t => t.stop());
video.srcObject = null;
}
const btn = document.getElementById('cameraBtn');
if (btn) {
btn.classList.remove('cam-active', 'cam-sensing');
btn.textContent = '◉ CAMERA';
}
stopFaceTracking();
}
function toggleCamera() {
if (cameraActive) {
stopCamera();
addMessage('system', 'Face detection disabled.');
} else {
startCamera();
}
}
// ── REFRESH ALL ───────────────────────────────────────────────────────
let _refreshTick = 0;
let selectedContext = null;
const _panelCtx = {};
let _haEntities = {};
const _svcLabels = {lshttpd:'WEB',mysql:'MYSQL',redis:'REDIS',memcached:'MEMCACHE',postfix:'POSTFIX',dovecot:'DOVECOT','jarvis-agent':'AGENT'};
async function refreshAll() {
_refreshTick++;
const el = document.getElementById('last-refresh');
if (el) el.textContent = new Date().toLocaleTimeString('en-US',{hour12:false});
// Fire core calls in parallel — cuts refresh latency from ~3s to ~600ms
const [s, n, d] = await Promise.all([
api('system').catch(() => null),
api('network').catch(() => null),
api('do').catch(() => null),
]);
if (s) renderSystem(s);
if (n) renderNetworkStatus(n);
if (d) renderDO(d);
// Agent status every tick (fire and forget — doesn't block)
checkAgentStatus().catch(() => {});
// Refresh right-panel tabs every 3rd tick (~30s) — all parallel
if (_refreshTick % 3 === 0) {
Promise.all([
loadHA().catch(() => {}),
loadAlerts().catch(() => {}),
loadAgents().catch(() => {}),
loadProxmox().catch(() => {}),
loadPlannerSummary().catch(() => {}),
]);
}
// Refresh Arc Reactor status every 6th tick (~60s)
if (_refreshTick % 6 === 0) {
checkArcStatus().catch(() => {});
}
// Refresh weather + news every 18th tick (~3 min)
if (_refreshTick % 18 === 0) {
Promise.all([
loadWeather().catch(() => {}),
loadNews().catch(() => {}),
]);
}
}
// ── ANIMATED NUMBER COUNTER ───────────────────────────────────────────
const _prevVals = {};
function tickTo(id, newVal, unit='%', decimals=0) {
const el = document.getElementById(id);
if (!el) return;
const prev = _prevVals[id] ?? newVal;
_prevVals[id] = newVal;
if (Math.abs(newVal - prev) < 0.5) { el.textContent = newVal.toFixed(decimals) + unit; return; }
const start = performance.now(), dur = 700;
(function frame(now) {
const p = Math.min((now - start) / dur, 1);
const ease = 1 - Math.pow(1 - p, 3);
el.textContent = (prev + (newVal - prev) * ease).toFixed(decimals) + unit;
if (p < 1) requestAnimationFrame(frame);
})(performance.now());
}
// ── RENDER: SYSTEM ────────────────────────────────────────────────────
function renderSystem(s) {
if (!s || s.error) return;
const cpu = s.cpu || 0;
const mem = s.memory?.percent || 0;
const disk = s.disk?.percent || 0;
// Top bar (animated)
tickTo('tb-cpu', cpu, '');
tickTo('tb-mem', mem, '');
// Metric bars
setBar('cpu', cpu);
setBar('mem', mem);
setBar('disk', disk);
tickTo('cpu-val', cpu);
tickTo('mem-val', mem);
tickTo('disk-val', disk);
// Sparklines
pushSparkData('cpu', cpu);
pushSparkData('mem', mem);
pushSparkData('disk', disk);
drawSparkline('spark-cpu', _sparkData.cpu, 'rgb(0,212,255)');
drawSparkline('spark-mem', _sparkData.mem, 'rgb(0,255,136)');
drawSparkline('spark-disk', _sparkData.disk, 'rgb(255,166,0)');
// Flash the system panel on data arrival
flashPanel(document.querySelector('#leftPanel .panel'));
document.getElementById('uptime-val').textContent = s.uptime || '--';
document.getElementById('load-val').textContent = s.load?.['1m'] || '--';
document.getElementById('host-val').textContent = s.hostname || 'jarvis';
// Services
if (s.services) {
const svcEl = document.getElementById('services-list');
svcEl.innerHTML = Object.entries(s.services).map(([k,v]) =>
`<div class="service-row">
<span class="svc-name">${_svcLabels[k]||k.toUpperCase()}</span>
<div class="svc-dot ${v?'on':'off'}" title="${k}: ${v?'ACTIVE':'INACTIVE'}"></div>
</div>`
).join('');
}
// Processes
if (s.processes?.length) {
document.getElementById('procs-list').innerHTML = s.processes.map(p =>
`<div class="val-row">
<div class="lbl" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.cmd}</div>
<div class="val">${p.cpu}%</div>
</div>`
).join('');
}
}
function setBar(id, pct) {
const el = document.getElementById(id+'-bar');
if (!el) return;
el.style.width = Math.min(pct,100) + '%';
el.className = 'metric-bar-fill' + (pct>90?' danger':pct>75?' warn':'');
}
// ── RENDER: DO SERVER (site health only — metrics merged into system panel) ───
function renderDO(d) {
const dot = document.getElementById('bb-do-dot');
const status = document.getElementById('bb-do-status');
const sitesEl = document.getElementById('sites-list');
if (!d || d.error || !d.reachable) {
if (dot) dot.className = 'bb-dot offline';
if (status) status.textContent = 'OFFLINE';
document.getElementById('tb-do').className = 'text-red';
document.getElementById('tb-do').textContent = 'OFFLINE';
if (sitesEl) sitesEl.innerHTML = '<div class="text-dim" style="font-size:0.72rem">Unavailable</div>';
return;
}
dot.className = 'bb-dot online';
status.textContent = 'ONLINE';
document.getElementById('tb-do').className = 'text-green';
document.getElementById('tb-do').textContent = 'ONLINE';
if (sitesEl && d.sites && Object.keys(d.sites).length) {
sitesEl.innerHTML = Object.entries(d.sites).map(([k, v]) => {
const cls = v === 'up' ? 'ok' : v === 'down' ? 'danger' : 'warn';
const lbl = k.replace(/^https?:\/\//, '').replace(/\.orbishosting\.com$/, '').replace(/\.com$/, '');
return `<div class="val-row">
<div class="lbl" style="font-size:0.62rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${lbl}</div>
<div class="val ${cls}">${v.toUpperCase()}</div>
</div>`;
}).join('');
}
}
async function loadNetwork() {
try {
const n = await api('network');
renderNetworkStatus(n);
} catch(e) {}
}
// ── RENDER: NETWORK ───────────────────────────────────────────────────
function renderNetworkStatus(n) {
if (!n) return;
renderTopology(n.devices || []);
const el = document.getElementById('network-list');
if (!el) return;
const devices = n.devices || [];
const online = devices.filter(d => d.alive || d.status === 'online').length;
const countEl = document.getElementById('net-agent-count');
if (countEl) countEl.textContent = online + '/' + devices.length + ' ONLINE';
const agents = devices.filter(d => d.source === 'agent');
const others = devices.filter(d => d.source !== 'agent');
function renderDev(d) {
const alive = d.alive || d.status === 'online';
const ctxKey = d.source === 'agent' ? 'agent_' + d.agent_id : 'net_' + (d.ip||'').replace(/\./g,'_');
_panelCtx[ctxKey] = {type: d.source === 'agent' ? 'agent' : 'network',
label: d.name || d.ip, ip: d.ip, status: d.status || (alive ? 'online' : 'offline'),
agent_id: d.agent_id, hostname: d.name};
const lat = d.latency_ms ? ' · ' + d.latency_ms + 'ms' : '';
const badge = d.source === 'agent'
? `<span style="font-size:0.53rem;color:var(--cyan);letter-spacing:1px;margin-left:4px">${(d.agent_type||'AGENT').toUpperCase()}</span>` : '';
const del = d.deletable
? `<button onclick="deleteNetworkDevice('${d.ip}',event)" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:0.9rem;padding:0 2px;opacity:0.5;flex-shrink:0" title="Remove">×</button>` : '';
const bl = d.source === 'agent' ? 'border-left:2px solid ' + (alive ? 'var(--green)' : 'var(--red)') + ';' : '';
return `<div class="device-item" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" style="${bl}display:flex;align-items:center">
<div class="device-status ${alive?'on':'off'}" style="flex-shrink:0"></div>
<div class="device-info" style="flex:1;min-width:0">
<div class="device-name" style="display:flex;align-items:center">${d.name||d.ip}${badge}</div>
<div class="device-ip">${d.ip||''}${lat}</div>
</div>${del}
</div>`;
}
let out = '';
if (agents.length) {
const agOn = agents.filter(d => d.alive || d.status === 'online').length;
out += `<div style="font-family:var(--font-mono);font-size:0.53rem;color:var(--cyan);letter-spacing:2px;padding:2px 0 3px">AGENTS (${agOn}/${agents.length})</div>`;
out += agents.map(renderDev).join('');
}
if (others.length) {
if (agents.length) out += '<div style="border-top:1px solid var(--panel-border);margin:5px 0 3px"></div>';
out += `<div style="font-family:var(--font-mono);font-size:0.53rem;color:var(--text-dim);letter-spacing:2px;padding:2px 0 3px">DEVICES</div>`;
out += others.map(renderDev).join('');
}
if (!out) out = '<div style="color:var(--text-dim);font-size:0.7rem;text-align:center;padding:8px">No devices</div>';
el.innerHTML = out;
}
// ── NETWORK SCAN ──────────────────────────────────────────────────────
async function scanNetwork() {
const btn = document.getElementById('scanBtn');
btn.textContent = 'QUEUING...';
btn.disabled = true;
try {
const data = await api('network/scan');
const count = data.count ?? 0;
const msg = data.queued
? `Network scan dispatched to PVE1 probe, Sir. Currently showing ${count} active device${count!==1?'s':''} — panel will refresh with live results in approximately 40 seconds.`
: `Showing last known network data: ${count} active device${count!==1?'s':''} on 10.48.200.0/24. PVE1 probe scans automatically every 3 minutes.`;
addMessage('jarvis', msg);
speak(count + ' devices online.');
// Refresh the network panel with current data
loadNetwork();
// Auto-refresh again after 45s to catch PVE1 scan results
if (data.queued) setTimeout(loadNetwork, 45000);
} catch(e) {
addMessage('jarvis', 'Network scan request failed, Sir.');
}
btn.textContent = 'RUN NETWORK SCAN';
btn.disabled = false;
}
// ── PROXMOX ───────────────────────────────────────────────────────────
async function loadProxmox() {
const data = await api('proxmox');
const el = document.getElementById('vm-list');
const dot = document.getElementById('bb-pve-dot');
const status = document.getElementById('bb-pve-status');
if (!data.configured) {
el.innerHTML = `<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text-dim);line-height:1.5">
<div class="text-yellow" style="margin-bottom:8px">⚠ NOT CONFIGURED</div>
Set PROXMOX_HOST and PROXMOX_TOKEN_VAL in config.php to enable VM monitoring.
</div>`;
dot.className='bb-dot offline'; status.textContent='NOT CONFIGURED';
return;
}
dot.className='bb-dot online'; status.textContent='ONLINE';
const vms = [...(data.vms||[]), ...(data.containers||[])];
if (!vms.length) {
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No VMs found.</div>';
return;
}
el.innerHTML = vms.map(vm => {
const statusColor = vm.status==='running'?'var(--green)':vm.status==='stopped'?'var(--red)':'var(--yellow)';
const cpuClass = vm.cpu>80?'text-red':vm.cpu>60?'text-orange':'text-cyan';
const ctxKey = 'vm_' + vm.vmid;
_panelCtx[ctxKey] = {type:'vm', label:vm.name,
vmid:vm.vmid, name:vm.name, status:vm.status,
cpu:vm.cpu, mem_mb:vm.mem_mb, maxmem_mb:vm.maxmem_mb,
type_label:vm.type||'qemu', uptime:vm.uptime||0};
return `<div class="vm-card" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this VM">
<div class="vm-header">
<span class="vm-name">${vm.name}</span>
<span style="color:${statusColor};font-size:0.65rem">● ${(vm.status||'').toUpperCase()}</span>
</div>
<div class="vm-metrics">
<div class="vm-metric">CPU <span class="${cpuClass}">${vm.cpu}%</span></div>
<div class="vm-metric">RAM <span class="text-cyan">${vm.mem_mb||0}/${vm.maxmem_mb||0}MB</span></div>
<div class="vm-metric">ID <span class="text-dim">${vm.vmid}</span></div>
<div class="vm-metric">TYPE <span class="text-dim">${vm.type||'qemu'}</span></div>
</div>
</div>`;
}).join('');
}
// ── HOME ASSISTANT ────────────────────────────────────────────────────
async function loadHA() {
const data = await api('ha');
const el = document.getElementById('ha-list');
const dot = document.getElementById('bb-ha-dot');
const sta = document.getElementById('bb-ha-status');
if (!data.configured) {
el.innerHTML = `<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text-dim);line-height:1.5">
<div class="text-yellow" style="margin-bottom:8px">⚠ NOT CONFIGURED</div>
Set HA_URL and HA_TOKEN in config.php to enable smart home control.
</div>`;
dot.className='bb-dot offline'; sta.textContent='NOT CONFIGURED';
return;
}
dot.className='bb-dot online'; sta.textContent='ONLINE';
const entities = data.entities || {};
_haEntities = entities;
if (!Object.keys(entities).length) {
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No entities found.</div>';
return;
}
renderHATable(entities);
}
const _domainIcon = {
light:'\u{1F4A1}', switch:'\u{1F50C}', scene:'\u{1F3AC}',
media_player:'\u{1F4FA}', alarm_control_panel:'\u{1F512}',
lawn_mower:'\u{1F33F}', water_heater:'\u{1F321}', fan:'\u{1F4A8}',
lock:'\u{1F511}', cover:'\u{1FA9F}', climate:'☃', input_boolean:'⚙'
};
function renderHATable(entities) {
const el = document.getElementById('ha-list');
if (!el) return;
let rows = '';
let totalShown = 0;
for (const [domain, items] of Object.entries(entities)) {
const icon = _domainIcon[domain] || '•';
const available = items.filter(e => e.state !== 'unavailable' && e.state !== 'unknown');
if (!available.length) continue;
available.forEach(e => {
totalShown++;
const isOn = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night'].includes(e.state);
const isScene = domain === 'scene';
const ctxKey = 'ha_' + e.entity_id.replace(/[^a-z0-9]/gi,'_');
_panelCtx[ctxKey] = {type:'ha', label:e.name,
entity_id:e.entity_id, name:e.name, state:e.state, domain:domain};
const stateLabel = isScene ? '—' : (isOn ? 'ON' : 'OFF');
const stateClass = isOn ? 'on' : 'off';
const eid = e.entity_id.replace(/'/g,"\\'");
const ctrl = isScene
? `<button class="ha-scene-btn" onclick="toggleHA('${eid}','${domain}','${e.state}')">▶ RUN</button>`
: `<label class="ha-toggle"><input type="checkbox"${isOn?' checked':''} onchange="toggleHA('${eid}','${domain}','${e.state}')"><span class="ha-slider"></span></label>`;
rows += `<tr class="ha-row">
<td class="ha-col-domain" title="${domain}">${icon}</td>
<td class="ha-col-name" title="${e.name}">${e.name}</td>
<td class="ha-col-state ${stateClass}">${stateLabel}</td>
<td class="ha-col-ctrl">${ctrl}</td>
</tr>`;
});
}
if (!totalShown) {
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem;margin-top:8px">No available entities.</div>';
return;
}
el.innerHTML = `<table class="ha-table"><thead class="ha-thead"><tr>
<th></th><th>DEVICE</th><th>STATE</th><th>CTRL</th>
</tr></thead><tbody>${rows}</tbody></table>`;
}
async function toggleHA(entityId, domain, currentState) {
let service;
const ON_STATES = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night','active'];
const wasOn = ON_STATES.includes(currentState);
if (domain === 'scene') {
service = 'turn_on';
} else if (domain === 'alarm_control_panel') {
service = currentState === 'disarmed' ? 'alarm_arm_away' : 'alarm_disarm';
} else {
service = wasOn ? 'turn_off' : 'turn_on';
}
try {
await api('ha/service', 'POST', {domain, service, entity_id: entityId});
// Optimistic update — flip state immediately so toggle doesn't snap back
if (_haEntities[domain]) {
const ent = _haEntities[domain].find(e => e.entity_id === entityId);
if (ent && domain !== 'scene') ent.state = wasOn ? 'off' : 'on';
}
renderHATable(_haEntities);
// Full sync after 4s — HA executes + agent pushes new state
setTimeout(loadHA, 4000);
} catch(e) {}
}
// ── PROACTIVE REMINDERS ──────────────────────────────────────────────────────
let _reminderShown = false;
async function checkPlannerReminder() {
if (_reminderShown || sessionStorage.getItem('reminderShown')) return;
_reminderShown = true;
sessionStorage.setItem('reminderShown', '1');
const d = await api('planner/today').catch(() => null);
if (!d) return;
const tasks = [...(d.tasks_overdue||[]), ...(d.tasks_today||[])];
const appts = d.appts_today || [];
const overdue = d.tasks_overdue?.length || 0;
if (!tasks.length && !appts.length) return;
const parts = [];
if (overdue) parts.push(overdue + ' overdue task' + (overdue > 1 ? 's' : ''));
if (tasks.length - overdue > 0) parts.push((tasks.length - overdue) + ' task' + (tasks.length - overdue > 1 ? 's' : '') + ' due today');
if (appts.length) {
const nextAppt = appts[0];
const t = nextAppt.start_at ? new Date(nextAppt.start_at).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}) : '';
parts.push((t ? 'appointment at ' + t : appts.length + ' appointment' + (appts.length > 1 ? 's' : '') + ' today'));
}
const msg = 'Heads up, ' + (sessionUser||'Sir') + '. You have ' + parts.join(' and ') + '.';
addMessage('jarvis', msg);
if (typeof speak === 'function' && isVoiceActive) speak(msg);
}
// Check for upcoming appointments (fires every 5 min after load)
let _apptAlerted = new Set();
async function checkUpcomingAppts() {
const d = await api('planner/today').catch(() => null);
if (!d) return;
const now = Date.now();
for (const a of (d.appts_today||[])) {
if (!a.start_at || _apptAlerted.has(a.id)) continue;
const start = new Date(a.start_at).getTime();
const minsUntil = (start - now) / 60000;
if (minsUntil > 0 && minsUntil <= 15) {
_apptAlerted.add(a.id);
const msg = 'Reminder: ' + a.title + ' starts in ' + Math.round(minsUntil) + ' minutes' + (a.location ? ' at ' + a.location : '') + '.';
addMessage('jarvis', msg);
if (typeof speak === 'function' && isVoiceActive) speak(msg);
}
}
}
// ── PLANNER SUMMARY (top bar badge only) ─────────────────────────────────
async function loadPlannerSummary() {
const d = await api('planner/today');
const el = document.getElementById('tb-planner');
const tx = document.getElementById('tb-planner-text');
if (el && tx) {
const tasksDue = (d.tasks_today || []).length + (d.tasks_overdue || []).length;
const appts = (d.appts_today || []).length;
if (!tasksDue && !appts) { el.style.display = 'none'; }
else {
const parts = [];
if (tasksDue) parts.push(tasksDue + ' TASK' + (tasksDue > 1 ? 'S' : ''));
if (appts) parts.push(appts + ' APPT' + (appts > 1 ? 'S' : ''));
tx.textContent = parts.join(' · ');
el.style.display = '';
}
}
// Render planner mini panel
const pEl = document.getElementById('planner-tasks');
const badge = document.getElementById('planner-badge');
if (!pEl) return;
const priClass = {urgent:'pri-urgent',high:'pri-high',normal:'pri-normal',low:'pri-low'};
const fmtTime = s => { if(!s) return ''; const d=new Date(s); return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); };
const fmtDate = s => { if(!s) return ''; const d=new Date(s+'T00:00:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); };
const tasks = [...(d.tasks_overdue||[]).map(t=>({...t,_overdue:true})), ...(d.tasks_today||[])];
const appts = d.appts_today || [];
let html = '';
if (!tasks.length && !appts.length) {
html = '<div style="color:var(--text-dim);font-size:0.6rem;padding:4px 0">No tasks or appointments today.</div>';
} else {
if (appts.length) {
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin-bottom:3px">TODAY\'S SCHEDULE</div>';
html += appts.map(a => `<div class="appt-row"><span class="appt-time">${fmtTime(a.start_at)}</span><span>${a.title}</span>${a.location?'<span style="color:var(--text-dim);font-size:0.55rem"> · '+a.location+'</span>':''}</div>`).join('');
}
if (tasks.length) {
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin:5px 0 3px">TASKS DUE</div>';
html += tasks.map(t => `<div class="task-item"><span class="pri-dot ${priClass[t.priority]||'pri-normal'}"></span><span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${t.title}</span>${t._overdue?'<span style="color:#f66;font-size:0.55rem">OVERDUE</span>':''}</div>`).join('');
}
if (d.pending_count > tasks.length) {
html += `<div style="color:var(--text-dim);font-size:0.55rem;padding:3px 0">${d.pending_count} pending total</div>`;
}
}
pEl.innerHTML = html;
const total = tasks.length + appts.length;
if (badge) badge.textContent = total ? total + ' TODAY' : '';
}
// ── ALERTS ────────────────────────────────────────────────────────────
async function loadAlerts() {
const data = await api('alerts');
const el = document.getElementById('alerts-list');
const tb = document.getElementById('tb-alerts');
const alerts = data.alerts || [];
if (!alerts.length) {
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--green);text-align:center;margin-top:20px">✓ NO ACTIVE ALERTS</div>';
tb.textContent='NO ALERTS'; tb.className='text-green';
setAlertState(false);
setSystemHealth('ok');
return;
}
tb.textContent=alerts.length+' ALERT'+(alerts.length>1?'S':'');
tb.className='text-red';
setAlertState(true);
const hasCritical = alerts.some(a => a.severity === 'critical');
setSystemHealth(hasCritical ? 'critical' : 'warning');
el.innerHTML = alerts.map(a => {
const ctxKey = 'alert_' + a.id;
_panelCtx[ctxKey] = {type:'alert', label:a.title,
id:a.id, title:a.title, message:a.message, severity:a.severity};
return `<div class="alert-item ${a.severity}" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this alert">
<div style="flex:1">
<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text)">${a.title}</div>
<div style="font-size:0.65rem;color:var(--text-dim)">${a.message}</div>
</div>
<button onclick="event.stopPropagation();resolveAlert(${a.id})" style="background:none;border:1px solid var(--dim);border-radius:3px;color:var(--text-dim);font-size:0.6rem;padding:2px 6px;cursor:pointer">✓</button>
</div>`;
}).join('');
}
async function resolveAlert(id) {
await api('alerts/resolve', 'POST', {id});
loadAlerts();
}
// ── PROACTIVE ALERT POLLING ───────────────────────────────────────────────────
let _knownAlertIds = null;
let _spokenAlertIds = new Set();
async function pollAlertsProactive() {
const data = await api('alerts').catch(() => null);
if (!data) return;
const alerts = (data.alerts || []);
if (_knownAlertIds === null) {
// First run: baseline existing alerts — do not speak them
_knownAlertIds = new Set(alerts.map(a => a.id));
return;
}
for (const a of alerts) {
if (_knownAlertIds.has(a.id) || _spokenAlertIds.has(a.id)) continue;
_knownAlertIds.add(a.id);
_spokenAlertIds.add(a.id);
if (a.severity === 'critical' || a.severity === 'warning') {
const prefix = a.severity === 'critical' ? '🚨' : '⚠';
addMessage('jarvis', `${prefix} ${a.title}: ${a.message}`);
const tts = (a.severity === 'critical' ? 'Critical alert. ' : 'Warning. ') + a.title + '. ' + a.message;
if (typeof speak === 'function' && isVoiceActive) speak(tts);
}
}
// Remove resolved alerts from known set so they can re-trigger if they come back
const liveIds = new Set(alerts.map(a => a.id));
for (const id of _knownAlertIds) {
if (!liveIds.has(id)) _knownAlertIds.delete(id);
}
}
// ── WEATHER ───────────────────────────────────────────────────────────
async function loadWeather() {
const d = await api('weather');
if (!d || !d.current) return;
const c = d.current;
document.getElementById('weather-temp').textContent = c.temp;
document.getElementById('weather-desc').textContent = (c.desc || '').toUpperCase();
document.getElementById('weather-feels').textContent = c.feels + '°F';
document.getElementById('weather-humidity').textContent = c.humidity + '%';
document.getElementById('weather-details').textContent =
'Wind ' + c.wind + ' mph · Cloud ' + c.cloud + '% · Vis ' + c.vis + ' mi';
const fc = d.forecast || [];
document.getElementById('weather-forecast').innerHTML = fc.slice(0, 4).map(day => `
<div class="forecast-card">
<div class="fc-day">${day.day}</div>
<div class="fc-icon" style="font-size:0.55rem;color:var(--cyan);padding:3px 0;line-height:1.3">${day.icon}</div>
<div class="fc-temps">${day.high}&deg;<span style="color:var(--text-dim)">${day.low}&deg;</span></div>
<div class="fc-rain">${day.rain_pct > 0 ? day.rain_pct+'%' : ''}</div>
</div>`).join('');
}
// ── NEWS ──────────────────────────────────────────────────────────────
function getNewsHidden() {
try { return JSON.parse(localStorage.getItem('news_hidden_cats') || '[]'); } catch(e) { return []; }
}
function setNewsHidden(arr) { localStorage.setItem('news_hidden_cats', JSON.stringify(arr)); }
function toggleNewsFilter() {
const panel = document.getElementById('news-filter-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
let _newsCats = [];
async function loadNews() {
const d = await api('news');
const el = document.getElementById('news-list');
if (!d || !d.categories || Object.keys(d.categories).length === 0) {
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">News loading...</div>';
return;
}
const catLabels = { headlines: '📰 TOP HEADLINES', technology: '💻 TECHNOLOGY', pinned: '📌 JARVIS PINNED' };
const hidden = getNewsHidden();
_newsCats = Object.keys(d.categories);
// Build filter checkboxes
const cbContainer = document.getElementById('news-filter-checkboxes');
if (cbContainer) {
cbContainer.innerHTML = _newsCats.map(cat => `
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.6rem;color:var(--text-primary)">
<input type="checkbox" ${hidden.includes(cat) ? '' : 'checked'} onchange="toggleNewsCat('${cat}',this.checked)"
style="accent-color:var(--cyan)"/>
${catLabels[cat] || cat.toUpperCase()}
</label>`).join('');
}
let html = '';
for (const [cat, articles] of Object.entries(d.categories)) {
if (!articles.length || hidden.includes(cat)) continue;
html += `<div class="news-cat-header">${catLabels[cat] || cat.toUpperCase()}</div>`;
for (const a of articles.slice(0, 5)) {
const ctxKey = 'news_' + (cat + '_' + a.title).replace(/[^a-z0-9]/gi,'').slice(0,30);
_panelCtx[ctxKey] = {type:'news', label:a.title,
title:a.title, source:a.source, pub:a.pub||'', category:cat};
html += `<div class="news-item" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this story">
<div class="news-source">${a.source}</div>
<div class="news-title">${a.title.length > 90 ? a.title.slice(0,87)+'…' : a.title}</div>
${a.pub ? '<div class="news-time">' + a.pub + '</div>' : ''}
</div>`;
}
}
if (!html) html = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">All categories hidden — use ⚙ to show sources</div>';
const ageMin = d.cache_age_s > 0 ? Math.round(d.cache_age_s/60) : 0;
html += `<div style="font-size:0.5rem;color:var(--text-dim);text-align:right;margin-top:8px">Updated ${ageMin}m ago</div>`;
el.innerHTML = html;
}
function toggleNewsCat(cat, show) {
const hidden = getNewsHidden();
if (show) {
const idx = hidden.indexOf(cat);
if (idx > -1) hidden.splice(idx, 1);
} else {
if (!hidden.includes(cat)) hidden.push(cat);
}
setNewsHidden(hidden);
loadNews();
}
// ── TABS ──────────────────────────────────────────────────────────────
function switchTab(name) {
if (name === 'sites') { openSitesModal(); return; }
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
event.target.classList.add('active');
const pane = document.getElementById('tab-'+name);
if (pane) pane.classList.add('active');
if (name === 'news') loadNews();
if (name === 'agents') loadAgents();
if (name === 'intel') loadIntel();
if (name === 'comms') { loadComms(); loadCommsOutbox(); }
if (name === 'guardian') loadGuardian();
if (name === 'missions') loadMissionsHud();
if (name === 'directives') loadDirectivesHud();
if (name === 'clearance') loadClearanceHud();
if (name === 'alerts') loadAlerts();
}
// ── CHAT ──────────────────────────────────────────────────────────────
function sourceBadge(source) {
if (!source) return '';
let cls, label;
if (/^intent:|^planner:|^kb:/.test(source)) { cls = 'kb'; label = 'KB'; }
else if (/^groq:/.test(source)) { cls = 'groq'; label = 'GROQ'; }
else if (source === 'claude' || /^claude/.test(source)) { cls = 'claude'; label = 'CLAUDE'; }
else if (/^ollama/.test(source)) { cls = 'ollama'; label = 'LOCAL AI'; }
else return '';
const s = document.createElement('div');
s.style.cssText = 'margin-top:4px;text-align:right';
s.innerHTML = `<span class="tier-badge ${cls}">${label}</span>`;
return s;
}
function addMessage(role, text, source=null) {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg ' + role;
log.appendChild(div);
if (role === 'jarvis' && text && text.length > 0) {
// Adaptive speed: fast for short, slower for long (feels intentional either way)
const msPerChar = Math.max(9, Math.min(25, 1600 / text.length));
const cursor = document.createElement('span');
cursor.className = 'type-cursor';
div.appendChild(cursor);
let i = 0;
const type = () => {
if (i < text.length) {
cursor.insertAdjacentText('beforebegin', text[i++]);
log.scrollTop = log.scrollHeight;
setTimeout(type, msPerChar + (text[i-1] === '.' || text[i-1] === ',' ? msPerChar * 4 : 0));
} else {
cursor.remove();
const badge = sourceBadge(source);
if (badge) div.appendChild(badge);
}
};
setTimeout(type, 0);
} else {
div.textContent = text;
}
log.scrollTop = log.scrollHeight;
return div;
}
function showThinking() {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg jarvis';
div.innerHTML = '<div class="thinking"><div class="thinking-dot"></div><div class="thinking-dot"></div><div class="thinking-dot"></div></div>';
div.id = 'thinking-bubble';
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
// ── PANEL CONTEXT SELECTION ───────────────────────────────────────────
function selectContext(key) {
const ctx = _panelCtx[key];
if (!ctx) return;
// Clear previous active highlight
document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active'));
selectedContext = ctx;
// Highlight clicked element
const el = document.querySelector('[data-ctx-key="' + key + '"]');
if (el) el.classList.add('ctx-active');
// Show chip
const chip = document.getElementById('contextChip');
const typeLabels = {vm:'VM', network:'DEVICE', alert:'ALERT', news:'NEWS', ha:'HOME'};
document.getElementById('contextType').textContent = typeLabels[ctx.type] || ctx.type.toUpperCase();
document.getElementById('contextLabel').textContent = ctx.label;
chip.classList.add('visible');
// Focus input for immediate question
document.getElementById('textInput').focus();
}
function clearContext() {
selectedContext = null;
document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active'));
const chip = document.getElementById('contextChip');
chip.classList.remove('visible');
}
async function sendMessage() {
const input = document.getElementById('textInput');
const text = input.value.trim();
if (!text) return;
// Local commands — no API round-trip
var t2 = text.toLowerCase();
// Sleep command
if (SLEEP_CMDS.test(t2)) {
input.value = '';
addMessage('user', text);
enterSleepMode();
return;
}
if (NM_OPEN_RE.test(t2)) {
input.value=''; addMessage('user',text);
addMessage('jarvis','Launching network topology display.');
speak('Launching network topology display.');
openNetMap(); return;
}
if (NM_CLOSE_RE.test(t2)) {
input.value=''; addMessage('user',text);
var isOpen=document.getElementById('netMapOverlay')&&document.getElementById('netMapOverlay').classList.contains('nm-open');
if(isOpen){closeNetMap();addMessage('jarvis','Network map closed.');speak('Network map closed.');}
else addMessage('jarvis','Network map is not currently active.');
return;
}
input.value = '';
addMessage('user', text);
showThinking();
try {
const payload = {message:text, session_id:sessionId};
if (selectedContext) {
payload.context = selectedContext;
clearContext();
}
const data = await api('chat', 'POST', payload);
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
if (data.reply) {
addMessage('jarvis', data.reply, data.source || null);
speak(data.reply);
}
if (data.open_network_map) { openNetMap(); }
if (data.ui_action === 'focus_mode') { if (panelsVisible) togglePanels(true); }
if (data.ui_action === 'show_panels') { if (!panelsVisible) togglePanels(true); }
if (data.arc_job) { onArcJobStarted(data.arc_job, data.source || ''); }
} catch(e) {
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
addMessage('jarvis', 'I encountered a communication error, Sir. Please check my API connection.');
}
}
// ── VOICE RECOGNITION ─────────────────────────────────────────────────
function initVoice() {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
if (window.isSecureContext === false) {
console.warn('Speech Recognition blocked: not a secure context');
} else {
console.warn('Speech Recognition not supported in this browser');
}
return;
}
recognition = new SR();
recognition.continuous = false; // restart-per-utterance — most reliable in Chrome
recognition.interimResults = false;
recognition.lang = 'en-US';
recognition.maxAlternatives = 1;
recognition.onresult = (e) => {
if (isSpeaking) return;
const transcript = (e.results[0][0].transcript || '').trim();
if (!transcript) return;
const lc = transcript.toLowerCase();
// Sleeping: ONLY respond to master wake phrases
if (isAsleep) {
if (WAKE_PHRASES.some(p => lc.includes(p))) wakeFromSleep();
return;
}
if (!voiceMode) {
if (WAKE_PHRASES.some(p => lc.includes(p))) enterVoiceMode();
} else if (!voiceMuted) {
voiceLastCmd = Date.now();
voiceActive = Date.now();
const cmd = lc.startsWith(CMD_PREFIX)
? transcript.substring(CMD_PREFIX.length).trim()
: transcript;
if (cmd) {
// Check for sleep command by voice
if (SLEEP_CMDS.test(cmd)) {
addMessage('user', transcript);
enterSleepMode();
return;
}
_showTranscript(cmd);
document.getElementById('textInput').value = cmd;
sendMessage();
}
}
};
recognition.onend = () => {
// Restart immediately unless TTS is playing or mic is off
if (isListening && !isSpeaking) {
_scheduleRecStart(100);
}
};
recognition.onerror = (e) => {
if (e.error === 'not-allowed') {
isListening = false;
updateMicBtn();
addMessage('system', 'Microphone access denied. Please allow microphone permission in your browser, then reload.');
} else if (e.error === 'audio-capture') {
isListening = false;
updateMicBtn();
addMessage('system', 'No microphone detected. Please connect a microphone and try again.');
}
// no-speech / aborted / network: onend will fire and restart
};
}
function _showTranscript(text) {
const el = document.getElementById('textInput');
if (el) { el.placeholder = '▶ ' + text.substring(0, 60); setTimeout(() => { el.placeholder = 'Enter command or speak to JARVIS...'; }, 3000); }
}
function enterVoiceMode(source) {
voiceMode = true;
voiceMuted = false;
voiceLastCmd = Date.now();
voiceActive = Date.now();
updateMicBtn();
// Focus/notify when woken from minimized or sleep
_focusWindow();
if (source === 'wake') {
const g = 'All systems back online, ' + (sessionUser || 'Sir') + '. Good to have you back.';
addMessage('jarvis', g);
speak(g);
} else {
speak('Yes, ' + (sessionUser || 'Sir') + '?');
}
}
function exitVoiceMode() {
voiceMode = false;
voiceMuted = false;
updateMicBtn();
}
function updateMicBtn() {
const btn = document.getElementById('micBtn');
const icon = document.getElementById('micIcon');
const wave = document.getElementById('waveform');
if (!btn) return;
if (!voiceMode) {
btn.classList.remove('listening', 'muted');
btn.title = 'Click to activate, or say: wake up JARVIS / daddy\'s home';
icon.textContent = '🎤';
wave.classList.remove('active');
} else if (voiceMuted) {
btn.classList.remove('listening');
btn.classList.add('muted');
btn.title = 'Muted — click to unmute';
icon.textContent = '🔇';
wave.classList.remove('active');
} else {
btn.classList.add('listening');
btn.classList.remove('muted');
btn.title = 'Listening — click to mute';
icon.textContent = '🟢';
wave.classList.add('active');
}
}
function toggleVoice() {
if (!voiceMode) {
enterVoiceMode();
} else {
voiceMuted = !voiceMuted;
if (!voiceMuted) voiceLastCmd = Date.now();
updateMicBtn();
}
}
let _recTimer = null;
function _scheduleRecStart(ms = 100) {
clearTimeout(_recTimer);
_recTimer = setTimeout(() => {
if (isListening && !isSpeaking) {
try { recognition.start(); } catch(_) {}
}
}, ms);
}
function startListening() {
if (!recognition) {
if (!window.isSecureContext) {
addMessage('system', 'Voice recognition requires a trusted HTTPS connection. Please access JARVIS via https://jarvis.orbishosting.com for voice support.');
} else {
addMessage('system', 'Voice recognition requires Chrome or Edge browser.');
}
return;
}
isListening = true;
_scheduleRecStart(50);
}
function stopListening() {
isListening = false;
voiceMode = false;
voiceMuted = false;
updateMicBtn();
clearTimeout(_recTimer);
try { recognition.abort(); } catch(_) {}
}
// ── SPEECH SYNTHESIS ──────────────────────────────────────────────────
function loadVoices() {
const set = () => {
const voices = synth.getVoices();
// Priority: Australian male → Australian → British male → British → any English
selectedVoice =
voices.find(v => v.name === 'Nathan') // macOS Australian male
|| voices.find(v => v.name === 'Google Australian English') // Chrome Australian
|| voices.find(v => v.name === 'Karen') // macOS Australian female
|| voices.find(v => v.lang === 'en-AU') // any Australian
|| voices.find(v => v.name === 'Daniel') // macOS British male
|| voices.find(v => v.name === 'Google UK English Male') // Chrome British male
|| voices.find(v => v.lang === 'en-GB') // any British
|| voices.find(v => v.lang.startsWith('en')) // any English
|| voices[0]
|| null;
};
set();
synth.onvoiceschanged = set;
}
let _ttsAudio = null;
async function speak(text) {
if (!text) return;
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
synth?.cancel();
isSpeaking = true;
// Pause recognition while JARVIS speaks to avoid mic feedback
try { recognition?.abort(); } catch(_) {}
const reactor = document.getElementById('arcReactor');
reactor?.classList.add('speaking');
const _resumeMic = () => {
isSpeaking = false;
reactor?.classList.remove('speaking');
// onend will fire from the abort we did before TTS, and restart cleanly
if (isListening) _scheduleRecStart(900);
};
try {
const res = await fetch('/api/tts', {
method: 'POST',
headers: {'Content-Type':'application/json','X-Session-Token': sessionToken},
body: JSON.stringify({text: text.substring(0, 400)}),
});
if (!res.ok) throw new Error('tts');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
_ttsAudio = new Audio(url);
_ttsAudio.onended = () => { URL.revokeObjectURL(url); _ttsAudio = null; _resumeMic(); };
_ttsAudio.onerror = () => { _ttsAudio = null; _resumeMic(); };
await _ttsAudio.play();
} catch(e) {
_resumeMic();
_speakFallback(text);
}
}
function _speakFallback(text) {
if (!synth || !text) return;
synth.cancel();
isSpeaking = true;
const utter = new SpeechSynthesisUtterance(text);
if (selectedVoice) utter.voice = selectedVoice;
utter.rate = 0.92; utter.pitch = 0.85; utter.volume = 1;
const reactor = document.getElementById('arcReactor');
utter.onstart = () => reactor?.classList.add('speaking');
utter.onend = () => {
reactor?.classList.remove('speaking');
isSpeaking = false;
if (isListening) _scheduleRecStart(900);
};
synth.speak(utter);
}
// ── AGENT DETECTION & BROWSER INSTALL ─────────────────────────────────
let _agentOnline = false;
let _myAgent = null;
function detectOS() {
const ua = navigator.userAgent;
const p = (navigator.platform || '').toLowerCase();
// Tablets — check before desktop OS (iPads spoof MacIntel)
if (/iPad|Android/.test(ua) || (p.includes('mac') && navigator.maxTouchPoints > 1)) return 'tablet';
if (/iPhone/.test(ua)) return 'tablet';
if (p.includes('win') || ua.includes('Windows')) return 'windows';
if (p.includes('mac') || ua.includes('Macintosh')) return 'mac';
if (p.includes('linux') || ua.includes('Linux')) return 'linux';
return 'unknown';
}
async function checkAgentStatus() {
const dot = document.getElementById('bb-agent-dot');
const sta = document.getElementById('bb-agent-status');
const btn = document.getElementById('agentBtn');
if (!dot || !sta) return;
try {
const data = await api('agent/list');
const agents = data.agents || [];
const online = agents.filter(a => a.status === 'online');
dot.className = 'bb-dot ' + (online.length > 0 ? 'online' : 'offline');
sta.textContent = online.length > 0 ? online.length + ' ONLINE' : 'NONE';
const cnt = document.getElementById('net-agent-count');
if (cnt) cnt.textContent = online.length + ' AGENT' + (online.length !== 1 ? 'S' : '') + ' ONLINE';
const myIp = data.my_ip || '';
// Match by exact IP first, then by same /24 subnet (handles NAT behind same router)
const mySubnet = myIp.split('.').slice(0,3).join('.');
_myAgent = online.find(a => a.ip_address === myIp)
|| online.find(a => a.ip_address && a.ip_address.startsWith(mySubnet + '.'));
_agentOnline = !!_myAgent;
if (btn) {
const isTablet = detectOS() === 'tablet';
if (isTablet) {
btn.title = 'JARVIS Agent — not available for tablets';
btn.style.opacity = '0.5';
} else if (_agentOnline) {
btn.classList.add('agent-online');
btn.title = 'Agent active: ' + _myAgent.hostname;
} else {
btn.classList.remove('agent-online');
btn.title = 'Click to install JARVIS Agent on this machine';
}
}
// Also refresh the AGENTS tab if it's visible
if (document.getElementById('tab-agents').classList.contains('active')) {
renderAgentsTab(agents, data.metrics || {});
}
} catch(e) {
if (dot) dot.className = 'bb-dot offline';
if (sta) sta.textContent = 'ERROR';
}
}
// ── ARC REACTOR STATUS ────────────────────────────────────────────────
let _arcOnline = false;
let _arcJobs = { queued: 0, running: 0, done: 0, failed: 0 };
async function checkArcStatus() {
const dot = document.getElementById('bb-arc-dot');
const sta = document.getElementById('bb-arc-status');
if (!dot || !sta) return;
try {
const d = await api('arc?action=status');
if (d && d.online) {
_arcOnline = true;
dot.className = 'bb-dot online';
const active = (d.active_jobs || 0) + (d.queued_jobs || 0);
sta.textContent = active > 0 ? active + ' JOB' + (active !== 1 ? 'S' : '') : 'ONLINE';
_arcJobs = { queued: d.queued_jobs||0, running: d.running_jobs||0,
done: d.jobs_done||0, failed: d.jobs_failed||0 };
} else {
_arcOnline = false;
dot.className = 'bb-dot offline';
sta.textContent = 'OFFLINE';
}
} catch(e) {
_arcOnline = false;
dot.className = 'bb-dot offline';
sta.textContent = 'OFFLINE';
}
}
// Submit a job to the Arc Reactor and return job_id
async function arcSubmitJob(type, payload, priority) {
payload = payload || {};
priority = priority || 5;
const d = await api('arc', { action: 'job_create', type: type, payload: payload, priority: priority });
return d.job_id || null;
}
// Poll a job until done or failed (max 120s), calling onProgress each tick
async function arcWaitJob(jobId, onProgress) {
var start = Date.now();
while (Date.now() - start < 120000) {
const d = await api('arc?action=job_get&id=' + jobId);
if (onProgress) onProgress(d);
if (d.status === 'done') return d;
if (d.status === 'failed') throw new Error(d.error || 'Job failed');
await new Promise(function(r){ setTimeout(r, 1500); });
}
throw new Error('Arc Reactor job timed out');
}
// ── INTEL PROTOCOL — HUD panel ────────────────────────────────────────
let _intelPollTimer = null;
let _intelActiveJobs = new Set();
let _intelLastLoad = 0;
async function loadIntel() {
const el = document.getElementById('intel-list');
if (!el) return;
_intelLastLoad = Date.now();
try {
// Fetch recent research + tool_loop jobs
const [resJobs, toolJobs] = await Promise.all([
api('arc?action=jobs&status=&limit=20').catch(() => []),
Promise.resolve([]),
]);
const jobs = Array.isArray(resJobs) ? resJobs.filter(j => ['research','tool_loop','llm'].includes(j.job_type)) : [];
if (!jobs.length) {
el.innerHTML = '<div class="intel-empty">◈ NO INTEL JOBS<br><span style="opacity:0.5">Say "research [topic]" to activate</span></div>';
stopIntelPolling();
return;
}
// Check for active jobs
const hasActive = jobs.some(j => j.status === 'queued' || j.status === 'running');
if (hasActive) startIntelPolling(); else stopIntelPolling();
let html = '<button class="intel-new-btn" onclick="intelPrompt()">⚡ NEW RESEARCH</button>';
for (const job of jobs) {
const isOpen = _intelActiveJobs.has(job.id) || job.status === 'running';
const statusClass = job.status === 'done' ? 'done' : job.status === 'failed' ? 'failed' : 'running';
const statusLabel = job.status === 'queued' ? 'QUEUED' : job.status === 'running' ? '● ACTIVE' : job.status.toUpperCase();
const typeLabel = job.job_type === 'research' ? '◈ INTEL' : job.job_type === 'tool_loop' ? '⚡ IRON' : '◈ LLM';
// Get result details if done
let bodyHtml = '';
if (job.status === 'done' && job.result) {
let r = job.result;
if (typeof r === 'string') { try { r = JSON.parse(r); } catch(e) {} }
if (typeof r === 'object') {
const synthesis = (r.synthesis || r.result || r.response || '').trim();
const sources = r.sources || [];
const query = r.query || r.task || '';
const provider = r.provider || '';
bodyHtml = `<div class="intel-card-body">`;
if (provider) bodyHtml += `<div style="font-size:0.55rem;color:var(--text-dim);margin:6px 0 2px;font-family:var(--font-mono)">PROVIDER: ${provider.toUpperCase()} · SOURCES: ${r.source_count||sources.length||'—'}</div>`;
if (synthesis) bodyHtml += `<div class="synthesis">${escHtml(synthesis.substring(0, 1500))}${synthesis.length>1500?'\n\n[...truncated — view in admin]':''}</div>`;
if (sources.length) {
bodyHtml += '<div class="intel-sources"><div style="font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin-bottom:4px;font-family:var(--font-display)">SOURCES</div>';
sources.slice(0,5).forEach((s,i) => {
const title = escHtml((s.title||s.url||'').substring(0,60));
const url = escHtml(s.url||'');
bodyHtml += `<div class="intel-source">${i+1}. <a href="${url}" target="_blank" rel="noopener">${title||url}</a></div>`;
});
bodyHtml += '</div>';
}
bodyHtml += '</div>';
}
} else if (job.status === 'running' || job.status === 'queued') {
const typeMsg = job.job_type === 'research' ? 'Searching sources and extracting content...' : 'Executing tool loop...';
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--text-dim);padding:8px 0;font-family:var(--font-mono)">${typeMsg}</div></div>`;
} else if (job.status === 'failed' && job.error) {
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--red);padding:8px 0;font-family:var(--font-mono)">${escHtml(job.error.substring(0,200))}</div></div>`;
}
const queryText = job.created_by ? job.created_by.replace('chat:', '').replace(/session.*/, '') : '';
const ts = job.created_at ? new Date(job.created_at).toLocaleTimeString() : '';
html += `<div class="intel-card${(isOpen && bodyHtml) ? ' open':''}" id="intel-card-${job.id}">
<div class="intel-card-head" onclick="toggleIntelCard(${job.id})">
<span style="font-size:0.55rem;color:var(--text-dim);font-family:var(--font-mono);flex-shrink:0">${typeLabel}</span>
<span class="intel-card-query">#${job.id} ${escHtml((job.created_by||'').replace('chat:','').substring(0,40))}</span>
<span style="font-size:0.55rem;color:var(--text-dim);flex-shrink:0;font-family:var(--font-mono)">${ts}</span>
<span class="intel-card-status ${statusClass}">${statusLabel}</span>
</div>
${bodyHtml}
</div>`;
}
el.innerHTML = html;
} catch(e) {
if (el) el.innerHTML = '<div class="intel-empty">INTEL OFFLINE</div>';
}
}
function toggleIntelCard(id) {
const card = document.getElementById('intel-card-' + id);
if (!card) return;
if (_intelActiveJobs.has(id)) _intelActiveJobs.delete(id);
else _intelActiveJobs.add(id);
card.classList.toggle('open');
}
function startIntelPolling() {
if (_intelPollTimer) return;
_intelPollTimer = setInterval(() => {
if (document.getElementById('tab-intel')?.classList.contains('active')) {
loadIntel();
}
}, 4000);
}
function stopIntelPolling() {
if (_intelPollTimer) { clearInterval(_intelPollTimer); _intelPollTimer = null; }
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function intelPrompt() {
const input = document.getElementById('textInput');
if (input) { input.value = 'research '; input.focus(); }
}
// Called when arc_job is returned from chat response
function onArcJobStarted(jobId, jobType) {
const commsTypes = ['arc:gmail_triage', 'arc:send_email', 'arc:compose_email', 'arc:schedule_event', 'arc:meeting_prep'];
if (commsTypes.includes(jobType)) {
const commsBtn = document.getElementById('tab-btn-comms');
if (commsBtn) commsBtn.click();
startCommsPolling();
} else {
_intelActiveJobs.add(jobId);
const intelTab = document.querySelector('[onclick*="switchTab(\'intel\')"]');
if (intelTab) intelTab.click();
startIntelPolling();
}
}
// ── COMMS PROTOCOL — email triage HUD ────────────────────────────────────
let _commsPollTimer = null;
let _commsFilter = 'priority';
let _commsOpenCards = new Set();
async function loadComms() {
const el = document.getElementById('comms-list');
if (!el) return;
try {
const res = await api('arc?action=triage&limit=50&filter=' + _commsFilter);
const items = Array.isArray(res) ? res : (res.items || []);
if (!items.length) {
el.innerHTML = '<button class="comms-triage-btn" onclick="commsTriageNow()">◈ TRIAGE INBOX NOW</button>'
+ '<div class="comms-empty">◈ NO TRIAGE DATA<br><span style="opacity:0.5">Say "check my email" to activate</span></div>';
stopCommsPolling();
return;
}
const catOrder = {urgent:0, action:1, reply:2, meeting:3, info:4, promo:5, spam:6};
const catIcons = {urgent:'🔴', action:'⚡', reply:'◈', meeting:'📅', info:'', promo:'📢', spam:'🗑'};
let html = '<div style="display:flex;gap:5px;margin-bottom:5px">';
html += '<button class="comms-triage-btn" style="flex:3;margin-bottom:0" onclick="commsTriageNow()">◈ TRIAGE INBOX</button>';
html += '<button class="comms-compose-btn" style="flex:2;margin-bottom:0" onclick="commsShowCompose()">+ COMPOSE</button>';
html += '</div>';
html += '<div class="comms-header-bar">';
for (const [f, label] of [['priority','PRIORITY'],['urgent','URGENT'],['action','ACTION'],['all','ALL']]) {
html += `<div class="comms-filter-btn${_commsFilter===f?' active':''}" onclick="commsSetFilter('${f}')">${label}</div>`;
}
html += '</div>';
for (const item of items) {
const cat = item.category || 'info';
const icon = catIcons[cat] || '◈';
const prio = item.priority || 0;
const isOpen = _commsOpenCards.has(item.id);
const hasReply = item.draft_reply && item.draft_reply.trim().length > 5;
html += `<div class="comms-card${isOpen?' open':''}" id="comms-card-${item.id}">
<div class="comms-card-head" onclick="toggleCommsCard(${item.id})">
<span class="comms-card-cat ${cat}">${icon} ${cat.toUpperCase()}</span>
<span class="comms-card-subject">${escHtml((item.subject||'(no subject)').substring(0,60))}</span>
<span class="comms-prio">${prio}/10</span>
</div>
<div class="comms-card-body">
<div class="comms-card-from">FROM: ${escHtml((item.from_name||item.from_email||'').substring(0,50))}</div>
<div class="comms-card-summary">${escHtml(item.summary||'')}</div>
${hasReply ? `<div class="comms-draft-label">DRAFT REPLY</div><div class="comms-draft" id="comms-draft-${item.id}">${escHtml(item.draft_reply)}</div>` : ''}
<div style="display:flex;gap:5px;margin-top:8px">
${hasReply ? `<button class="comms-send-btn" id="comms-send-${item.id}" onclick="commsSendReply(${item.id})">◈ SEND REPLY</button>` : ''}
${hasReply ? `<button onclick="commsCopyReply(${item.id})" style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">COPY</button>` : ''}
<button onclick="commsDismiss(${item.id})" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">DISMISS</button>
</div>
</div>
</div>`;
}
el.innerHTML = html;
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">COMMS OFFLINE</div>';
}
}
function toggleCommsCard(id) {
const card = document.getElementById('comms-card-' + id);
if (!card) return;
if (_commsOpenCards.has(id)) _commsOpenCards.delete(id);
else _commsOpenCards.add(id);
card.classList.toggle('open');
}
function commsSetFilter(f) {
_commsFilter = f;
loadComms();
}
async function commsDismiss(id) {
await api('arc?action=triage_action&id=' + id, 'POST', {action: 'dismissed'}).catch(() => {});
loadComms();
}
async function commsCopyReply(id) {
const draft = document.querySelector(`#comms-draft-${id}`);
if (draft) {
navigator.clipboard.writeText(draft.innerText).catch(() => {});
const btn = document.querySelector(`#comms-card-${id} [onclick*="commsCopyReply"]`);
if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY', 1500); }
}
}
async function commsSendReply(id) {
const btn = document.getElementById('comms-send-' + id);
const draft = document.getElementById('comms-draft-' + id);
if (!btn || !draft) return;
btn.disabled = true;
btn.textContent = '◈ SENDING…';
try {
const res = await api('arc', 'POST', {
action: 'job_create',
type: 'send_email',
payload: { triage_id: id, content: draft.innerText },
priority: 8,
});
if (res.job_id) {
btn.textContent = '◈ SENT ✓';
btn.style.color = '#00ff88';
setTimeout(() => loadComms(), 3000);
loadCommsOutbox();
} else {
btn.disabled = false;
btn.textContent = '◈ SEND REPLY';
alert('Send failed: ' + (res.error || 'unknown error'));
}
} catch(e) {
btn.disabled = false;
btn.textContent = '◈ SEND REPLY';
}
}
function commsShowCompose() {
const existing = document.getElementById('comms-compose-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.className = 'comms-compose-modal';
modal.id = 'comms-compose-modal';
modal.innerHTML = `
<div class="comms-compose-inner">
<div class="comms-compose-title">◈ COMPOSE MESSAGE</div>
<select id="cc-account" class="comms-compose-field" style="cursor:pointer">
<option value="gmail">Gmail</option>
<option value="icloud">iCloud</option>
</select>
<input id="cc-to" class="comms-compose-field" placeholder="To: email address" type="email">
<input id="cc-subject" class="comms-compose-field" placeholder="Subject">
<textarea id="cc-instructions" class="comms-compose-field" rows="4" placeholder="Describe what to say (AI will draft it)"></textarea>
<div id="cc-preview" style="display:none">
<div class="comms-draft-label">DRAFTED MESSAGE</div>
<div class="comms-draft" id="cc-preview-body" style="max-height:200px"></div>
</div>
<div class="comms-compose-actions">
<button class="comms-send-btn" style="flex:1" onclick="commsComposeDraft()">◈ DRAFT</button>
<button class="comms-send-btn" style="flex:1;display:none" id="cc-send-btn" onclick="commsComposeAndSend()">◈ SEND NOW</button>
<button onclick="document.getElementById('comms-compose-modal').remove()" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">CANCEL</button>
</div>
<div id="cc-status" style="font-family:var(--font-mono);font-size:0.55rem;color:var(--cyan);margin-top:6px;min-height:14px"></div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
}
let _ccDraftedBody = '';
async function commsComposeDraft() {
const to = document.getElementById('cc-to')?.value.trim();
const subject = document.getElementById('cc-subject')?.value.trim();
const instructions = document.getElementById('cc-instructions')?.value.trim();
const account = document.getElementById('cc-account')?.value;
const status = document.getElementById('cc-status');
if (!to || !instructions) { if (status) status.textContent = 'Please fill in To and message description.'; return; }
if (status) status.textContent = '◈ DRAFTING…';
try {
const res = await api('arc', 'POST', {
action: 'job_create', type: 'compose_email',
payload: { recipient: to, subject, instructions, account, auto_send: false },
priority: 7,
});
if (!res.job_id) throw new Error(res.error || 'No job');
// poll for result
let attempts = 0;
const poll = async () => {
const job = await api('arc?action=job_get&id=' + res.job_id);
if (job.status === 'done' && job.result?.drafted_body) {
_ccDraftedBody = job.result.drafted_body;
document.getElementById('cc-preview-body').textContent = _ccDraftedBody;
document.getElementById('cc-preview').style.display = 'block';
document.getElementById('cc-send-btn').style.display = '';
if (status) status.textContent = '◈ DRAFT READY — Review and send';
} else if (job.status === 'failed') {
if (status) status.textContent = '✗ Draft failed: ' + (job.error || 'unknown');
} else if (attempts++ < 20) {
setTimeout(poll, 1500);
} else {
if (status) status.textContent = '◈ Job still running — check INTEL tab';
}
};
setTimeout(poll, 1500);
} catch(e) {
if (status) status.textContent = '✗ Error: ' + e.message;
}
}
async function commsComposeAndSend() {
const to = document.getElementById('cc-to')?.value.trim();
const subject = document.getElementById('cc-subject')?.value.trim();
const account = document.getElementById('cc-account')?.value;
const status = document.getElementById('cc-status');
const btn = document.getElementById('cc-send-btn');
if (!to || !_ccDraftedBody) return;
if (btn) { btn.disabled = true; btn.textContent = '◈ SENDING…'; }
if (status) status.textContent = '◈ TRANSMITTING…';
try {
const res = await api('arc', 'POST', {
action: 'job_create', type: 'send_email',
payload: { to_email: to, subject, body: _ccDraftedBody, account },
priority: 9,
});
if (res.job_id) {
if (status) status.textContent = '◈ SENT ✓ (Job #' + res.job_id + ')';
setTimeout(() => {
document.getElementById('comms-compose-modal')?.remove();
loadCommsOutbox();
}, 1500);
} else {
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
if (status) status.textContent = '✗ Send failed: ' + (res.error || 'unknown');
}
} catch(e) {
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
if (status) status.textContent = '✗ Error: ' + e.message;
}
}
async function loadCommsOutbox() {
const el = document.getElementById('comms-outbox');
if (!el) return;
try {
const data = await api('arc?action=comms_sent&limit=20');
const sent = Array.isArray(data) ? data : (data.sent || []);
if (!sent.length) {
el.innerHTML = '<div class="comms-empty" style="padding:10px">No sent messages yet</div>';
return;
}
const statusColor = {sent:'#00ff88', failed:'#ff2244', queued:'#ffd700'};
let html = '';
for (const m of sent) {
const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
const sc = m.status || 'sent';
html += `<div class="comms-outbox-card">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="comms-outbox-to">TO: ${escHtml((m.to_email||'').substring(0,40))}</div>
<span class="comms-outbox-status ${sc}">${sc.toUpperCase()}</span>
</div>
<div class="comms-outbox-subj">${escHtml((m.subject||'(no subject)').substring(0,60))}</div>
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${ts} · ${m.account||'gmail'}</div>
</div>`;
}
el.innerHTML = html;
} catch(e) {
el.innerHTML = '<div class="comms-empty" style="padding:10px">OUTBOX OFFLINE</div>';
}
}
function commsTriageNow() {
const input = document.getElementById('textInput');
if (input) { input.value = 'check my email'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
}
function startCommsPolling() {
if (_commsPollTimer) return;
_commsPollTimer = setInterval(() => {
if (document.getElementById('tab-comms')?.classList.contains('active')) { loadComms(); loadCommsOutbox(); }
}, 8000);
}
function stopCommsPolling() {
if (_commsPollTimer) { clearInterval(_commsPollTimer); _commsPollTimer = null; }
}
// ── GUARDIAN MODE ─────────────────────────────────────────────────────────────
let _guardianPollTimer = null;
let _guardianChatTimer = null;
let _guardianLastChat = '';
let _guardianUnread = 0;
async function loadGuardian() {
const el = document.getElementById('guardian-list');
if (!el) return;
try {
const [statusData, eventsData] = await Promise.all([
api('arc?action=guardian_status').catch(() => ({})),
api('arc?action=guardian_events&limit=40').catch(() => []),
]);
const events = Array.isArray(eventsData) ? eventsData : [];
const status = statusData || {};
const counts = status.counts || {};
const unread = parseInt(counts.unread || 0);
const critU = parseInt(counts.critical_unread || 0);
_guardianUnread = unread;
_updateGuardianBadge(unread, critU);
const lastScan = status.last_scan
? new Date(status.last_scan + 'Z').toLocaleTimeString()
: '—';
let html = `<div style="padding:6px 10px 4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--cyan)">◈ GUARDIAN MODE</span>
<span style="font-family:var(--font-mono);font-size:0.5rem;color:${status.enabled?'var(--green)':'var(--red)'}">
${status.enabled ? '● ACTIVE' : '○ INACTIVE'}
</span>
<span style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim)">SCAN: ${lastScan}</span>
${unread ? `<button onclick="guardianAckAll()" class="guardian-ack-btn" style="margin-left:auto">ACK ALL (${unread})</button>` : '<span style="margin-left:auto"></span>'}
<button onclick="guardianSitrep()" style="background:rgba(0,212,255,0.08);border:1px solid var(--panel-border);color:var(--cyan);padding:3px 7px;border-radius:3px;font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SITREP</button>
</div>`;
if (!events.length) {
html += '<div style="text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px">◈ ALL CLEAR<br><span style="opacity:0.5">Guardian is watching...</span></div>';
} else {
for (const ev of events) {
const sev = ev.severity || 'info';
const acked = ev.acknowledged;
const ts = ev.created_at ? new Date(ev.created_at).toLocaleTimeString() : '';
const typeIco = {agent_offline:'⚠',agent_online:'✓',cpu_high:'⚡',
mem_high:'⚡',disk_high:'💾',service_down:'✗',
service_recovered:'✓',sitrep:'◈',anomaly:'◈'}[ev.event_type] || '◈';
html += `<div class="guardian-event ${sev}${acked?' acked':''}" id="gev-${ev.id}">
<span class="guardian-sev ${sev}">${sev.toUpperCase()}</span>
<div style="flex:1">
<div class="guardian-msg">${typeIco} ${escHtml(ev.message||'')}</div>
${ev.ai_analysis ? `<div class="guardian-ai">${escHtml(ev.ai_analysis.substring(0,200))}</div>` : ''}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0">
<span class="guardian-time">${ts}</span>
${!acked ? `<button class="guardian-ack-btn" onclick="guardianAck(${ev.id})">ACK</button>` : ''}
</div>
</div>`;
}
}
el.innerHTML = html;
startGuardianPolling();
} catch(e) {
if (el) el.innerHTML = '<div style="text-align:center;padding:20px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim)">GUARDIAN OFFLINE</div>';
}
}
function _updateGuardianBadge(unread, critical) {
const dot = document.getElementById('bb-guardian-dot');
const badge = document.getElementById('bb-guardian-badge');
const status = document.getElementById('bb-guardian-status');
if (!dot) return;
dot.className = 'bb-dot';
if (critical > 0) {
dot.classList.add('critical'); status.textContent = 'ALERT'; status.style.color = 'var(--red)';
} else if (unread > 0) {
dot.classList.add('warning'); status.textContent = 'WARNING'; status.style.color = '#f5a623';
} else {
dot.classList.add('all-clear'); status.textContent = 'CLEAR'; status.style.color = 'var(--green)';
}
if (unread > 0) {
badge.textContent = unread; badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
}
async function guardianAck(id) {
await api('arc?action=guardian_ack&id=' + id).catch(() => {});
const ev = document.getElementById('gev-' + id);
if (ev) ev.classList.add('acked');
_guardianUnread = Math.max(0, _guardianUnread - 1);
_updateGuardianBadge(_guardianUnread, 0);
}
async function guardianAckAll() {
await api('arc?action=guardian_ack').catch(() => {});
loadGuardian();
}
function guardianSitrep() {
const input = document.getElementById('textInput');
if (input) { input.value = 'sitrep'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
}
function switchGuardianTab() {
const btn = document.getElementById('tab-btn-guardian');
if (btn) btn.click();
}
function startGuardianPolling() {
if (_guardianPollTimer) return;
_guardianPollTimer = setInterval(() => {
if (document.getElementById('tab-guardian')?.classList.contains('active')) loadGuardian();
else _refreshGuardianBadge();
}, 30000);
}
async function _refreshGuardianBadge() {
const s = await api('arc?action=guardian_status').catch(() => null);
if (!s) return;
const counts = s.counts || {};
_updateGuardianBadge(parseInt(counts.unread||0), parseInt(counts.critical_unread||0));
}
// Proactive chat polling — checks for guardian-injected messages every 30s
let _proactiveChatLastId = 0;
async function _pollProactiveChat() {
try {
const rows = await api('arc?action=guardian_chat').catch(() => []);
if (!Array.isArray(rows)) return;
for (const row of rows) {
if (row.id > _proactiveChatLastId) {
_proactiveChatLastId = row.id;
// Don't spam on first load — only show messages from last 5 min
const age = Date.now() - new Date(row.created_at + 'Z').getTime();
if (age < 300000) {
addMessage('jarvis', row.message);
speak(row.message);
}
}
}
} catch(e) {}
}
// ── MISSION OPS HUD ───────────────────────────────────────────────────────────
let _missionsOpenCards = new Set();
async function loadMissionsHud() {
const el = document.getElementById('missions-hud');
if (!el) return;
try {
const missions = await api('arc?action=missions');
const list = Array.isArray(missions) ? missions : [];
let html = '<button class="mission-new-btn" onclick="window.open(\'/admin#missions\',\'_blank\')">◈ MANAGE MISSIONS IN ADMIN</button>';
if (!list.length) {
html += '<div class="comms-empty">◈ NO MISSIONS<br><span style="opacity:0.5">Create workflows in Admin → Mission Ops</span></div>';
el.innerHTML = html;
return;
}
const trigIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
for (const m of list) {
const isOpen = _missionsOpenCards.has(m.id);
const icon = trigIcons[m.trigger_type] || '◈';
const enabled = m.enabled;
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleTimeString() : 'never';
html += `<div class="mission-card${isOpen?' open':''}" id="mission-card-${m.id}">
<div class="mission-card-head" onclick="toggleMissionCard(${m.id})">
<span style="opacity:${enabled?1:0.35}">${icon}</span>
<span class="mission-card-name" style="opacity:${enabled?1:0.45}">${escHtml(m.name)}</span>
<span class="mission-card-trigger">${m.trigger_type.replace('_',' ').toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${m.run_count||0} runs</span>
</div>
<div class="mission-card-body">
${m.description ? `<div style="font-size:0.58rem;color:var(--text-dim);margin:6px 0">${escHtml(m.description)}</div>` : ''}
<div style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);margin:4px 0">Last run: ${lastRun} · ${m.run_count||0} total runs</div>
<div class="mission-run-bar">
<button class="mission-run-btn" id="mission-run-btn-${m.id}" onclick="hudRunMission(${m.id})"${!enabled?' disabled title="Mission disabled"':''}>▶ RUN NOW</button>
</div>
<div id="mission-run-result-${m.id}" style="font-family:var(--font-mono);font-size:0.52rem;margin-top:6px;min-height:12px"></div>
</div>
</div>`;
}
el.innerHTML = html;
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">MISSIONS OFFLINE</div>';
}
}
function toggleMissionCard(id) {
const card = document.getElementById('mission-card-' + id);
if (!card) return;
if (_missionsOpenCards.has(id)) _missionsOpenCards.delete(id);
else _missionsOpenCards.add(id);
card.classList.toggle('open');
}
async function hudRunMission(id) {
const btn = document.getElementById('mission-run-btn-' + id);
const res = document.getElementById('mission-run-result-' + id);
if (btn) { btn.disabled = true; btn.textContent = '◈ RUNNING…'; }
if (res) res.textContent = '';
try {
const data = await api('arc?action=mission_run&id=' + id, 'POST', {trigger_source: 'hud'});
const s = data.status || 'done';
const color = s === 'done' ? '#00ff88' : s === 'failed' ? '#ff2244' : '#ffd700';
if (res) res.style.color = color;
if (res) res.textContent = `◈ ${s.toUpperCase()} — Run #${data.run_id||'?'} · ${data.steps||0} steps completed`;
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
setTimeout(loadMissionsHud, 2000);
} catch(e) {
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
if (res) res.textContent = '✗ Run failed';
}
}
// ── DIRECTIVES HUD ────────────────────────────────────────────────────────────
let _dirOpenCards = new Set();
async function loadDirectivesHud() {
const el = document.getElementById('directives-hud');
if (!el) return;
try {
const d = await api('directives/list?status=active');
const list = (d.directives || []);
let html = '<button class="dir-admin-btn" onclick="window.open(\'/admin#directives\',\'_blank\')">◈ MANAGE IN ADMIN</button>';
if (!list.length) {
html += '<div class="comms-empty">◈ NO ACTIVE DIRECTIVES<br><span style="opacity:0.5">Create objectives in Admin → Directives</span></div>';
el.innerHTML = html;
return;
}
const catColors = {work:'var(--cyan)',personal:'#a78bfa',health:'#00ff88',finance:'#ffd700',home:'var(--panel-border)',other:'var(--text-dim)'};
for (const dir of list) {
const pct = Math.min(100, Math.round(dir.progress || 0));
const isOpen = _dirOpenCards.has(dir.id);
const color = catColors[dir.category] || 'var(--cyan)';
const fillColor = pct >= 80 ? '#00ff88' : pct >= 40 ? '#ffd700' : '#ff6644';
const daysLeft = dir.target_date
? Math.ceil((new Date(dir.target_date) - new Date()) / 86400000) : null;
const dueTxt = daysLeft !== null
? (daysLeft < 0 ? `OVERDUE ${Math.abs(daysLeft)}d` : `${daysLeft}d left`)
: '';
const dueColor = daysLeft !== null && daysLeft < 0 ? '#ff2244' : daysLeft < 14 ? '#ffd700' : 'var(--text-dim)';
html += `<div class="dir-card${isOpen?' open':''}" id="dir-card-${dir.id}">
<div class="dir-card-head" onclick="toggleDirCard(${dir.id})">
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${color};flex-shrink:0">${dir.category.toUpperCase()}</span>
<span class="dir-card-title" style="color:${color}">${escHtml(dir.title)}</span>
<span style="font-family:var(--font-mono);font-size:0.55rem;color:${fillColor};flex-shrink:0">${pct}%</span>
${dueTxt ? `<span style="font-family:var(--font-mono);font-size:0.48rem;color:${dueColor};flex-shrink:0">${dueTxt}</span>` : ''}
</div>
<div class="dir-card-body">
<div class="dir-progress-bar"><div class="dir-progress-fill" style="width:${pct}%;background:${fillColor}"></div></div>
<div style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim);margin-bottom:6px">${dir.kr_count||0} KEY RESULTS · ${dir.link_count||0} LINKED ITEMS</div>
<button onclick="hudDirectiveReview(${dir.id})" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.2);border-radius:3px;padding:3px 8px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ AI REVIEW</button>
</div>
</div>`;
}
el.innerHTML = html;
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">DIRECTIVES OFFLINE</div>';
}
}
function toggleDirCard(id) {
const card = document.getElementById('dir-card-' + id);
if (!card) return;
if (_dirOpenCards.has(id)) _dirOpenCards.delete(id);
else _dirOpenCards.add(id);
card.classList.toggle('open');
}
async function hudDirectiveReview(id) {
const res = await api('arc?action=job_create', 'POST', {
type: 'directive_review', payload: {directive_id: id, provider: 'claude'}, priority: 6,
});
if (res.job_id) {
addMessage('jarvis', `◈ DIRECTIVE REVIEW initiated (Job #${res.job_id}). Analyzing objectives and key results now. Results will appear here shortly.`);
speak(`Directive review underway. I'll brief you on your progress in a moment.`);
}
}
// ── MEMORY CORE — bottom bar count ────────────────────────────────────────────
async function updateMemoryCount() {
try {
const stats = await api('memory?action=stats');
const el = document.getElementById('bb-memory-count');
const dot = document.getElementById('bb-memory-dot');
if (el && stats) {
const total = stats.total || 0;
el.textContent = total + ' FACTS';
if (dot) dot.style.background = total > 0 ? 'var(--cyan)' : 'rgba(0,212,255,0.3)';
}
} catch(e) {}
}
// ── CLEARANCE PROTOCOL HUD ─────────────────────────────────────────────────────
const _clrOpenCards = new Set();
async function updateClearanceBanner() {
try {
const pending = await api('arc?action=clearance_pending');
const list = Array.isArray(pending) ? pending : [];
const count = list.length;
const banner = document.getElementById('clearance-banner');
const badge = document.getElementById('clr-tab-badge');
const bcount = document.getElementById('clr-banner-count');
if (banner) {
if (count > 0) {
banner.classList.add('active');
if (bcount) bcount.textContent = count;
} else {
banner.classList.remove('active');
}
}
if (badge) {
if (count > 0) { badge.style.display = 'inline'; badge.textContent = count; }
else badge.style.display = 'none';
}
} catch(e) {}
}
async function loadClearanceHud() {
const el = document.getElementById('clearance-hud');
if (!el) return;
try {
const [pendingRes, rulesRes, historyRes] = await Promise.all([
api('arc?action=clearance_pending'),
api('arc?action=clearance_rules'),
api('arc?action=clearance_history&limit=20')
]);
const pending = Array.isArray(pendingRes) ? pendingRes : [];
const rules = Array.isArray(rulesRes) ? rulesRes : [];
const history = Array.isArray(historyRes) ? historyRes : [];
let html = '<button class="clr-admin-btn" onclick="window.open(\'/admin#clearance\',\'_blank\')">◈ MANAGE CLEARANCE RULES IN ADMIN</button>';
// Pending requests
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:#ff6680;margin:8px 0 4px">PENDING AUTHORIZATION (${pending.length})</div>`;
if (!pending.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">◈ NO PENDING CLEARANCE REQUESTS</div>';
} else {
for (const cr of pending) {
const isOpen = _clrOpenCards.has(cr.id);
const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload || '{}') : (cr.job_payload || {});
const created = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
const expires = cr.expires_at ? new Date(cr.expires_at).toLocaleString() : '';
html += `<div class="clr-card${isOpen?' open':''}" id="clr-card-${cr.id}">
<div class="clr-card-head" onclick="toggleClrCard(${cr.id})">
<span class="clr-card-type">${escHtml(cr.job_type.toUpperCase().replace(/_/g,' '))}</span>
<span class="clr-card-risk ${cr.risk_level}">${cr.risk_level.toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">#${cr.id}</span>
</div>
<div class="clr-card-body">
<div class="clr-card-desc">${escHtml(cr.description || 'No description')}</div>
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:4px">
Requested: ${created}${expires ? ' · Expires: ' + expires : ''}
</div>
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim);margin-bottom:6px;word-break:break-all">
Payload: ${escHtml(JSON.stringify(pl))}
</div>
<div class="clr-action-bar">
<button class="clr-approve-btn" onclick="hudClearanceDecide(${cr.id},'approve')">◈ AUTHORIZE</button>
<button class="clr-deny-btn" onclick="hudClearanceDecide(${cr.id},'deny')">✕ DENY</button>
</div>
</div>
</div>`;
}
}
// Rules
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:12px 0 4px">CLEARANCE RULES</div>`;
if (!rules.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3);margin-bottom:10px">No rules configured</div>';
} else {
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px;margin-bottom:8px">';
for (const r of rules) {
const enClass = r.enabled ? 'clr-rule-enabled' : 'clr-rule-disabled';
const enLabel = r.enabled ? 'ON' : 'OFF';
const reqLabel = r.require_approval ? 'REQUIRES APPROVAL' : 'AUTO-ALLOW';
const autoTxt = r.auto_approve_after_min ? ` · AUTO ${r.auto_approve_after_min}m` : '';
html += `<div class="clr-rule-row">
<span class="clr-rule-type">${r.job_type.replace(/_/g,' ').toUpperCase()}</span>
<span class="clr-card-risk ${r.risk_level}" style="font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px;border:1px solid">${r.risk_level.toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${reqLabel}${autoTxt}</span>
<button class="clr-rule-toggle ${enClass}" onclick="hudClearanceRuleToggle(${r.id},${r.enabled?0:1})">${enLabel}</button>
</div>`;
}
html += '</div>';
}
// Recent history
html += `<div style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px">RECENT HISTORY</div>`;
const recentDecided = history.filter(h => h.status !== 'pending').slice(0, 10);
if (!recentDecided.length) {
html += '<div class="comms-empty" style="color:rgba(255,255,255,0.3)">No history yet</div>';
} else {
html += '<div style="background:rgba(0,0,0,0.2);border-radius:var(--r);padding:6px 10px">';
for (const h of recentDecided) {
const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
html += `<div class="clr-history-row">
<span class="clr-status-${h.status}">◈</span>
<span style="flex:1">${h.job_type.replace(/_/g,' ').toUpperCase()}</span>
<span class="clr-status-${h.status}">${h.status.toUpperCase()}</span>
<span style="color:rgba(255,255,255,0.3)">${ts}</span>
</div>`;
}
html += '</div>';
}
el.innerHTML = html;
await updateClearanceBanner();
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">CLEARANCE SYSTEM OFFLINE</div>';
}
}
function toggleClrCard(id) {
const card = document.getElementById('clr-card-' + id);
if (!card) return;
if (_clrOpenCards.has(id)) _clrOpenCards.delete(id);
else _clrOpenCards.add(id);
card.classList.toggle('open');
}
async function hudClearanceDecide(id, action) {
const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
if (!confirm(`${label} clearance request #${id}?`)) return;
const note = action === 'deny' ? (prompt('Reason for denial (optional):') || '') : '';
try {
const res = await api(`arc?action=clearance_${action}&id=${id}`, 'POST', { decided_by: 'admin', note });
const msg = action === 'approve'
? `◈ Clearance #${id} authorized. Job dispatched.`
: `◈ Clearance #${id} denied${note ? ': ' + note : ''}.`;
addMessage('jarvis', msg);
speak(action === 'approve' ? 'Clearance granted. Job dispatched.' : 'Request denied.');
await loadClearanceHud();
} catch(e) {
addMessage('system', 'Clearance action failed.');
}
}
async function hudClearanceRuleToggle(id, newEnabled) {
try {
await api(`arc?action=clearance_rule_update&id=${id}`, 'POST', { enabled: newEnabled });
await loadClearanceHud();
} catch(e) {}
}
async function loadAgents() {
const [listData, metricsData] = await Promise.all([
api('agent/list'),
api('agent/status')
]);
const agents = listData.agents || [];
const metrics = metricsData.metrics || {};
// Fetch sparkline data (non-blocking)
api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {});
renderAgentsTab(agents, metrics);
}
async function addNetworkDevice() {
const ip = prompt('IP address (e.g. 10.48.200.43):');
if (!ip) return;
const name = prompt('Device name (e.g. Yealink Phone):');
if (!name) return;
const type = prompt('Type (server, voip, nas, printer, device):', 'device') || 'device';
const r = await api('network/add', 'POST', {ip, alias: name, type});
if (r.error) { alert('Error: ' + r.error); return; }
loadNetwork();
}
async function deleteNetworkDevice(ip, evt) {
evt.stopPropagation();
if (!confirm('Remove ' + ip + ' from the network list?')) return;
const r = await api('network/delete', 'POST', {ip});
if (r.error) { alert('Error: ' + r.error); return; }
loadNetwork();
}
let _agentSparkData = {};
function sparkline(points, width=80, height=20, color='var(--cyan)') {
if (!points || points.length < 2) return '';
const max = Math.max(...points, 1);
const min = Math.min(...points);
const range = max - min || 1;
const step = width / (points.length - 1);
const pts = points.map((v, i) => {
const x = i * step;
const y = height - ((v - min) / range) * (height - 2) - 1;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg width="${width}" height="${height}" style="overflow:visible;display:block">
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.8"/>
<circle cx="${((points.length-1)*step).toFixed(1)}" cy="${(height - ((points[points.length-1]-min)/range)*(height-2)-1).toFixed(1)}" r="2" fill="${color}"/>
</svg>`;
}
function renderAgentsTab(agents, metrics) {
const el = document.getElementById('agents-list');
if (!el) return;
if (!agents.length) {
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center;margin-top:20px">NO AGENTS REGISTERED</div>';
return;
}
el.innerHTML = agents.map(ag => {
const m = metrics[ag.agent_id] || {};
const sys = m.system || {};
const alive = ag.status === 'online';
const cpu = sys.cpu_percent != null ? Math.round(sys.cpu_percent) : '--';
const mem = sys.memory ? Math.round(sys.memory.percent) : '--';
const memUsed = sys.memory ? Math.round(sys.memory.used_mb / 1024 * 10) / 10 + 'GB' : '--';
const memTot = sys.memory ? Math.round(sys.memory.total_mb / 1024 * 10) / 10 + 'GB' : '--';
const disks = sys.disk || [];
const maxDisk = disks.length ? Math.max(...disks.map(d => parseInt(d.percent)||0)) : null;
const uptime = sys.uptime ? sys.uptime.human : (alive ? 'ONLINE' : 'OFFLINE');
const since = ag.last_seen ? ag.last_seen.replace('T',' ').replace(/\.\d+Z$/,'') : '--';
const gauge = (val, unit='%', warn=80, crit=90) => {
const v = typeof val === 'number' ? val : parseInt(val);
if (isNaN(v)) return `<span style="color:var(--text-dim)">--</span>`;
const col = v >= crit ? 'var(--red)' : v >= warn ? '#f5a623' : 'var(--green)';
return `<div style="display:flex;align-items:center;gap:4px">
<div style="width:50px;height:5px;background:rgba(255,255,255,0.1);border-radius:3px;flex-shrink:0">
<div style="width:${Math.min(v,100)}%;height:100%;background:${col};border-radius:3px;transition:width 0.5s"></div>
</div>
<span style="color:${col};font-size:0.65rem">${v}${unit}</span>
</div>`;
};
const svcs = (sys.services || []).filter(s => s.status !== 'inactive' || true)
.map(s => `<span style="color:${s.status==='active'?'var(--green)':'var(--red)'};font-size:0.58rem;margin-right:6px">${s.service}: ${s.status}</span>`)
.join('');
const ctxKey = 'agent_' + ag.agent_id;
_panelCtx[ctxKey] = {type:'agent', label: ag.hostname, agent_id: ag.agent_id,
hostname: ag.hostname, status: ag.status, cpu, mem};
return `<div class="alert-item ${alive ? '' : 'critical'}" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')"
style="flex-direction:column;align-items:stretch;border-left:3px solid ${alive ? 'var(--green)' : 'var(--red)'}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<div style="width:8px;height:8px;border-radius:50%;background:${alive ? 'var(--green)' : 'var(--red)'};box-shadow:${alive ? '0 0 6px var(--green)' : 'none'};flex-shrink:0"></div>
<span style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text);flex:1">${ag.hostname}</span>
<span style="font-size:0.58rem;color:var(--text-dim)">${ag.agent_type.toUpperCase()} · ${ag.ip_address}</span>
<span style="font-size:0.58rem;color:${alive ? 'var(--green)' : 'var(--red)'};">${alive ? 'ONLINE' : 'OFFLINE'}</span>
</div>
${alive ? `<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:4px">
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">CPU</div>${gauge(cpu)}</div>
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">MEM ${memUsed}/${memTot}</div>${gauge(mem)}</div>
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">DISK</div>${maxDisk != null ? gauge(maxDisk) : '<span style="color:var(--text-dim)">--</span>'}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:4px">
<div>
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">CPU 2H</div>
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')}
</div>
<div>
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">MEM 2H</div>
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')}
</div>
</div>` : ''}
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="font-size:0.58rem;color:var(--text-dim)">UP: ${uptime} · SEEN: ${since}</div>
${svcs ? `<div style="font-size:0.58rem">${svcs}</div>` : ''}
</div>
${alive ? `<div style="display:flex;gap:5px;margin-top:6px">
<button onclick="event.stopPropagation();agentScreenshot('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SCREENSHOT</button>
<button onclick="event.stopPropagation();agentSysinfo('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">⚡ SYSINFO</button>
</div>` : ''}
</div>`;
}).join('');
}
function openAgentModal() {
const os = detectOS();
const title = document.getElementById('agentModalTitle');
const content = document.getElementById('agentModalContent');
const modal = document.getElementById('agentModal');
const regKey = 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518';
const baseUrl = 'https://jarvis.orbishosting.com/agent';
const jUrl = 'https://jarvis.orbishosting.com';
if (os === 'tablet') {
title.textContent = '● JARVIS — TABLET / MOBILE';
content.innerHTML =
'<div style="color:var(--cyan);font-size:0.75rem;margin-bottom:12px">✓ You\'re viewing JARVIS on a tablet or mobile device.</div>' +
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.6">The JARVIS Agent runs on desktop and server platforms (Windows, macOS, Linux).<br><br>' +
'Tablets and phones can browse the full JARVIS dashboard but do not need an agent installed — all data comes from your other monitored machines.</div>';
} else if (_agentOnline) {
title.textContent = '● AGENT CONNECTED';
content.innerHTML =
'<div style="color:var(--green);font-size:0.75rem;margin-bottom:12px">✓ JARVIS Agent is active on this machine.</div>' +
'<div style="color:var(--text-dim);font-size:0.65rem;line-height:1.8">' +
'<b style="color:var(--text)">Host:</b> ' + (_myAgent?.hostname||'—') + '<br>' +
'<b style="color:var(--text)">IP:</b> ' + (_myAgent?.ip_address||'—') + '<br>' +
'<b style="color:var(--text)">Type:</b> ' + (_myAgent?.agent_type||'—').toUpperCase() + '<br>' +
'<b style="color:var(--text)">Reporting:</b> CPU · Memory · Disk · Services · Uptime</div>';
} else {
const inst = {
windows: {
label:'Windows',
cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process -Force\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile "$env:TEMP\\install.ps1"\n& "$env:TEMP\\install.ps1" -JarvisUrl '+jUrl+' -Key '+regKey,
dl: baseUrl+'/install-windows.ps1',
note:'Run PowerShell as Administrator. Installs as a Windows Task Scheduler service.'
},
mac: {
label:'macOS',
cmd:'bash <(curl -sSL '+baseUrl+'/install-mac.sh) \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
dl: baseUrl+'/install-mac.sh',
note:'Run in Terminal. Installs as a launchd background service.'
},
linux: {
label:'Linux',
cmd:'curl -sSL '+baseUrl+'/install.sh | sudo bash -s -- \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
dl: baseUrl+'/install.sh',
note:'Run in terminal. Installs as a systemd service.'
},
unknown: {
label:'Your System',
cmd:'# Browse installers:\nhttps://jarvis.orbishosting.com/agent/',
dl: 'https://jarvis.orbishosting.com/agent/',
note:'Choose your platform installer from the JARVIS agent directory.'
}
};
const i = inst[os] || inst.unknown;
const osBadge = {windows:'🪟 WINDOWS', mac:'🍎 MACOS', linux:'🐧 LINUX', unknown:'❓ UNKNOWN'}[os] || os.toUpperCase();
title.textContent = '● INSTALL AGENT · ' + (inst[os] ? inst[os].label.toUpperCase() : 'YOUR SYSTEM');
content.innerHTML =
'<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:1px;margin-bottom:8px">DETECTED: ' + osBadge + '</div>' +
'<div style="color:var(--text-dim);font-size:0.65rem;margin-bottom:12px">'+i.note+'</div>' +
'<pre id="agentCmdPre">'+i.cmd+'</pre>' +
'<a class="agent-dl-btn" href="'+i.dl+'" target="_blank">↓ DOWNLOAD INSTALLER</a>' +
'<div style="color:var(--text-dim);font-size:0.6rem;margin-top:16px;opacity:0.7">After install, the AGENT indicator turns green within 30 seconds.</div>';
}
modal.classList.add('open');
}
document.addEventListener('click', function(e) {
if (e.target === document.getElementById('agentModal'))
document.getElementById('agentModal').classList.remove('open');
});
// ── SITES MANAGER ────────────────────────────────────────────────────
let sitesData = {};
function openSitesModal() {
document.getElementById('sitesModal').style.display = 'flex';
loadSites();
}
function closeSitesModal() {
document.getElementById('sitesModal').style.display = 'none';
}
// Close on backdrop click
document.getElementById('sitesModal').addEventListener('click', function(e) {
if (e.target === this) closeSitesModal();
});
async function loadSites() {
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:var(--text-dim);font-size:0.65rem;letter-spacing:2px">LOADING SITE SETTINGS...</div>';
const res = await api('sites');
if (!res.success) {
document.getElementById('sites-grid').innerHTML = '<div style="grid-column:1/-1;color:#f44;font-size:0.65rem">FAILED TO LOAD SETTINGS</div>';
return;
}
sitesData = res.sites;
// Pre-fill global key from first site
const firstKey = Object.values(res.sites)[0]?.api_key || '';
document.getElementById('global-api-key').value = firstKey;
renderSiteCards();
}
function renderSiteCards() {
const grid = document.getElementById('sites-grid');
let html = '';
for (const [id, s] of Object.entries(sitesData)) {
html += `
<div style="background:rgba(0,212,255,0.02);border:1px solid rgba(0,212,255,0.12);padding:16px">
<div style="margin-bottom:12px">
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:2px">${s.name.toUpperCase()}</div>
<div style="color:var(--text-dim);font-size:0.58rem">${s.url}</div>
</div>
<div style="margin-bottom:10px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM EMAIL</div>
<input id="${id}-from_email" type="text" value="${s.from_email || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="margin-bottom:10px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">FROM NAME</div>
<input id="${id}-from_name" type="text" value="${s.from_name || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="margin-bottom:12px">
<div style="color:var(--text-dim);font-size:0.58rem;letter-spacing:1px;margin-bottom:4px">ADMIN NOTIFICATION EMAIL</div>
<input id="${id}-admin_email" type="text" value="${s.admin_email || ''}"
style="width:100%;background:#0a0f1a;border:1px solid rgba(0,212,255,0.15);color:var(--text);font-family:var(--font-mono);font-size:0.65rem;padding:6px 10px;outline:none;box-sizing:border-box">
</div>
<div style="display:flex;align-items:center;gap:10px">
<button onclick="saveSite('${id}')"
style="background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);color:var(--cyan);font-family:var(--font-mono);font-size:0.58rem;letter-spacing:2px;padding:6px 16px;cursor:pointer">
SAVE
</button>
<span id="${id}-status" style="font-size:0.58rem;color:var(--text-dim)"></span>
</div>
</div>`;
}
grid.innerHTML = html;
}
async function pushApiKey() {
const key = document.getElementById('global-api-key').value.trim();
const status = document.getElementById('push-status');
if (!key) { status.style.color='#f44'; status.textContent='✗ API KEY REQUIRED'; return; }
status.style.color='var(--text-dim)'; status.textContent='PUSHING TO ALL SITES...';
const res = await api('sites', 'POST', {action:'push_key', api_key:key});
if (res.success) {
const ok = Object.values(res.results).filter(Boolean).length;
const total = Object.keys(res.results).length;
status.style.color = ok === total ? 'var(--cyan)' : '#fa0';
status.textContent = `✓ PUSHED TO ${ok}/${total} SITES`;
for (const id of Object.keys(sitesData)) sitesData[id].api_key = key;
} else {
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
}
}
async function saveSite(id) {
const status = document.getElementById(id + '-status');
status.style.color='var(--text-dim)'; status.textContent='SAVING...';
const res = await api('sites', 'POST', {
action: 'save',
site: id,
from_email: document.getElementById(id+'-from_email').value.trim(),
from_name: document.getElementById(id+'-from_name').value.trim(),
admin_email: document.getElementById(id+'-admin_email').value.trim(),
});
if (res.success) {
status.style.color='var(--cyan)'; status.textContent='✓ SAVED';
setTimeout(() => { status.textContent=''; }, 3000);
} else {
status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
}
}
// ── VISION PROTOCOL — screenshot lightbox ────────────────────────────────────
function openVisionLightbox(title) {
const lb = document.getElementById('vision-lightbox');
document.getElementById('vision-lb-title').textContent = title || '◈ VISION PROTOCOL';
document.getElementById('vision-lb-img').style.display = 'none';
document.getElementById('vision-lb-img').src = '';
document.getElementById('vision-lb-analysis').textContent = '';
document.getElementById('vision-lb-spinner').style.display = 'block';
lb.classList.add('open');
}
function closeVisionLightbox() {
document.getElementById('vision-lightbox').classList.remove('open');
}
async function agentScreenshot(hostname) {
openVisionLightbox('◈ VISION PROTOCOL — ' + hostname.toUpperCase());
const arcRes = await api('arc?action=job_create', 'POST', {
type: 'screenshot',
payload: {agent: hostname, analyze: true},
priority: 8,
}).catch(() => null);
if (!arcRes || !arcRes.job_id) {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit screenshot job — Arc Reactor may be offline.';
return;
}
// Poll for result
const jobId = arcRes.job_id;
let tries = 0;
const poll = async () => {
tries++;
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
if (job && job.status === 'done') {
const r = job.result || {};
document.getElementById('vision-lb-spinner').style.display = 'none';
if (r.has_image && r.screenshot_id) {
// Fetch full screenshot with image
const full = await api('arc?action=screenshot_get&id=' + r.screenshot_id).catch(() => null);
if (full && full.image_b64) {
const img = document.getElementById('vision-lb-img');
img.src = 'data:image/png;base64,' + full.image_b64;
img.style.display = 'block';
}
}
document.getElementById('vision-lb-analysis').textContent =
r.analysis || (r.has_image ? 'Screenshot captured — no analysis available.' : JSON.stringify(r.snapshot || r, null, 2));
} else if (job && job.status === 'failed') {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Screenshot failed: ' + (job.error || 'Unknown error');
} else if (tries < 30) {
setTimeout(poll, 2000);
} else {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Timed out waiting for screenshot.';
}
};
setTimeout(poll, 2000);
}
async function agentSysinfo(hostname) {
openVisionLightbox('⚡ FIELD SYSINFO — ' + hostname.toUpperCase());
const arcRes = await api('arc?action=job_create', 'POST', {
type: 'sysinfo',
payload: {agent: hostname, analyze: true},
priority: 7,
}).catch(() => null);
if (!arcRes || !arcRes.job_id) {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Failed to submit sysinfo job.';
return;
}
const jobId = arcRes.job_id;
let tries = 0;
const poll = async () => {
tries++;
const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
if (job && job.status === 'done') {
const r = job.result || {};
document.getElementById('vision-lb-spinner').style.display = 'none';
const snap = r.snapshot || {};
const snapText = Object.entries(snap)
.filter(([k]) => !['success','screenshot_available','snapshot_type'].includes(k))
.map(([k,v]) => `${k.toUpperCase().replace(/_/g,' ')}: ${Array.isArray(v) ? v.join('\n ') : v}`)
.join('\n');
document.getElementById('vision-lb-analysis').textContent =
(r.analysis ? r.analysis + '\n\n─────────────────────\n\n' : '') + (snapText || JSON.stringify(r, null, 2));
} else if (job && job.status === 'failed') {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Sysinfo failed: ' + (job.error || 'Unknown error');
} else if (tries < 20) {
setTimeout(poll, 2000);
} else {
document.getElementById('vision-lb-spinner').style.display = 'none';
document.getElementById('vision-lb-analysis').textContent = 'Timed out.';
}
};
setTimeout(poll, 2000);
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeVisionLightbox();
});
// ── CHAT HISTORY SEARCH ───────────────────────────────────────────────────────
function openSearchModal() {
document.getElementById('searchModal').style.display = 'flex';
document.getElementById('searchInput').focus();
}
function closeSearchModal() {
document.getElementById('searchModal').style.display = 'none';
document.getElementById('searchResults').innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>';
document.getElementById('searchInput').value = '';
}
async function runSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
const el = document.getElementById('searchResults');
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Searching...</div>';
try {
const d = await api('history?q=' + encodeURIComponent(q));
if (!d.results || !d.results.length) {
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">No results for "' + q + '"</div>';
return;
}
el.innerHTML = d.results.map(r => {
const role = r.role === 'user' ? '👤' : '🤖';
const ts = new Date(r.created_at).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
const snippet = r.content.length > 200 ? r.content.slice(0,197) + '…' : r.content;
return `<div style="background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:10px 12px">
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:var(--cyan)">${role} ${r.role.toUpperCase()}</span>
<span style="font-size:0.52rem;color:var(--text-dim)">${ts}</span>
</div>
<div style="font-size:0.68rem;color:var(--text-primary);line-height:1.4">${snippet.replace(/</g,'&lt;')}</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = '<div style="color:var(--red);font-size:0.65rem;text-align:center;padding:20px">Search failed</div>';
}
}
document.getElementById('searchModal')?.addEventListener('click', e => {
if (e.target === document.getElementById('searchModal')) closeSearchModal();
});
// ── PROACTIVE SUGGESTIONS ────────────────────────────────────────────────────
const _shownSuggestions = new Set();
async function checkSuggestions() {
const d = await api('suggestions').catch(() => null);
if (!d || !d.suggestions || !d.suggestions.length) return;
for (const s of d.suggestions) {
const key = s.intent + ':' + d.hour + ':' + d.dow;
if (_shownSuggestions.has(key)) continue;
_shownSuggestions.add(key);
// Show as a soft suggestion chip in chat
const log = document.getElementById('chatLog');
const chip = document.createElement('div');
chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0';
chip.innerHTML = `<button onclick="sendSuggestion('${s.intent}',this)" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.25);border-radius:12px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:1px;padding:4px 12px;cursor:pointer;transition:all 0.2s" onmouseover="this.style.background='rgba(0,212,255,0.12)'" onmouseout="this.style.background='rgba(0,212,255,0.06)'">◈ ${s.prompt}</button>`;
log.appendChild(chip);
log.scrollTop = log.scrollHeight;
break; // show max one suggestion at a time
}
}
function sendSuggestion(intent, btn) {
btn.closest('div').remove();
const prompts = {
'network_scan': 'run a network scan',
'jellyfin_now_playing': 'what is playing on Jellyfin',
'ha_scene': 'what scenes are available',
'planner:briefing': 'daily briefing',
'vm_suggestions': 'VM resource suggestions',
'focus_mode': 'focus mode',
};
const msg = prompts[intent] || intent.replace(/_/g,' ');
document.getElementById('textInput').value = msg;
sendMessage();
}
// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
function mobSwitch(which) {
if (window.innerWidth > 900) return;
const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'};
Object.entries(panels).forEach(([k, id]) => {
document.getElementById(id)?.classList.toggle('mob-active', k === which);
});
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('mob-btn-' + which)?.classList.add('active');
if (which === 'right') loadNews();
}
function initMobile() {
if (window.innerWidth > 900) return;
['leftPanel','centerPanel','rightPanel'].forEach(id =>
document.getElementById(id)?.classList.remove('mob-active'));
document.getElementById('leftPanel')?.classList.add('mob-active');
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('mob-btn-left')?.classList.add('active');
}
window.addEventListener('resize', initMobile);
</script>
<!-- VISION LIGHTBOX -->
<div id="vision-lightbox">
<div id="vision-lb-header">
<span id="vision-lb-title">◈ VISION PROTOCOL</span>
<button id="vision-lb-close" onclick="closeVisionLightbox()">✕ CLOSE</button>
</div>
<div id="vision-lb-spinner">● SCANNING...</div>
<img id="vision-lb-img" alt="Agent Screenshot" style="display:none">
<pre id="vision-lb-analysis"></pre>
</div>
<!-- CHAT HISTORY SEARCH MODAL -->
<div id="searchModal" style="display:none;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);align-items:center;justify-content:center">
<div style="background:#000d1f;border:1px solid var(--panel-border);border-radius:6px;padding:20px;width:min(620px,94vw);max-height:80vh;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="font-family:var(--font-display);font-size:0.75rem;letter-spacing:3px;color:var(--cyan)">◈ CHAT HISTORY SEARCH</div>
<button onclick="closeSearchModal()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1rem"></button>
</div>
<div style="display:flex;gap:8px">
<input id="searchInput" type="text" placeholder="Search conversations…"
style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);color:var(--text-primary);font-family:var(--font-mono);font-size:0.75rem;padding:8px 12px;outline:none"
onkeydown="if(event.key==='Enter')runSearch()" autocomplete="off"/>
<button onclick="runSearch()" style="background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.3);color:var(--cyan);font-family:var(--font-display);font-size:0.6rem;letter-spacing:2px;padding:8px 16px;cursor:pointer">SEARCH</button>
</div>
<div id="searchResults" style="overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:6px;min-height:60px">
<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>
</div>
</div>
</div>
<nav id="mobileNav">
<button class="mob-nav-btn active" id="mob-btn-left" onclick="mobSwitch('left')">
<span class="mob-icon">📊</span>STATS
</button>
<button class="mob-nav-btn" id="mob-btn-center" onclick="mobSwitch('center')">
<span class="mob-icon">💬</span>CHAT
</button>
<button class="mob-nav-btn" id="mob-btn-right" onclick="mobSwitch('right')">
<span class="mob-icon">🛰</span>INFO
</button>
</nav>
</body>
</html>