Astraea v2: auth+profiles+About-Me, documents, TTS, Twilio/SMTP missions, DV safety, conversation memory, FLUX landing

This commit is contained in:
drjones
2026-09-05 08:04:35 -07:00
parent 913edc0951
commit b075699cb6
11 changed files with 1570 additions and 473 deletions

View File

@@ -3,406 +3,333 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Astraea — Washington Family Law, Decoded</title>
<meta name="description" content="A constellation of Washington State family-law specialist agents. Ask about divorce, custody, child support, property, and protection orders.">
<title>Astraea — Your Attorney</title>
<style>
:root {
--bg: #070b1a;
--bg2: #0b1228;
--panel: #101832;
--panel2: #141d3d;
--line: #233055;
--text: #e8edfb;
--muted: #8b98c4;
--accent: #7dd3fc;
--gold: #f5d06f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: radial-gradient(1200px 700px at 50% -10%, #18254f 0%, var(--bg2) 45%, var(--bg) 100%);
color: var(--text);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
#stars { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
: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; }
.wrap { position: relative; z-index: 1; max-width: 1120px; margin: 0 auto; padding: 0 22px 80px; }
.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; }
header {
padding: 46px 0 8px;
display: flex; align-items: center; justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 14px; }
.logo {
width: 46px; height: 46px; border-radius: 13px;
background: linear-gradient(135deg, #6ea8ff, #a78bfa);
display: grid; place-items: center; font-size: 26px;
box-shadow: 0 0 26px rgba(124, 155, 255, .55);
}
.brand h1 { font-size: 26px; font-weight: 700; letter-spacing: .4px; }
.brand h1 span { color: var(--gold); }
.brand .sub { color: var(--muted); font-size: 13px; margin-top: 2px; letter-spacing: .3px; }
.pill {
font-size: 12px; color: var(--muted); border: 1px solid var(--line);
padding: 6px 12px; border-radius: 999px; display: inline-flex; align-items: center; gap: 7px;
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 10px #4ade80; }
.hero { padding: 26px 0 34px; max-width: 720px; }
.hero h2 { font-size: clamp(28px, 5vw, 46px); line-height: 1.12; font-weight: 800; letter-spacing: -.5px; }
.hero h2 .grad {
background: linear-gradient(90deg, #7dd3fc, #a78bfa, #f0abfc);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.hero p { color: var(--muted); font-size: 16.5px; line-height: 1.6; margin-top: 14px; max-width: 640px; }
.section-label { font-size: 12px; text-transform: uppercase; letter-spacing: 2px; color: var(--muted); margin: 10px 0 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.card {
background: linear-gradient(180deg, var(--panel2), var(--panel));
border: 1px solid var(--line); border-radius: 16px; padding: 18px 18px 16px;
cursor: pointer; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
position: relative; overflow: hidden;
}
.card::before {
content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
background: var(--c, var(--accent)); opacity: .85;
}
.card:hover { transform: translateY(-3px); border-color: var(--c, var(--accent)); box-shadow: 0 14px 34px rgba(0,0,0,.4); }
.card.active { border-color: var(--c, var(--accent)); box-shadow: 0 0 0 1px var(--c, var(--accent)), 0 16px 40px rgba(0,0,0,.5); }
.card .emoji { font-size: 30px; line-height: 1; }
.card h3 { font-size: 16.5px; font-weight: 700; margin: 12px 0 3px; }
.card .tag { font-size: 12px; color: var(--c, var(--accent)); font-weight: 600; margin-bottom: 8px; }
.card p { font-size: 13px; color: var(--muted); line-height: 1.5; }
.chat-panel {
margin-top: 38px;
background: linear-gradient(180deg, var(--panel2), var(--panel));
border: 1px solid var(--line); border-radius: 20px;
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 30px 70px rgba(0,0,0,.45);
}
.chat-head {
padding: 16px 20px; border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 13px;
}
.chat-head .emoji { font-size: 26px; }
.chat-head .who { font-weight: 700; font-size: 15.5px; }
.chat-head .who .scope { color: var(--muted); font-weight: 500; font-size: 12.5px; margin-top: 1px; }
.chips { margin-left: auto; display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }
.chip {
font-size: 12px; padding: 6px 11px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--line); color: var(--muted); background: transparent;
transition: all .15s ease; white-space: nowrap;
}
.chip:hover { color: var(--text); border-color: var(--muted); }
.chip.on { color: #06121f; background: var(--c, var(--accent)); border-color: var(--c, var(--accent)); font-weight: 700; }
.messages { padding: 22px; min-height: 240px; max-height: 520px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
.msg { max-width: 78%; display: flex; flex-direction: column; }
.msg.user { align-self: flex-end; align-items: flex-end; }
.msg.agent { align-self: flex-start; align-items: flex-start; }
.bubble {
padding: 13px 16px; border-radius: 16px; font-size: 15px; 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: 11.5px; color: var(--muted); margin: 0 4px 5px; }
.citations { margin-top: 10px; width: 100%; }
.cite-toggle {
font-size: 12px; color: var(--gold); cursor: pointer; border: none; background: none;
display: inline-flex; align-items: center; gap: 5px; padding: 4px 2px;
}
.cite-list { margin-top: 8px; display: none; flex-direction: column; gap: 8px; }
.cite-list.open { display: flex; }
.cite {
background: #0c142e; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
font-size: 12.5px; color: var(--muted);
}
.cite b { color: var(--text); display: block; margin-bottom: 3px; font-size: 13px; }
.cite .src { color: var(--gold); font-size: 11px; }
.typing { display: inline-flex; gap: 5px; align-items: center; padding: 6px 2px; }
.typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); animation: blink 1.2s infinite; }
.typing i:nth-child(2) { animation-delay: .2s; }
.typing i:nth-child(3) { animation-delay: .4s; }
@keyframes blink { 0%,100% { opacity: .25; } 50% { opacity: 1; } }
.composer { border-top: 1px solid var(--line); padding: 14px 16px; display: flex; gap: 10px; align-items: flex-end; }
.composer textarea {
flex: 1; resize: none; background: #0c142e; border: 1px solid var(--line); color: var(--text);
border-radius: 12px; padding: 12px 14px; font-size: 15px; font-family: inherit; line-height: 1.5;
max-height: 140px; outline: none; transition: border-color .15s ease;
}
.composer textarea:focus { border-color: var(--c, var(--accent)); }
.send {
border: none; border-radius: 12px; padding: 13px 20px; font-size: 15px; font-weight: 700;
color: #06121f; background: var(--c, var(--accent)); cursor: pointer; transition: transform .1s ease, opacity .2s;
}
.send:active { transform: scale(.96); }
.send:disabled { opacity: .45; cursor: default; }
.hint { font-size: 11.5px; color: var(--muted); text-align: center; padding: 0 22px 14px; }
.disclaimer {
margin-top: 26px; font-size: 12.5px; color: var(--muted); text-align: center; line-height: 1.6;
border-top: 1px solid var(--line); padding-top: 20px;
}
footer { margin-top: 20px; text-align: center; }
.bmac {
display: inline-flex; align-items: center; gap: 9px; color: var(--text); text-decoration: none;
background: var(--panel2); border: 1px solid var(--line); padding: 10px 18px; border-radius: 999px;
font-size: 13.5px; font-weight: 600; transition: all .15s ease;
}
.bmac:hover { border-color: var(--gold); box-shadow: 0 0 18px rgba(245,208,111,.25); }
.bmac .cup { font-size: 17px; }
#mascot {
position: fixed; z-index: 2; pointer-events: none; will-change: transform;
font-size: 24px; filter: drop-shadow(0 0 8px rgba(255,220,150,.8)); opacity: .9;
}
@media (max-width: 640px) {
.chips { margin-left: 0; width: 100%; justify-content: flex-start; }
.chat-head { flex-wrap: wrap; }
.msg { max-width: 92%; }
}
.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); }
.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);} }
</style>
</head>
<body>
<canvas id="stars"></canvas>
<div id="mascot">✦</div>
<div class="wrap">
<div class="app">
<header>
<div class="brand">
<div class="logo">⚖️</div>
<div>
<h1>ASTRA<span>EA</span></h1>
<div class="sub">Washington Family Law, decoded by specialist agents</div>
</div>
<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()">📄 Documents</button>
<button class="btn" onclick="openSettings()">⚙️ Settings</button>
<div class="avatar-sm" id="myAvatar" onclick="openProfile()" style="background:linear-gradient(135deg,#6ea8ff,#a78bfa)">⚖</div>
</div>
<div class="pill"><span class="dot"></span> <span id="statusText">Engines online</span></div>
</header>
<section class="hero">
<h2>Eight specialists.<br>One <span class="grad">constellation</span> of Washington family law.</h2>
<p>Ask a divorce, custody, child-support, property, or protection-order question. Each specialist answers from the actual Revised Code of Washington — with citations — not from thin air.</p>
</section>
<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="section-label">Choose your specialist</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="who"><div class="scope" id="headScope">Where to start</div></div>
</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="openMission()">📨 Send on my behalf</button>
<button class="btn" onclick="clearConv()">🗑 Clear chat</button>
</div>
<div class="composer">
<textarea id="input" rows="1" placeholder="Ask a Washington family-law question…"></textarea>
<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>
<div class="hint">Answers cite the Revised Code of Washington. This is legal information, not advice.</div>
</section>
<div class="disclaimer">{{ disclaimer }}</div>
<footer>
<a class="bmac" href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener">
<span class="cup">☕</span> Support Astraea
</a>
</footer>
<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>About Me — everything your attorney should know (your story, dates, names, what you're going through)</label><textarea id="pfAbout" placeholder="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>
<button class="btn primary" style="width:100%" onclick="saveProfile()">Save profile</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>
const AGENTS = {{ agents | tojson }};
const DISCLAIMER = {{ disclaimer | tojson }};
// ---- Starfield + cursor mascot ----
const cvs = document.getElementById('stars');
const ctx = cvs.getContext('2d');
let stars = [];
function sizeCanvas() {
cvs.width = window.innerWidth; cvs.height = window.innerHeight;
stars = Array.from({length: 130}, () => ({
x: Math.random() * cvs.width, y: Math.random() * cvs.height,
r: Math.random() * 1.4 + .3, tw: Math.random() * Math.PI * 2,
sp: Math.random() * .02 + .005
}));
}
sizeCanvas();
window.addEventListener('resize', sizeCanvas);
function drawStars() {
ctx.clearRect(0, 0, cvs.width, cvs.height);
for (const s of stars) {
s.tw += s.sp;
const a = .35 + Math.abs(Math.sin(s.tw)) * .65;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(190,210,255,${a})`; ctx.fill();
}
requestAnimationFrame(drawStars);
}
drawStars();
const mascot = document.getElementById('mascot');
let mx = innerWidth/2, my = innerHeight/3, cx = mx, cy = my;
addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; });
(function follow() {
cx += (mx - cx) * .08; cy += (my - cy) * .08;
mascot.style.transform = `translate(${cx - 8}px, ${cy - 30}px) rotate(${Math.sin(Date.now()/700)*20}deg)`;
requestAnimationFrame(follow);
})();
// ---- App state ----
const grid = document.getElementById('grid');
const chips = document.getElementById('chips');
const messages = document.getElementById('messages');
const input = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const statusText = document.getElementById('statusText');
const token = localStorage.getItem('astraea_token') || '';
let active = AGENTS[0].id;
const history = {}; // per-agent turn history
let avatars = [];
let myProfile = {display_name:'', avatar:0, about_me:''};
let mySettings = {};
const history = {};
AGENTS.forEach(a => history[a.id] = []);
function accent(id) { const a = AGENTS.find(x => x.id === id); return a ? a.accent : '#7dd3fc'; }
// 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 renderGrid() {
grid.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);
grid.appendChild(c);
});
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() {
chips.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);
chips.appendChild(b);
});
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); });
}
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;
document.getElementById('chatPanel').style.setProperty('--c', a.accent);
document.getElementById('chatPanel').style.borderColor = a.accent + '55';
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();
messages.innerHTML = '';
renderHistory();
if (scroll) document.getElementById('chatPanel').scrollIntoView({behavior: 'smooth', block: 'start'});
input.focus();
}
function renderHistory() {
const h = history[active] || [];
h.forEach(t => addBubble(t.role, t.content, t.citations));
}
function addBubble(role, text, citations) {
const m = document.createElement('div');
m.className = 'msg ' + role;
const label = document.createElement('div');
label.className = 'who-label';
label.textContent = role === 'user' ? 'You' : (AGENTS.find(x => x.id === active)?.name || 'Agent');
const b = document.createElement('div');
b.className = 'bubble';
b.textContent = text;
m.appendChild(label); m.appendChild(b);
if (role === 'assistant' && citations && citations.length) {
const cw = document.createElement('div'); cw.className = 'citations';
const t = document.createElement('button'); t.className = 'cite-toggle';
t.innerHTML = '🔗 ' + citations.length + ' source' + (citations.length > 1 ? 's' : '');
const list = document.createElement('div'); list.className = 'cite-list';
citations.forEach(c => {
const item = document.createElement('div'); item.className = 'cite';
item.innerHTML = `<b>${c.title}</b><div>${c.snippet}</div><div class="src">${c.source}</div>`;
list.appendChild(item);
});
t.onclick = () => list.classList.toggle('open');
cw.appendChild(t); cw.appendChild(list);
m.appendChild(cw);
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){}
}
messages.appendChild(m);
messages.scrollTop = messages.scrollHeight;
(history[id]||[]).forEach(t=>addBubble(t.role,t.content,t.citations));
if(scroll) cp.scrollIntoView({behavior:'smooth',block:'start'});
}
function typingIndicator(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); messages.appendChild(m);
messages.scrollTop = messages.scrollHeight;
async function clearConv(){
if(token){ try{ await fetch('/api/conversation/'+active,{method:'DELETE',headers:authHeaders()}); }catch(e){} }
history[active]=[]; document.getElementById('messages').innerHTML='';
}
async function send() {
const text = input.value.trim();
if (!text) return;
input.value = ''; input.style.height = 'auto';
addBubble('user', text);
history[active].push({role: 'user', content: text});
typingIndicator(true); sendBtn.disabled = true;
try {
const r = await fetch('/api/chat', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({agent_id: active, message: text, history: history[active].slice(0, -1)})
});
const d = await r.json();
typingIndicator(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. Please try again.', null);
}
} catch (e) {
typingIndicator(false);
addBubble('assistant', 'Could not reach the legal engine. Please try again.', null);
} finally {
sendBtn.disabled = false; input.focus();
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){}
}
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',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({agent_id:active,message:t,history:history[active].slice(0,-1)})});
const d=await r.json(); 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);
}catch(e){ typing(false); addBubble('assistant','Could not reach the engine.',null); }
finally{ document.getElementById('sendBtn').disabled=false; }
}
sendBtn.onclick = send;
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
input.addEventListener('input', () => {
input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px';
});
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 a=avatars[myProfile.avatar]||avatars[0]; const el=document.getElementById('myAvatar'); if(a){ el.style.background=`linear-gradient(135deg,${a.c1},${a.c2})`; el.textContent=a.glyph; } }
function openProfile(){ document.getElementById('pfName').value=myProfile.display_name||''; document.getElementById('pfAbout').value=myProfile.about_me||''; renderAvatarSel(); document.getElementById('profileModal').classList.add('open'); }
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';
}
// ---- Boot ----
renderGrid(); renderChips(); selectAgent(active, false);
fetch('/api/health').then(r => r.json()).then(d => {
if (d.ok) {
statusText.textContent = d.index.ready
? `${d.agent_count} agents · ${d.index.chunks} law passages indexed`
: 'Indexing law library…';
}
}).catch(() => { statusText.textContent = 'Engine warming up'; });
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>
<!-- Umami analytics beacon -->
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="c8c2f69c-4448-40ab-9848-02179f10c001"></script>
</body>
</html>