Files
astraea/templates/index.html
drjones 7a6ba23c99 v1.2 mobile PWA: installable app + mobile-first UI
- Installable PWA: manifest.json (standalone, portrait, maskable icons),
  theme-color + apple-mobile-web-app meta, generated 192/512 PNG icons,
  service worker (network-first, never caches /api/).
- Mobile UI (≤720px): sticky blurred header, agent grid -> horizontal
  snap-scroll carousel, fixed bottom composer with safe-area insets,
  modals -> full-screen bottom sheets, 16px inputs (no iOS zoom), bigger
  touch targets, icon-only header actions.
- /app remains the app shell; / is the landing page.
2026-09-07 20:00:46 -07:00

591 lines
46 KiB
HTML
Raw Permalink 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.0, viewport-fit=cover">
<meta name="theme-color" content="#070b1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Astraea">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="icon" type="image/png" href="/static/icon-192.png">
<title>Astraea — Your Attorney</title>
<style>
:root { --bg:#070b1a; --panel:#101832; --panel2:#141d3d; --line:#233055; --text:#e8edfb; --muted:#8b98c4; --gold:#f5d06f; }
* { box-sizing:border-box; margin:0; padding:0; }
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; background:radial-gradient(1200px 700px at 50% -10%,#18254f 0%,var(--bg) 60%); color:var(--text); min-height:100vh; }
canvas { position:fixed; inset:0; z-index:0; pointer-events:none; }
.app { position:relative; z-index:1; max-width:1120px; margin:0 auto; padding:0 20px 60px; }
header { padding:20px 0; display:flex; align-items:center; justify-content:space-between; gap:14px; }
.brand { display:flex; align-items:center; gap:11px; }
.logo { width:40px; height:40px; border-radius:11px; background:linear-gradient(135deg,#6ea8ff,#a78bfa); display:grid; place-items:center; font-size:20px; }
.brand h1 { font-size:20px; font-weight:800; }
.brand h1 span { color:var(--gold); }
.top-actions { display:flex; align-items:center; gap:10px; }
.avatar-sm { width:36px; height:36px; border-radius:50%; display:grid; place-items:center; font-size:17px; color:#fff; cursor:pointer; }
.btn { border:1px solid var(--line); background:var(--panel2); color:var(--text); border-radius:10px; padding:9px 15px; font-size:13.5px; font-weight:600; cursor:pointer; transition:.15s; }
.btn:hover { border-color:#3b82f6; }
.btn.primary { background:linear-gradient(135deg,#3b82f6,#6366f1); border:none; color:#fff; }
.hero { padding:12px 0 20px; }
.hero h2 { font-size:clamp(24px,4vw,34px); font-weight:800; letter-spacing:-.5px; }
.hero p { color:var(--muted); margin-top:8px; font-size:15px; }
.label { font-size:11.5px; text-transform:uppercase; letter-spacing:2px; color:var(--muted); margin:14px 0 12px; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(235px,1fr)); gap:13px; }
.card { background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:15px; padding:16px; cursor:pointer; position:relative; overflow:hidden; transition:.18s; }
.card::before { content:""; position:absolute; inset:0 0 auto 0; height:3px; background:var(--c,#7dd3fc); }
.card:hover { transform:translateY(-3px); border-color:var(--c,#7dd3fc); }
.card.active { border-color:var(--c,#7dd3fc); box-shadow:0 0 0 1px var(--c,#7dd3fc),0 14px 34px rgba(0,0,0,.5); }
.card .emoji { font-size:27px; }
.card h3 { font-size:15.5px; font-weight:700; margin:10px 0 3px; }
.card .tag { font-size:12px; color:var(--c,#7dd3fc); font-weight:600; margin-bottom:6px; }
.card p { font-size:12.5px; color:var(--muted); line-height:1.45; }
.chat-panel { margin-top:30px; background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; overflow:hidden; }
.chat-head { padding:14px 18px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:11px; flex-wrap:wrap; }
.chat-head .emoji { font-size:24px; }
.chat-head .who { font-weight:700; font-size:15px; }
.chat-head .scope { color:var(--muted); font-weight:500; font-size:12px; }
.chips { margin-left:auto; display:flex; gap:6px; flex-wrap:wrap; }
.chip { font-size:11.5px; padding:5px 10px; border-radius:999px; border:1px solid var(--line); color:var(--muted); background:transparent; cursor:pointer; white-space:nowrap; }
.chip.on { color:#06121f; background:var(--c,#7dd3fc); border-color:var(--c,#7dd3fc); font-weight:700; }
.messages { padding:20px; min-height:220px; max-height:520px; overflow-y:auto; display:flex; flex-direction:column; gap:15px; }
.msg { max-width:80%; display:flex; flex-direction:column; }
.msg.user { align-self:flex-end; align-items:flex-end; }
.msg.agent { align-self:flex-start; }
.bubble { padding:12px 15px; border-radius:15px; font-size:14.5px; line-height:1.6; white-space:pre-wrap; word-break:break-word; }
.msg.user .bubble { background:linear-gradient(135deg,#3b82f6,#6366f1); color:#fff; border-bottom-right-radius:4px; }
.msg.agent .bubble { background:var(--panel2); border:1px solid var(--line); border-bottom-left-radius:4px; }
.msg .who-label { font-size:11px; color:var(--muted); margin:0 4px 4px; }
.speak { margin-top:6px; background:none; border:none; color:var(--gold); cursor:pointer; font-size:12px; }
.citations { margin-top:9px; }
.cite-toggle { font-size:12px; color:var(--gold); cursor:pointer; background:none; border:none; }
.cite-list { margin-top:7px; display:none; flex-direction:column; gap:7px; }
.cite-list.open { display:flex; }
.cite { background:#0c142e; border:1px solid var(--line); border-radius:9px; padding:9px 11px; font-size:12px; color:var(--muted); }
.cite b { color:var(--text); display:block; margin-bottom:2px; }
.typing { display:inline-flex; gap:5px; padding:6px 2px; }
.typing i { width:7px; height:7px; border-radius:50%; background:var(--muted); animation:bl 1.2s infinite; }
.typing i:nth-child(2){ animation-delay:.2s; } .typing i:nth-child(3){ animation-delay:.4s; }
@keyframes bl { 0%,100%{opacity:.25;} 50%{opacity:1;} }
.composer { border-top:1px solid var(--line); padding:13px 15px; display:flex; gap:9px; align-items:flex-end; }
.composer textarea { flex:1; resize:none; background:#0c142e; border:1px solid var(--line); color:var(--text); border-radius:11px; padding:11px 13px; font-size:14.5px; font-family:inherit; line-height:1.5; max-height:130px; outline:none; }
.composer textarea:focus { border-color:var(--c,#7dd3fc); }
.send { border:none; border-radius:11px; padding:12px 18px; font-size:14px; font-weight:700; color:#06121f; background:var(--c,#7dd3fc); cursor:pointer; }
.send:disabled { opacity:.45; }
.tools-row { display:flex; gap:8px; padding:0 15px 12px; flex-wrap:wrap; }
.tools-row .btn { font-size:12.5px; padding:8px 13px; }
.modal-bg { position:fixed; inset:0; background:rgba(3,5,12,.8); z-index:50; display:none; align-items:center; justify-content:center; padding:20px; }
.modal-bg.open { display:flex; }
.modal { background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:18px; width:100%; max-width:620px; max-height:90vh; overflow-y:auto; padding:26px; }
.modal h2 { font-size:20px; font-weight:800; margin-bottom:4px; }
.modal .sub { color:var(--muted); font-size:13px; margin-bottom:18px; }
.modal .close { float:right; background:none; border:none; color:var(--muted); font-size:22px; cursor:pointer; }
.fld { margin-bottom:15px; }
.fld label { display:block; font-size:12.5px; color:var(--muted); margin-bottom:6px; }
.fld input, .fld textarea, .fld select { width:100%; background:#0c142e; border:1px solid var(--line); color:var(--text); border-radius:10px; padding:11px 13px; font-size:14px; outline:none; font-family:inherit; }
.fld textarea { min-height:110px; resize:vertical; }
.fld input:focus, .fld textarea:focus { border-color:#6366f1; }
.avatar-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-bottom:18px; }
.avatar-opt { aspect-ratio:1; border-radius:50%; display:grid; place-items:center; font-size:22px; color:#fff; cursor:pointer; border:2px solid transparent; }
.avatar-opt.sel { border-color:#fff; box-shadow:0 0 0 3px rgba(255,255,255,.2); }
.progbar { height:6px; background:var(--panel2); border-radius:3px; overflow:hidden; margin:14px 0 18px; }
.progbar > div { height:100%; background:linear-gradient(90deg,#6ea8ff,#a78bfa); border-radius:3px; transition:width .2s; }
.intake-q { font-size:16px; font-weight:600; margin-bottom:12px; line-height:1.4; }
.intake-nav { display:flex; justify-content:space-between; margin-top:14px; gap:10px; }
.intake-nav .btn { flex:1; }
.modal.wide { width:min(880px,94vw); max-height:90vh; overflow:auto; }
.file-list { max-height:240px; overflow:auto; display:flex; flex-direction:column; gap:6px; }
.file-item { display:flex; align-items:center; gap:8px; padding:8px 10px; background:var(--panel2); border-radius:8px; font-size:13px; }
.file-item .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.file-item .cat { color:var(--muted); font-size:11px; }
.file-item button { background:none; border:1px solid var(--line); color:var(--text); border-radius:6px; padding:4px 9px; cursor:pointer; font-size:12px; }
.file-item button:hover { background:var(--line); }
.switch { display:flex; align-items:center; gap:10px; }
.switch input { width:auto; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:13px; }
.doc-result { background:#0c142e; border:1px solid var(--line); border-radius:12px; padding:16px; font-size:13px; white-space:pre-wrap; max-height:50vh; overflow-y:auto; line-height:1.6; margin-top:14px; }
footer { text-align:center; margin-top:26px; font-size:12px; color:var(--muted); }
footer a { color:var(--muted); }
@media (max-width:640px){ .msg{max-width:92%;} .grid2{grid-template-columns:1fr;} .avatar-grid{grid-template-columns:repeat(4,1fr);} }
/* ───────────────────────── Mobile app experience (≤720px) ───────────────────────── */
@media (max-width:720px){
body { background:var(--bg); overscroll-behavior-y:none; }
.app { max-width:100%; padding:0 0 120px; }
/* sticky, blurred header */
header { position:sticky; top:0; z-index:20; padding:12px 14px; padding-top:calc(12px + env(safe-area-inset-top)); background:rgba(7,11,26,.82); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); border-bottom:1px solid var(--line); margin-bottom:4px; }
.logo { width:36px; height:36px; font-size:18px; }
.brand h1 { font-size:17px; }
.top-actions .btn { padding:8px 11px; font-size:12px; }
.top-actions .btn .lbl { display:none; }
.hero { padding:8px 14px 6px; }
.hero h2 { font-size:21px; }
.hero p { font-size:13.5px; }
.label { padding:0 14px; margin:10px 0 10px; }
/* agents -> horizontal snap carousel */
.grid { display:flex; overflow-x:auto; scroll-snap-type:x mandatory; gap:11px; padding:2px 14px 10px; -webkit-overflow-scrolling:touch; scrollbar-width:none; }
.grid::-webkit-scrollbar { display:none; }
.card { flex:0 0 78%; scroll-snap-align:center; padding:15px; }
.card:hover { transform:none; }
.chat-panel { margin-top:6px; border-radius:18px 18px 0 0; border-left:none; border-right:none; border-bottom:none; }
.messages { max-height:none; height:calc(100vh - 300px); min-height:200px; padding:14px; }
.chat-head { padding:11px 14px; }
.chips { order:3; width:100%; margin-left:0; padding-top:8px; }
/* fixed composer with safe-area */
.composer { position:fixed; left:0; right:0; bottom:0; z-index:25; background:rgba(16,24,50,.96); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); border-top:1px solid var(--line); padding:10px 12px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); }
.composer textarea { font-size:16px; } /* 16px prevents iOS zoom-on-focus */
.send { padding:12px 16px; }
.tools-row { overflow-x:auto; flex-wrap:nowrap; padding:0 14px 10px; scrollbar-width:none; }
.tools-row .btn { flex:0 0 auto; }
/* modals -> full-screen bottom sheets */
.modal-bg { align-items:flex-end; padding:0; }
.modal { max-width:100%; width:100%; max-height:92vh; border-radius:22px 22px 0 0; padding:22px 18px; padding-bottom:calc(24px + env(safe-area-inset-bottom)); }
.modal.wide { width:100%; max-height:92vh; }
.modal .close { font-size:26px; padding:0 4px; }
.avatar-grid { grid-template-columns:repeat(4,1fr); }
/* bigger touch targets */
.btn, .chip, .send, .speak, .cite-toggle { min-height:40px; }
.chip { font-size:12px; padding:7px 12px; }
footer { padding:0 14px 20px; font-size:11px; }
}
</style>
</head>
<body>
<canvas id="stars"></canvas>
<div class="app">
<header>
<div class="brand"><div class="logo">⚖️</div><div><h1>ASTRA<span>EA</span></h1></div></div>
<div class="top-actions">
<button class="btn" onclick="openDocs()">📄 <span class="lbl">Documents</span></button>
<button class="btn" onclick="openSettings()">⚙️ <span class="lbl">Settings</span></button>
<div class="avatar-sm" id="myAvatar" onclick="openProfile()" style="background:linear-gradient(135deg,#6ea8ff,#a78bfa)">⚖</div>
</div>
</header>
<div class="hero">
<h2>Eight specialists. Your case.</h2>
<p>Ask anything. Every answer is grounded in Washington statute and verified case law — and it already knows your situation.</p>
</div>
<div class="label">Choose your specialist</div>
<div class="grid" id="grid"></div>
<section class="chat-panel" id="chatPanel">
<div class="chat-head">
<div class="emoji" id="headEmoji">🧭</div>
<div><div class="who" id="headName">Navigator</div><div class="scope" id="headScope">Where to start</div></div>
<div class="chips" id="chips"></div>
</div>
<div class="messages" id="messages"></div>
<div class="tools-row">
<button class="btn" onclick="openDocs()">📄 Prep a document</button>
<button class="btn" onclick="openFill()">📝 Fill a form</button>
<button class="btn" onclick="openMission()">📨 Send on my behalf</button>
<button class="btn" onclick="clearConv()">🗑 Clear chat</button>
</div>
<div class="composer">
<button class="btn" id="micBtn" onclick="toggleMic()" title="Speak" style="flex:0 0 auto">🎤</button>
<textarea id="input" rows="1" placeholder="Ask your attorney…"></textarea>
<button class="send" id="sendBtn">Ask</button>
</div>
</section>
<footer>Astraea gives practical legal guidance grounded in Washington law — not a substitute for a licensed attorney. · <a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener">☕ Support</a></footer>
</div>
<!-- Profile modal -->
<div class="modal-bg" id="profileModal"><div class="modal">
<button class="close" onclick="closeModal('profileModal')">×</button>
<h2>Your profile</h2><div class="sub">This context shapes every answer — your attorney already knows your situation.</div>
<div class="fld"><label>Display name</label><input id="pfName" placeholder="Your name"></div>
<div class="fld"><label>Display picture</label></div>
<div class="avatar-grid" id="avatarGrid"></div>
<div class="fld"><label>Or upload your own photo</label><input type="file" id="pfPic" accept="image/*" onchange="uploadPic()"><span id="pfPicPreview"></span></div>
<div class="fld"><label>About Me — everything your attorney should know</label><button class="btn" style="width:100%;margin-bottom:8px" onclick="openIntake()">✍️ Guided intake — answer questions step by step</button><textarea id="pfAbout" placeholder="…or write it freely here. e.g. Married 2011, two children (ages 9 and 12), separated June 2025, own a home in King County, spouse filed a parenting plan…"></textarea></div>
<div class="fld"><label>📁 Case documents — upload & organize your paperwork</label>
<div style="display:flex;gap:8px;margin-bottom:8px"><select id="fileCat" style="width:auto"></select><input type="file" id="fileInput" multiple onchange="uploadFiles()" style="flex:1"></div>
<div id="fileList" class="file-list"><div class="sub">No documents yet — upload your petition, orders, agreements, and evidence.</div></div>
</div>
<button class="btn primary" style="width:100%" onclick="saveProfile()">Save profile</button>
</div></div>
<!-- Guided intake modal -->
<div class="modal-bg" id="intakeModal"><div class="modal">
<button class="close" onclick="closeModal('intakeModal')">×</button>
<h2>Guided intake</h2><div class="sub">Answer a few questions — your attorney uses these as context for every answer.</div>
<div class="progbar"><div id="intakeBar"></div></div>
<div class="intake-q" id="intakeQ"></div>
<input id="intakeA" placeholder="">
<div class="intake-nav">
<button class="btn" id="intakeBack" onclick="intakePrev()">← Back</button>
<button class="btn primary" id="intakeNext" onclick="intakeNext()">Next →</button>
</div>
</div></div>
<!-- Document viewer modal -->
<div class="modal-bg" id="viewerModal"><div class="modal wide">
<button class="close" onclick="closeModal('viewerModal')">×</button>
<h2 id="viewerTitle"></h2>
<div id="viewerBody" style="margin-top:12px"></div>
</div></div>
<!-- Fill a form modal -->
<div class="modal-bg" id="fillModal"><div class="modal">
<button class="close" onclick="closeModal('fillModal')">×</button>
<h2>Fill a court form</h2><div class="sub">Your answers are typed onto the official WA form PDF, ready to print and file.</div>
<div class="fld"><label>Form</label><select id="fillFormSelect"></select></div>
<button class="btn" style="width:100%;margin-bottom:12px" onclick="autofillForm()">✨ Auto-fill from my info</button>
<div id="fillFields"></div>
<button class="btn primary" style="width:100%" onclick="generateForm()">⬇ Generate filled PDF</button>
</div></div>
<!-- Settings modal -->
<div class="modal-bg" id="settingsModal"><div class="modal">
<button class="close" onclick="closeModal('settingsModal')">×</button>
<h2>Settings</h2><div class="sub">Comms channels your attorney can use for missions, and safety.</div>
<div class="fld"><label>Twilio Account SID</label><input id="twSid" placeholder="AC…"></div>
<div class="fld"><label>Twilio Auth Token</label><input id="twToken" type="password" placeholder="••••"></div>
<div class="fld"><label>Twilio From number (E.164)</label><input id="twFrom" placeholder="+14255550123"></div>
<div class="grid2">
<div class="fld"><label>SMTP host</label><input id="smHost" placeholder="smtp.gmail.com"></div>
<div class="fld"><label>SMTP port</label><input id="smPort" value="587"></div>
</div>
<div class="grid2">
<div class="fld"><label>SMTP username</label><input id="smUser"></div>
<div class="fld"><label>SMTP password</label><input id="smPass" type="password"></div>
</div>
<div class="fld"><label>Email "from" address</label><input id="emFrom" placeholder="you@example.com"></div>
<div class="fld"><label>TTS voice</label><select id="ttsVoice"></select></div>
<div class="fld switch"><label style="margin:0">🔒 No-contact order in effect</label><input type="checkbox" id="ncOrder"></div>
<div class="fld"><label>Protected person's name (blocked from contact)</label><input id="ncName" placeholder="Full name of the person you must not contact"></div>
<div class="fld switch"><label style="margin:0">⚠️ Domestic violence is a factor in my case</label><input type="checkbox" id="dvFactor"></div>
<div class="fld switch"><label style="margin:0">🛡️ Active protection order in effect (RCW 7.105)</label><input type="checkbox" id="poOrder"></div>
<div class="fld"><label>Safety notes — threats, incidents, what you're afraid of</label><textarea id="safetyNotes" style="min-height:70px" placeholder="e.g. There has been physical violence. I'm afraid for my safety and the children's safety."></textarea></div>
<button class="btn primary" style="width:100%" onclick="saveSettings()">Save settings</button>
</div></div>
<!-- Documents modal -->
<div class="modal-bg" id="docsModal"><div class="modal">
<button class="close" onclick="closeModal('docsModal')">×</button>
<h2>Prepare a document</h2><div class="sub">Court-ready Washington divorce paperwork, drafted to your facts.</div>
<div class="fld"><label>Document</label><select id="docType"></select></div>
<div class="fld"><label>Specific instructions (optional)</label><textarea id="docInst" placeholder="e.g. I want primary residential time with the children, propose alternate weekends for the other parent."></textarea></div>
<button class="btn primary" style="width:100%" id="docBtn" onclick="genDoc()">Generate document</button>
<div class="doc-result" id="docResult" style="display:none"></div>
</div></div>
<!-- Mission modal -->
<div class="modal-bg" id="missionModal"><div class="modal">
<button class="close" onclick="closeModal('missionModal')">×</button>
<h2>Send on your behalf</h2><div class="sub">Your attorney will format this professionally. A no-contact order blocks protected recipients.</div>
<div class="fld"><label>Type</label><select id="msType"><option value="sms">Text (SMS)</option><option value="email">Email</option><option value="call">Phone call</option></select></div>
<div class="fld"><label>Recipient (phone E.164 or email)</label><input id="msRecipient" placeholder="+14255550123 or name@example.com"></div>
<div class="fld" id="msSubjectWrap" style="display:none"><label>Subject</label><input id="msSubject"></div>
<div class="fld"><label>Message</label><textarea id="msBody" placeholder="What do you want to say?"></textarea></div>
<button class="btn primary" style="width:100%" onclick="doMission()">Send</button>
<div id="msStatus" style="margin-top:12px;font-size:13px"></div>
</div></div>
<script>
// Register the service worker for installable PWA (skips on non-secure origins)
if ('serviceWorker' in navigator && location.protocol === 'https:') {
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
}
const AGENTS = {{ agents | tojson }};
const token = localStorage.getItem('astraea_token') || '';
let active = AGENTS[0].id;
let avatars = [];
let myProfile = {display_name:'', avatar:0, about_me:''};
let mySettings = {};
const history = {};
AGENTS.forEach(a => history[a.id] = []);
// starfield
const cvs = document.getElementById('stars'), ctx = cvs.getContext('2d');
let stars = [];
(function size(){ cvs.width=innerWidth; cvs.height=innerHeight; stars=Array.from({length:120},()=>({x:Math.random()*cvs.width,y:Math.random()*cvs.height,r:Math.random()*1.3+.3,t:Math.random()*6.28,s:Math.random()*.02+.005})); })();
addEventListener('resize',()=>{cvs.width=innerWidth;cvs.height=innerHeight;});
(function draw(){ ctx.clearRect(0,0,cvs.width,cvs.height); for(const s of stars){ s.t+=s.s; const a=.3+Math.abs(Math.sin(s.t))*.7; ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,6.28); ctx.fillStyle=`rgba(190,210,255,${a})`; ctx.fill(); } requestAnimationFrame(draw); })();
function authHeaders(){ return token ? {'Authorization':'Bearer '+token} : {}; }
function accent(id){ const a=AGENTS.find(x=>x.id===id); return a?a.accent:'#7dd3fc'; }
function closeModal(id){ document.getElementById(id).classList.remove('open'); }
function renderGrid(){
const g=document.getElementById('grid'); g.innerHTML='';
AGENTS.forEach(a=>{ const c=document.createElement('div'); c.className='card'+(a.id===active?' active':''); c.style.setProperty('--c',a.accent); c.innerHTML=`<div class="emoji">${a.emoji}</div><h3>${a.name}</h3><div class="tag">${a.tagline}</div><p>${a.description}</p>`; c.onclick=()=>selectAgent(a.id,true); g.appendChild(c); });
}
function renderChips(){
const c=document.getElementById('chips'); c.innerHTML='';
AGENTS.forEach(a=>{ const b=document.createElement('button'); b.className='chip'+(a.id===active?' on':''); b.style.setProperty('--c',a.accent); b.textContent=a.emoji+' '+a.name.split(' & ')[0].split(' ')[0]; b.onclick=()=>selectAgent(a.id,false); c.appendChild(b); });
}
async function selectAgent(id,scroll){
active=id; const a=AGENTS.find(x=>x.id===id);
document.getElementById('headEmoji').textContent=a.emoji;
document.getElementById('headName').textContent=a.name;
document.getElementById('headScope').textContent=a.tagline;
const cp=document.getElementById('chatPanel'); cp.style.setProperty('--c',a.accent); cp.style.borderColor=a.accent+'55';
renderGrid(); renderChips();
const m=document.getElementById('messages'); m.innerHTML='';
if(token && !(history[id]||[]).length){
try{ const r=await fetch('/api/conversation/'+id,{headers:authHeaders()}); const d=await r.json(); history[id]=d.history||[]; }catch(e){}
}
(history[id]||[]).forEach(t=>addBubble(t.role,t.content,t.citations));
if(scroll) cp.scrollIntoView({behavior:'smooth',block:'start'});
}
async function clearConv(){
if(token){ try{ await fetch('/api/conversation/'+active,{method:'DELETE',headers:authHeaders()}); }catch(e){} }
history[active]=[]; document.getElementById('messages').innerHTML='';
}
let rec=null;
function toggleMic(){
const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
const mb=document.getElementById('micBtn');
if(!SR){ mb.title='Voice input needs Chrome/Edge'; return; }
if(rec){ rec.stop(); rec=null; mb.classList.remove('on'); mb.style.borderColor=''; return; }
rec=new SR(); rec.lang='en-US'; rec.interimResults=false;
rec.onresult=e=>{ const t=e.results[0][0].transcript; const i=document.getElementById('input'); i.value=(i.value?i.value+' ':'')+t; };
rec.onend=()=>{ rec=null; mb.classList.remove('on'); mb.style.borderColor=''; };
rec.onerror=()=>{ rec=null; mb.classList.remove('on'); mb.style.borderColor=''; };
rec.start(); mb.classList.add('on'); mb.style.borderColor='#f87171';
}
function addBubble(role,text,citations){
const m=document.createElement('div'); m.className='msg '+role;
const l=document.createElement('div'); l.className='who-label'; l.textContent=role==='user'?'You':(AGENTS.find(x=>x.id===active)?.name||'Attorney');
const b=document.createElement('div'); b.className='bubble'; b.textContent=text;
m.appendChild(l); m.appendChild(b);
if(role==='assistant'){
const sp=document.createElement('button'); sp.className='speak'; sp.textContent='🔊 Listen'; sp.onclick=()=>speak(text); m.appendChild(sp);
if(citations&&citations.length){ const cw=document.createElement('div'); cw.className='citations'; const t=document.createElement('button'); t.className='cite-toggle'; t.textContent='🔗 '+citations.length+' source'+(citations.length>1?'s':''); const cl=document.createElement('div'); cl.className='cite-list'; citations.forEach(c=>{ const it=document.createElement('div'); it.className='cite'; it.innerHTML=`<b>${c.title}</b>${c.snippet}<div style="color:var(--gold);font-size:10.5px">${c.source}</div>`; cl.appendChild(it); }); t.onclick=()=>cl.classList.toggle('open'); cw.appendChild(t); cw.appendChild(cl); m.appendChild(cw); }
}
document.getElementById('messages').appendChild(m);
document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight;
}
function typing(on){ document.querySelectorAll('.typing').forEach(e=>e.remove()); if(!on)return; const m=document.createElement('div'); m.className='msg agent'; const t=document.createElement('div'); t.className='typing'; t.innerHTML='<i></i><i></i><i></i>'; m.appendChild(t); document.getElementById('messages').appendChild(m); }
async function speak(text){
try{ const r=await fetch('/api/speak',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:text.replace(/\n+/g,'. ').substring(0,2500),voice:mySettings.tts_voice||'aria'})}); const b=await r.blob(); new Audio(URL.createObjectURL(b)).play(); }catch(e){}
}
function liveBubble(){
const m=document.createElement('div'); m.className='msg agent';
const l=document.createElement('div'); l.className='who-label'; l.textContent=(AGENTS.find(x=>x.id===active)?.name||'Attorney');
const b=document.createElement('div'); b.className='bubble'; b.textContent='';
m.appendChild(l); m.appendChild(b);
document.getElementById('messages').appendChild(m);
document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight;
return {m, b};
}
async function send(){
const t=document.getElementById('input').value.trim(); if(!t)return;
document.getElementById('input').value='';
addBubble('user',t); history[active].push({role:'user',content:t});
typing(true); document.getElementById('sendBtn').disabled=true;
try{
const r=await fetch('/api/chat/stream',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({agent_id:active,message:t,history:history[active].slice(0,-1)})});
if(!r.ok || !r.body){ // fallback to non-streaming
const d=await r.json().catch(()=>({})); typing(false);
if(d.answer){ addBubble('assistant',d.answer,d.citations); history[active].push({role:'assistant',content:d.answer,citations:d.citations}); }
else addBubble('assistant',d.error||'Something went wrong.',null);
} else {
typing(false);
const live=liveBubble(); let acc=''; let cites=null;
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
const {done,value}=await reader.read(); if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n'); buf=parts.pop();
for(const part of parts){
const ev=(part.match(/^event: (.+)$/m)||[])[1];
const dataLine=(part.match(/^data: (.+)$/m)||[])[1];
if(!ev||!dataLine)continue;
const payload=JSON.parse(dataLine);
if(ev==='delta'){ acc+=payload.t; live.b.textContent=acc; document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight; }
else if(ev==='done'){ cites=payload.citations; }
}
}
live.m.remove();
addBubble('assistant',acc,cites); history[active].push({role:'assistant',content:acc,citations:cites});
}
}catch(e){ typing(false); addBubble('assistant','Could not reach the engine.',null); }
finally{ document.getElementById('sendBtn').disabled=false; }
}
async function loadAvatars(){ const r=await fetch('/api/avatars'); avatars=await r.json(); const g=document.getElementById('avatarGrid'); g.innerHTML=''; avatars.forEach(a=>{ const d=document.createElement('div'); d.className='avatar-opt'; d.style.background=`linear-gradient(135deg,${a.c1},${a.c2})`; d.textContent=a.glyph; d.title=a.label; d.onclick=()=>{ myProfile.avatar=a.id; renderAvatarSel(); }; g.appendChild(d); }); }
function renderAvatarSel(){ document.querySelectorAll('.avatar-opt').forEach((d,i)=>d.classList.toggle('sel',i===myProfile.avatar)); }
function renderMyAvatar(){ const el=document.getElementById('myAvatar'); if(myProfile.profile_pic){ el.textContent=''; el.style.background=`url('${myProfile.profile_pic}') center/cover`; } else { const a=avatars[myProfile.avatar]||avatars[0]; if(a){ el.style.background=`linear-gradient(135deg,${a.c1},${a.c2})`; el.textContent=a.glyph; } } }
async function uploadPic(){
const f=document.getElementById('pfPic').files[0];
if(!f) return;
const fd=new FormData(); fd.append('file',f);
const pv=document.getElementById('pfPicPreview'); pv.textContent='Uploading…';
try{ const r=await fetch('/api/profile/pic',{method:'POST',headers:authHeaders(),body:fd}); const d=await r.json();
if(d.error){ pv.textContent=d.error; pv.style.color='#f87171'; return; }
myProfile=d; renderMyAvatar(); pv.textContent='✓ uploaded'; pv.style.color='#4ade80';
}catch(e){ pv.textContent='Upload failed'; pv.style.color='#f87171'; }
}
const INTAKE=[
{label:"My full legal name", q:"What's your full legal name (first, middle, last)?", p:"Jane Marie Doe"},
{label:"My mailing address", q:"What's your current mailing address (street, city, state, ZIP)?", p:"123 Main St, Everett, WA 98201"},
{label:"Spouse's full legal name", q:"What's your spouse's full legal name?", p:"John Robert Doe"},
{label:"Spouse's mailing address", q:"What's your spouse's mailing address?", p:"456 Oak Ave, Everett, WA 98201"},
{label:"Marriage date & place", q:"When and where did you get married (exact date + city/county/state)?", p:"June 15, 2011, Seattle, WA"},
{label:"Separation date", q:"When did you separate (exact date)?", p:"June 1, 2025"},
{label:"Case number", q:"Has a case been filed? What's the case number (if you know it)?", p:"Not yet / 24-3-01234-31"},
{label:"Children (names, ages, DOBs)", q:"List each child's full name, age, and date of birth.", p:"Emma Grace Doe, 12, born 01/15/2013; Liam James Doe, 9, born 03/22/2016"},
{label:"Current custody", q:"Where do the children live now, and what's the current schedule?", p:"They live with me; dad has them every other weekend"},
{label:"Home / real estate", q:"Do you own a home or other real estate? Address, when bought, whose name is on the title/mortgage?", p:"House at 123 Main St, bought 2012, both names on title"},
{label:"Assets", q:"What are your main assets? (bank accounts, retirement, vehicles, business, investments)", p:"Joint savings ~$20k, my 401k ~$85k, two cars"},
{label:"Debts", q:"What are your main debts? (mortgage, credit cards, loans)", p:"Mortgage $280k remaining, $9k credit card, $12k car loan"},
{label:"My income & employer", q:"What's your job, employer, and annual gross income?", p:"Registered nurse, Providence, $72k/yr"},
{label:"Spouse's income & employer", q:"What's your spouse's job, employer, and annual gross income?", p:"Software engineer, Amazon, $95k/yr"},
{label:"Children's health insurance", q:"Who provides health insurance for the children, and through which employer?", p:"My employer (Providence) covers the kids"},
{label:"Case status", q:"Has either of you filed for divorce yet, and what does each side want (custody, support, property)?", p:"Spouse filed; wants full custody and to keep the house"},
{label:"Safety / DV", q:"Is there any domestic violence, abuse, or safety concern? Any protection orders in place?", p:"No / Yes — details"},
{label:"Other cases", q:"Are there any other court cases involving you, your spouse, or the children (criminal, CPS, other family law)?", p:"None"},
{label:"Goals & worries", q:"What outcome matters most to you? What are you most worried about?", p:"Keeping the kids with me and keeping the house"},
];
let intakeIdx=0; const intakeAnswers={};
function openIntake(){ intakeIdx=0; for(const k in intakeAnswers) delete intakeAnswers[k]; closeModal('profileModal'); renderIntake(); document.getElementById('intakeModal').classList.add('open'); }
function renderIntake(){ const q=INTAKE[intakeIdx]; document.getElementById('intakeQ').textContent=(intakeIdx+1)+'. '+q.q; const a=document.getElementById('intakeA'); a.value=intakeAnswers[intakeIdx]||''; a.placeholder=q.p; document.getElementById('intakeBar').style.width=Math.round((intakeIdx/(INTAKE.length-1))*100)+'%'; document.getElementById('intakeBack').style.visibility=intakeIdx===0?'hidden':'visible'; document.getElementById('intakeNext').textContent=intakeIdx===INTAKE.length-1?'Finish ✓':'Next →'; }
function intakeNext(){ intakeAnswers[intakeIdx]=document.getElementById('intakeA').value.trim(); if(intakeIdx<INTAKE.length-1){ intakeIdx++; renderIntake(); } else { finishIntake(); } }
function intakePrev(){ intakeAnswers[intakeIdx]=document.getElementById('intakeA').value.trim(); if(intakeIdx>0){ intakeIdx--; renderIntake(); } }
function finishIntake(){
const parts=[]; INTAKE.forEach((q,i)=>{ const v=(intakeAnswers[i]||'').trim(); if(v) parts.push(q.label+': '+v); });
document.getElementById('pfAbout').value=parts.join('\n');
closeModal('intakeModal'); document.getElementById('profileModal').classList.add('open');
}
let myFiles=[], myCats=['Other'];
function escapeHtml(s){ return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function fmtSize(n){ return n>1048576?(n/1048576).toFixed(1)+' MB':n>1024?(n/1024).toFixed(0)+' KB':(n||0)+' B'; }
async function loadFiles(){
if(!token) return;
try{ const r=await fetch('/api/files',{headers:authHeaders()}); const d=await r.json(); myFiles=d.files||[]; myCats=d.categories||['Other'];
const sel=document.getElementById('fileCat'); if(sel){ sel.innerHTML=''; myCats.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }
renderFiles();
}catch(e){}
}
function renderFiles(){
const el=document.getElementById('fileList'); if(!el) return;
if(!myFiles.length){ el.innerHTML='<div class="sub">No documents yet — upload your petition, orders, agreements, and evidence.</div>'; return; }
const groups={}; myFiles.forEach(f=>{ (groups[f.category]=groups[f.category]||[]).push(f); });
el.innerHTML='';
Object.keys(groups).forEach(cat=>{
const h=document.createElement('div'); h.style.cssText='color:var(--gold);font-size:12px;font-weight:700;margin-top:10px'; h.textContent=cat+' ('+groups[cat].length+')'; el.appendChild(h);
groups[cat].forEach(f=>{
const it=document.createElement('div'); it.className='file-item';
const nm=document.createElement('span'); nm.className='nm'; nm.textContent=f.orig_name; it.appendChild(nm);
const sz=document.createElement('span'); sz.className='cat'; sz.textContent=fmtSize(f.size); it.appendChild(sz);
const v=document.createElement('button'); v.textContent='View'; v.onclick=()=>viewFile(f.id); it.appendChild(v);
const d=document.createElement('button'); d.textContent='✕'; d.onclick=()=>delFile(f.id); it.appendChild(d);
el.appendChild(it);
});
});
}
async function uploadFiles(){
const fs=document.getElementById('fileInput').files; if(!fs.length) return;
const cat=document.getElementById('fileCat').value;
for(const f of fs){ const fd=new FormData(); fd.append('file',f); fd.append('category',cat); try{ await fetch('/api/files',{method:'POST',headers:authHeaders(),body:fd}); }catch(e){} }
document.getElementById('fileInput').value='';
await loadFiles();
}
async function delFile(id){ try{ await fetch('/api/files/'+id,{method:'DELETE',headers:authHeaders()}); }catch(e){} await loadFiles(); }
async function viewFile(id){
const rec=myFiles.find(f=>f.id===id); if(!rec) return;
const ext=(rec.orig_name.split('.').pop()||'').toLowerCase();
document.getElementById('viewerTitle').textContent=rec.orig_name;
const body=document.getElementById('viewerBody'); body.innerHTML='<div class="sub">Loading…</div>';
document.getElementById('viewerModal').classList.add('open');
if(['png','jpg','jpeg','gif','webp','svg'].includes(ext)){
const r=await fetch('/api/files/'+id+'/raw',{headers:authHeaders()}); const b=await r.blob(); const u=URL.createObjectURL(b);
body.innerHTML='<img src="'+u+'" style="max-width:100%;border-radius:8px">';
} else if(ext==='pdf'){
const r=await fetch('/api/files/'+id+'/raw',{headers:authHeaders()}); const b=await r.blob(); const u=URL.createObjectURL(b);
body.innerHTML='<iframe src="'+u+'" style="width:100%;height:70vh;border:0;border-radius:8px"></iframe>';
} else {
let txt=''; try{ const r=await fetch('/api/files/'+id+'/text',{headers:authHeaders()}); const d=await r.json(); txt=d.text||''; }catch(e){}
body.innerHTML='<pre style="white-space:pre-wrap;font-family:inherit;font-size:13px;line-height:1.55">'+escapeHtml(txt||'(no text preview — this file type downloads instead)')+'</pre>';
}
}
function openProfile(){ document.getElementById('pfName').value=myProfile.display_name||''; document.getElementById('pfAbout').value=myProfile.about_me||''; renderAvatarSel(); document.getElementById('profileModal').classList.add('open'); loadFiles(); }
let allForms=[];
async function openFill(){
try{ const r=await fetch('/api/forms'); allForms=await r.json();
const sel=document.getElementById('fillFormSelect'); sel.innerHTML='';
allForms.forEach(f=>{ const o=document.createElement('option'); o.value=f.id; o.textContent=f.name; sel.appendChild(o); });
document.getElementById('fillFields').innerHTML='<div class="sub">Click "Auto-fill from my info" to pre-fill the fields.</div>';
document.getElementById('fillModal').classList.add('open');
}catch(e){}
}
async function autofillForm(){
const fid=document.getElementById('fillFormSelect').value;
const el=document.getElementById('fillFields'); el.innerHTML='<div class="sub">Filling…</div>';
const r=await fetch('/api/forms/autofill',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({form_id:fid})});
const d=await r.json();
const f=allForms.find(x=>x.id===fid);
el.innerHTML='';
(f.fields||[]).forEach(fd=>{
const row=document.createElement('div'); row.style.cssText='margin-bottom:8px';
const lb=document.createElement('label'); lb.style.cssText='font-size:12px;color:var(--muted)'; lb.textContent=fd.label; row.appendChild(lb);
const inp=document.createElement('input'); inp.dataset.key=fd.key; inp.value=(d.values||{})[fd.key]||''; row.appendChild(inp);
el.appendChild(row);
});
}
async function generateForm(){
const fid=document.getElementById('fillFormSelect').value;
const values={}; document.querySelectorAll('#fillFields input').forEach(i=>values[i.dataset.key]=i.value);
const r=await fetch('/api/forms/fill',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({form_id:fid,values})});
if(!r.ok){ alert('Could not generate form'); return; }
const blob=await r.blob(); const url=URL.createObjectURL(blob);
const a=document.createElement('a'); a.href=url; a.download=fid+'_filled.pdf'; document.body.appendChild(a); a.click(); a.remove();
}
async function saveProfile(){
const body={display_name:document.getElementById('pfName').value, about_me:document.getElementById('pfAbout').value, avatar:myProfile.avatar};
const r=await fetch('/api/profile',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
myProfile=await r.json(); renderMyAvatar(); closeModal('profileModal');
}
function openSettings(){
const s=mySettings; document.getElementById('twSid').value=s.twilio_sid||''; document.getElementById('twToken').value=''; document.getElementById('twFrom').value=s.twilio_from||''; document.getElementById('smHost').value=s.smtp_host||''; document.getElementById('smPort').value=s.smtp_port||587; document.getElementById('smUser').value=s.smtp_user||''; document.getElementById('emFrom').value=s.email_from||''; document.getElementById('ttsVoice').value=s.tts_voice||'aria'; document.getElementById('ncOrder').checked=!!s.no_contact_order; document.getElementById('ncName').value=s.partner_name||''; document.getElementById('dvFactor').checked=!!s.domestic_violence; document.getElementById('poOrder').checked=!!s.protection_order; document.getElementById('safetyNotes').value=s.safety_notes||'';
document.getElementById('settingsModal').classList.add('open');
}
async function saveSettings(){
const body={ twilio_sid:document.getElementById('twSid').value, twilio_from:document.getElementById('twFrom').value, smtp_host:document.getElementById('smHost').value, smtp_port:parseInt(document.getElementById('smPort').value||587), smtp_user:document.getElementById('smUser').value, email_from:document.getElementById('emFrom').value, tts_voice:document.getElementById('ttsVoice').value, no_contact_order:document.getElementById('ncOrder').checked?1:0, partner_name:document.getElementById('ncName').value, domestic_violence:document.getElementById('dvFactor').checked?1:0, protection_order:document.getElementById('poOrder').checked?1:0, safety_notes:document.getElementById('safetyNotes').value };
const tw=document.getElementById('twToken').value; if(tw) body.twilio_auth_token=tw;
const sp=document.getElementById('smPass').value; if(sp) body.smtp_pass=sp;
const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
mySettings=await r.json(); closeModal('settingsModal');
}
async function openDocs(){ const r=await fetch('/api/documents/types'); const types=await r.json(); const s=document.getElementById('docType'); s.innerHTML=''; types.forEach(t=>{ const o=document.createElement('option'); o.value=t.id; o.textContent=t.name+' ('+t.form+')'; s.appendChild(o); }); document.getElementById('docResult').style.display='none'; document.getElementById('docsModal').classList.add('open'); }
async function genDoc(){
document.getElementById('docBtn').disabled=true;
const body={doc_type:document.getElementById('docType').value, instructions:document.getElementById('docInst').value};
const r=await fetch('/api/documents',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
const d=await r.json(); document.getElementById('docBtn').disabled=false;
const el=document.getElementById('docResult'); el.style.display='block'; el.textContent=d.document||d.error;
}
function openMission(){ document.getElementById('missionModal').classList.add('open'); }
document.getElementById('msType').onchange=function(){ document.getElementById('msSubjectWrap').style.display=this.value==='email'?'block':'none'; };
async function doMission(){
const type=document.getElementById('msType').value, recipient=document.getElementById('msRecipient').value;
const body={type,recipient,message:document.getElementById('msBody').value,subject:document.getElementById('msSubject').value,body:document.getElementById('msBody').value};
const r=await fetch('/api/mission',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
const d=await r.json(); document.getElementById('msStatus').textContent=d.ok?'✓ Sent.':('✗ '+(d.error||'Failed')); document.getElementById('msStatus').style.color=d.ok?'#4ade80':'#fca5a5';
}
document.getElementById('sendBtn').onclick=send;
document.getElementById('input').addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();} });
async function boot(){
if(!token){ window.location.href='/'; return; }
try{ const r=await fetch('/api/me',{headers:authHeaders()}); if(!r.ok){ window.location.href='/'; return; } const d=await r.json(); myProfile=d.profile; mySettings=d.settings; }catch(e){}
await loadAvatars(); renderMyAvatar(); renderGrid(); renderChips(); selectAgent(active,false);
// populate tts voices
try{ const vr=await fetch('/api/voices'); const vd=await vr.json(); const s=document.getElementById('ttsVoice'); s.innerHTML=''; vd.voices.forEach(v=>{ const o=document.createElement('option'); o.value=v.id; o.textContent=v.label; s.appendChild(o); }); s.value=mySettings.tts_voice||'aria'; }catch(e){}
}
boot();
</script>
</body>
</html>