Public snapshot

This commit is contained in:
Doxa Maps 2026-09-24 02:15:12 +00:00
commit deae43079e
7 changed files with 1599 additions and 0 deletions

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# quantum-forms-dist
PUBLIC built bundle of quantum-forms (CDN artifact only — source is private). CI publishes dist/ here; the plugin-CDN serves it.

355
assembly.html Normal file
View file

@ -0,0 +1,355 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<title>Quantum Forms — Q&amp;A Session</title>
<style>
/* ★ THEME TOKENS — dual light/dark (Tailwind-style token layer, no CDN). Toggle sets [data-theme] on <html>. */
:root, html[data-theme="dark"] {
--bg:#0e1013; --header:#16181d; --surface:#1e2127; --input:#0a0b0d; --fg:#eef1f4; --muted:#8b929c;
--line:#2a2e35; --accent:#4f9bf0; --accent-2:#7c6cf0; --ok:#48bb78; --danger:#e05252;
--grad:linear-gradient(135deg,#4f9bf0,#7c6cf0); --grad-soft:linear-gradient(135deg,rgba(79,155,240,.14),rgba(124,108,240,.14));
--clock-bg:#eef1f4; --clock-fg:#0e1013;
}
html[data-theme="light"] {
--bg:#f6f7f9; --header:#ffffff; --surface:#ffffff; --input:#ffffff; --fg:#141821; --muted:#5b636e;
--line:#e2e5ea; --accent:#2563eb; --accent-2:#7c3aed; --ok:#16a34a; --danger:#dc2626;
--grad:linear-gradient(135deg,#2563eb,#7c3aed); --grad-soft:linear-gradient(135deg,rgba(37,99,235,.08),rgba(124,58,237,.08));
--clock-bg:#141821; --clock-fg:#ffffff;
}
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { margin:0; padding:0; height:100%; overflow:hidden; }
#app { height:100dvh; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,Roboto,sans-serif;
display:flex; flex-direction:column; overflow:hidden; }
/* ★ SLIM mobile header (Driver: kill the white gap, ~44px bar). Left = title + neon E2E subheader; right = a
digital-clock countdown with tiny "Round N of M" + total under it. */
header { background:var(--header); background-image:var(--grad-soft); border-bottom:1px solid var(--line);
padding:3px 10px 3px; flex:0 0 auto;
display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:0; }
/* ★ FEATURE FLAG: the BROWSER (mobile web) needs NO top gap — the browser handles the status bar. Only a NATIVE
app wrapper (which can render under the camera/notch) needs the safe-area inset. Add .native-app on <html> to
re-enable the gap for the packaged mobile app; default (browser) = thin, no wasted space at the top. */
html.native-app header { padding-top:calc(3px + env(safe-area-inset-top)); }
.h-left { display:flex; flex-direction:column; min-width:0; }
.h-title { font-weight:700; font-size:.95rem; line-height:1.15; }
/* the E2E badge — uses the theme accent so it's visible in BOTH modes (was hardcoded teal, invisible on light) */
.h-neon { font-size:.6rem; letter-spacing:.4px; color:var(--accent); font-weight:600; line-height:1.2;
cursor:pointer; text-decoration:underline dotted; }
/* trust timeline panel (square, thin-line aesthetic) */
.trust-overlay { position:fixed; inset:0; background:rgba(0,0,0,.7); z-index:50; display:flex; align-items:flex-end;
justify-content:center; }
.trust-modal { background:var(--bg); border:1px solid var(--line); width:100%; max-width:520px; max-height:86dvh;
overflow-y:auto; padding:0; }
.trust-head { display:flex; justify-content:space-between; align-items:center; padding:12px 14px;
border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--header); }
.trust-x { background:none; border:0; color:var(--muted); font-size:1.5rem; cursor:pointer; line-height:1; }
.trust-live { margin:12px 14px; padding:9px 11px; border:1px solid; font-size:.78rem; line-height:1.4; }
.trust-timeline { padding:4px 14px 12px; }
.tl-node { display:flex; gap:10px; align-items:flex-start; }
.tl-body b { font-size:.9rem; } .tl-sub { display:block; font-size:.76rem; color:var(--muted); margin-top:2px; line-height:1.45; }
.tl-line { display:flex; gap:10px; padding-left:0; }
.tl-linebar { width:20px; display:flex; justify-content:center; }
.tl-linebar::before { content:''; width:2px; background:linear-gradient(#5eead4,#63b3ed); margin:2px 0; }
.tl-hop { flex:1; border-left:2px solid var(--line); margin-left:9px; padding:6px 0 10px 12px; }
.tl-proto { font-weight:700; font-size:.82rem; color:#5eead4; }
.tl-what { font-size:.76rem; color:var(--fg); margin-top:3px; line-height:1.45; }
.tl-meta { font-size:.72rem; color:var(--muted); margin-top:4px; }
.tl-link { display:inline-block; margin-top:6px; font-size:.75rem; color:var(--accent); }
.trust-foot { padding:10px 14px; border-top:1px solid var(--line); font-size:.72rem; color:var(--muted); line-height:1.5; }
.gear-btn { background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; flex:0 0 auto; order:2; }
.gear-btn:hover { color:var(--fg); }
.h-right { display:flex; flex-direction:column; align-items:flex-end; flex:0 0 auto; gap:1px; order:1; }
/* settings sheet */
.set-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:60; display:flex; align-items:flex-end; justify-content:center; }
.set-sheet { background:var(--header); color:var(--fg); border:1px solid var(--line); border-radius:10px 10px 0 0; width:100%; max-width:520px; max-height:80dvh; overflow-y:auto; }
.set-head { display:flex; justify-content:space-between; align-items:center; padding:11px 14px; border-bottom:1px solid var(--line); font-weight:700; }
.set-x { background:none; border:0; color:var(--muted); font-size:1.4rem; cursor:pointer; line-height:1; }
.set-body { padding:6px 16px 16px; }
.set-group { font-size:.66rem; text-transform:uppercase; letter-spacing:1.2px; color:var(--muted); font-weight:700;
margin:16px 0 4px; }
.set-group:first-child { margin-top:8px; }
.set-row { display:flex; justify-content:space-between; align-items:center; gap:14px; padding:12px 0;
border-bottom:1px solid var(--line); font-size:.9rem; }
.set-row:last-child { border-bottom:0; }
.set-row .lbl { min-width:0; } .set-row .lbl b { display:block; margin-bottom:2px; }
.set-row .lbl span { font-size:.76rem; color:var(--muted); line-height:1.4; }
.set-toggle { width:44px; height:24px; border-radius:12px; border:1px solid var(--line); background:var(--input); position:relative; cursor:pointer; flex:0 0 auto; }
.set-toggle::after { content:''; position:absolute; top:2px; left:2px; width:18px; height:18px; border-radius:50%; background:var(--muted); transition:.15s; }
.set-toggle.on { border-color:var(--accent); } .set-toggle.on::after { left:22px; background:var(--accent); }
/* ★ SQUARE DIGITAL ALARM CLOCK — a square box, inverse of the theme (white box on dark mode, dark digits),
with a digital 7-seg-style font. On dark mode: white/light box, dark digital text. */
.alarm-clock { background:var(--clock-bg); border:1px solid var(--clock-bg); border-radius:4px; padding:1px 7px; display:inline-flex; }
.digi-timer { font-family:"Courier New",ui-monospace,monospace; font-weight:700; font-size:1rem; letter-spacing:2px; color:var(--clock-fg); line-height:1.2; font-variant-numeric:tabular-nums; }
.round-line { font-size:.62rem; color:var(--accent); font-weight:700; line-height:1.15; }
.total-line { font-size:.55rem; color:var(--muted); line-height:1.1; }
/* light mode: box goes dark, digits go light (the inverse) */
/* alarm clock is token-driven now */
/* name + avatar preview on the join gate */
.name-row { display:flex; align-items:center; gap:8px; margin-top:8px; }
.name-row #name { flex:1; margin-top:0; }
.name-row .ident { border-radius:6px; flex:0 0 auto; }
#gate .hint { font-size:.72rem; color:var(--muted); margin:6px 0 0; }
.ident { border-radius:5px; flex:0 0 auto; }
.r-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mod-tag { font-size:.6rem; background:var(--accent); color:#fff; padding:1px 5px; border-radius:6px; flex:0 0 auto; }
/* ★ agent role badge + the GREEN CHECK (submitted = ready) in the roster */
.role-tag { font-size:.55rem; text-transform:uppercase; letter-spacing:.5px; background:var(--accent-2); color:#fff; padding:1px 5px; border-radius:6px; flex:0 0 auto; }
.r-item { display:flex; align-items:center; gap:6px; padding:4px 0; }
/* ★ (Driver) ready = a small green CHECK BADGE overlaid on the corner of the avatar image (not a standalone check) */
.avatar-wrap { position:relative; display:inline-flex; flex:0 0 auto; }
.avatar-ready { position:absolute; right:-3px; bottom:-3px; width:14px; height:14px; border-radius:50%;
background:var(--ok); color:#fff; font-size:.62rem; font-weight:800; line-height:14px; text-align:center;
border:2px solid var(--bg); box-shadow:0 0 0 1px var(--ok); }
/* join gate */
#gate { flex:1; display:flex; align-items:center; justify-content:center; padding:16px; }
#gate .box { width:100%; max-width:360px; text-align:center; }
#gate input { width:100%; padding:9px; border-radius:6px; border:1px solid var(--line); background:var(--input);
color:var(--fg); font-size:1rem; margin-top:8px; }
#gate input.code { letter-spacing:3px; text-transform:uppercase; text-align:center; }
#gate button { width:100%; margin-top:12px; padding:9px; border:0; border-radius:6px; background:var(--grad);
color:#fff; font-weight:600; cursor:pointer; }
/* ── the layout: TOP = questions-as-a-form (full width) · BOTTOM = who's-here + tabbed side panel ── */
#session { flex:1; display:none; grid-template-rows:1.35fr 1fr; grid-template-columns:1fr 1fr;
grid-template-areas: "questions questions" "roster sidePanel"; gap:2px; background:var(--line); min-height:0; }
/* ★ (Driver) solid divider lines between the sections — a clear border on each pane so the edges read as distinct. */
.pane { background:var(--bg); overflow-y:auto; padding:0 8px 8px; min-height:0; border:1px solid var(--line); } /* slightly more padding (Driver) */
#questions { border-bottom:2px solid var(--line); } /* the questions section gets a solid line under it */
.pane h3 { margin:0 -8px 6px; padding:5px 8px 4px; font-size:.68rem; text-transform:uppercase; letter-spacing:1px;
color:var(--muted); position:sticky; top:0; background:var(--header); border-bottom:1px solid var(--line); z-index:2; }
#questions { grid-area:questions; }
#roster { grid-area:roster; }
#sidePanel { grid-area:sidePanel; display:flex; flex-direction:column; padding:0; }
/* ── the tabbed side panel (Chat | Pending Decisions) ── */
.side-tabs { display:flex; position:sticky; top:0; z-index:2; background:var(--header); border-bottom:1px solid var(--line); }
/* ★ Fix #3 (Driver): tabs must NOT word-wrap, and the tab row is the SAME HEIGHT as the "Who's here" h3 header
(font-size + padding match .pane h3: .68rem, 5px 8px 4px). One line each. */
/* ★ (Driver) tabs size to their CONTENT — Chat isn't stretched to half the row; each tab is only as wide as its
label needs, sitting left-aligned. One line, same height as the "Who's here" h3. */
.side-tab { flex:0 0 auto; padding:5px 10px 4px; font-size:.68rem; text-transform:uppercase; letter-spacing:1px;
white-space:nowrap; background:transparent; color:var(--muted); border:none;
border-bottom:2px solid transparent; cursor:pointer; }
.side-tab.active { color:var(--fg); border-bottom-color:var(--accent); font-weight:600; }
.side-body { flex:1; display:flex; flex-direction:column; min-height:0; padding:6px 8px 8px; }
/* chat */
#chatLog { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:1px; padding-bottom:4px; } /* tighter line spacing (Driver) */
/* ★ (Driver) smaller chat text + ALTERNATING row shading so each message is visually separated (like most chat apps) */
.chat-msg { font-size:.72rem; line-height:1.25; padding:2px 7px; border-radius:3px; } /* dense, tight line spacing — more use of space */
.chat-msg:nth-child(odd) { background:var(--surface); } /* every other message gets a subtle shade */
.chat-msg:nth-child(even) { background:transparent; }
.chat-msg .chat-name { font-weight:600; color:var(--accent); margin-right:5px; }
.chat-msg.mine .chat-name { color:var(--ok); }
/* ★ Fix #1 (Driver): the chat input is a FULL-WIDTH bar docked at the BOTTOM of the screen, riding right above the
keyboard (like a messaging app). The viewport meta uses interactive-widget=resizes-content, so when the keyboard
opens the layout shrinks and this bottom-fixed bar sits directly on top of the keyboard. Full screen width. */
/* ★ Fix #4 (Driver): the typing window DOCKS at the bottom of the panel and must NOT overlap the messages. It's the
last flex item in #chatBody (flex-column); #chatLog (flex:1) scrolls above it → no overlap, no floating. */
.chat-input-row { flex:0 0 auto; display:flex; gap:6px; margin-top:auto;
padding:8px calc(4px + env(safe-area-inset-left)) calc(8px + env(safe-area-inset-bottom));
background:var(--header); border-top:1px solid var(--line); }
/* ★ Fix #2 (Driver): NO send button — the input is FULL WIDTH; you submit with the keyboard's own send/enter key
(enterkeyhint="send" makes mobile keyboards show a Send key). Gray placeholder tells you what to type when empty. */
#chatInput { flex:1; width:100%; padding:9px 11px; font-size:.9rem; background:var(--input); color:var(--fg);
border:1px solid var(--line); border-radius:6px; } /* slim, slightly rounded */
#chatInput::placeholder { color:var(--muted); } /* gray hint text when empty */
/* ★ Fix #5 (Driver): when the chat input is FOCUSED, the bar breaks out to FULL SCREEN WIDTH, fixed above the
keyboard (not confined to the little bottom-right panel). On blur it collapses back into the panel. Slim. */
#app.kb-open .chat-input-row { position:fixed; left:0; right:0; bottom:0; z-index:45; padding:8px 10px calc(8px + env(safe-area-inset-bottom)); }
#app.kb-open #chatInput { font-size:1rem; padding:11px 13px; } /* comfortable to type while expanded */
/* ★ Fix (Driver): when the input is FIXED (kb-open), it's out of flow → it OVERLAPPED the chat log + roster so you
couldn't scroll to the bottom. Reserve space by padding the scrollable areas so their content clears the bar. */
#app.kb-open #chatLog { padding-bottom:64px; }
#app.kb-open #rList { padding-bottom:64px; }
#app.kb-open #qList { padding-bottom:64px; }
/* chat log scrolls above the docked input; flex:1 already set below. no padding hack needed now (no overlap). */
/* decisions */
#decisionsList { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:5px; }
.decision-row { display:flex; justify-content:space-between; align-items:center; padding:8px; background:var(--surface);
border:1px solid var(--line); border-radius:4px; cursor:pointer; font-size:.86rem; }
.decision-row:hover { border-color:var(--accent); }
.decision-status { font-size:.68rem; text-transform:uppercase; letter-spacing:.5px; padding:2px 7px; border-radius:3px; }
.decision-status.solved { background:rgba(72,187,120,.2); color:var(--ok); }
.decision-status.open { background:rgba(237,137,54,.2); color:var(--danger); }
/* the centered mobile-friendly decision modal */
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center;
justify-content:center; z-index:50; padding:16px; }
.modal-card { background:var(--header); color:var(--fg); border:1px solid var(--line); border-radius:10px;
width:100%; max-width:440px; max-height:85vh; overflow-y:auto; padding:16px; }
.modal-head { display:flex; justify-content:space-between; align-items:center; font-weight:600; margin-bottom:8px; }
.modal-x { background:none; border:none; color:var(--muted); font-size:1rem; cursor:pointer; }
.modal-desc { font-size:.9rem; color:var(--muted); margin-bottom:10px; }
.modal-answers { margin-bottom:10px; font-size:.86rem; }
.modal-answers summary { cursor:pointer; color:var(--accent); }
#dmTake { width:100%; padding:8px; background:var(--input); color:var(--fg); border:1px solid var(--line);
border-radius:4px; font-family:inherit; margin-bottom:8px; }
#dmSubmit { width:100%; padding:9px; background:var(--accent); color:var(--bg); border:none; border-radius:4px; cursor:pointer; }
/* skribbl-style "submitted" pills above the questions */
.answered-bar { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:8px; }
.answered-pill { font-size:.72rem; background:rgba(72,187,120,.2); color:var(--ok); border:1px solid var(--ok);
border-radius:5px; padding:2px 9px; }
.q-item { background:var(--surface); border-radius:5px; padding:6px 8px; margin-bottom:4px; font-size:.92rem;
display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }
.q-item .q-num { color:var(--accent); font-weight:700; margin-right:6px; }
/* ★ the answer textarea directly under its question (form layout): full width, 1 row tall by default, resizable,
slightly rounded (not bubbly) per the moderator's aesthetic. */
.q-answer { margin:0 0 8px; }
.q-answer-box { width:100%; box-sizing:border-box; padding:8px; font-family:inherit; font-size:.9rem; resize:vertical;
background:var(--input); color:var(--fg); border:1px solid var(--line); border-radius:4px; min-height:38px; }
.q-answer-box:focus { outline:none; border-color:var(--accent); }
.notes-block { margin-top:8px; border-top:1px dashed var(--line); padding-top:8px; }
.notes-label { font-size:.72rem; color:var(--muted); margin-bottom:3px; }
.q-vote { display:flex; gap:4px; flex:0 0 auto; }
.vote-btn { border:1px solid var(--line); background:var(--input); border-radius:5px; padding:2px 5px; font-size:.95rem;
cursor:pointer; opacity:.85; color:var(--fg); } /* color:var(--fg) so the SVG (currentColor) is VISIBLE */
.vote-btn svg { fill:currentColor; }
.vote-btn.active.up { opacity:1; border-color:var(--ok); background:rgba(72,187,120,.18); color:var(--ok); }
.vote-btn.active.down { opacity:1; border-color:#e05252; background:rgba(224,82,82,.18); color:#e05252; }
.vote-btn.spk { opacity:.8; }
/* "Play all questions" — reads them aloud on-device (TTS, no cloud) */
.play-all { width:100%; margin-bottom:4px; padding:8px; border:1px solid var(--accent); border-radius:5px;
background:rgba(49,130,206,.12); color:var(--accent); font-weight:600; font-size:.85rem; cursor:pointer;
display:flex; align-items:center; justify-content:center; gap:6px; }
.vote-btn svg, .play-all svg { display:block; }
/* lightweight Talk/Type tabs */
.mode-tabs { display:flex; gap:4px; margin-bottom:4px; }
.mode-tab { flex:1; padding:4px; border:1px solid var(--line); background:var(--input); color:var(--muted);
border-radius:0; font-size:.8rem; font-weight:600; cursor:pointer; }
.mode-tab.active { background:var(--grad); color:#fff; border-color:var(--accent); }
/* talk-mode mic panel (streaming transcription indicator + live text) */
.talk-panel { display:flex; flex-direction:column; gap:8px; align-items:center; padding:14px 8px; }
.talk-mic { width:56px; height:56px; border-radius:50%; border:2px solid var(--accent); background:rgba(49,130,206,.12);
color:var(--accent); display:flex; align-items:center; justify-content:center; cursor:pointer; }
.talk-mic.live { animation:pulse 1.1s ease-in-out infinite; border-color:var(--ok); color:var(--ok); }
.talk-transcript { width:100%; min-height:80px; border:1px solid var(--line); border-radius:0; background:var(--input);
padding:8px 10px; font-size:.9rem; color:var(--fg); }
.talk-note { font-size:.7rem; color:var(--muted); text-align:center; }
.r-item { display:flex; align-items:center; gap:8px; padding:5px 6px; border-radius:0; font-size:.9rem; }
.r-item .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:0 0 auto; }
.r-item.me { background:rgba(49,130,206,.15); }
.a-block { margin-bottom:5px; }
.a-block label { display:block; font-size:.7rem; color:var(--muted); margin-bottom:2px; }
.a-block textarea { width:100%; min-height:64px; resize:vertical; padding:6px 8px; border-radius:0;
border:1px solid var(--line); background:var(--input); color:var(--fg); font:inherit; font-size:.9rem; }
#submitAns { width:100%; margin-top:6px; padding:8px; border:0; border-radius:0; background:var(--grad);
color:#fff; font-weight:600; cursor:pointer; }
#submitAns:disabled { opacity:.5; }
.waiting { font-size:.82rem; color:var(--muted); text-align:center; padding:10px; }
.agent-thinking { text-align:center; color:var(--accent); font-size:.95rem; padding:24px 10px; line-height:1.5;
animation:pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:.55} 50%{opacity:1} }
/* ★ QF-29 waiting-on-others dim overlay: a muted panel with the waiting message; makes it clear you're waiting, not broken. */
.waiting-dim { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px;
min-height:180px; border-radius:10px; background:var(--surface); border:1px dashed var(--line); padding:20px; }
/* ★ QF-29 review escape: a button to go back and re-read the questions while waiting on others */
.review-btn { background:transparent; color:var(--accent); border:1px solid var(--accent); border-radius:8px;
padding:7px 14px; font-size:.82rem; cursor:pointer; }
.review-btn:hover { background:var(--grad-soft); }
.review-banner { display:flex; justify-content:space-between; align-items:center; gap:10px; font-size:.78rem;
color:var(--muted); padding:6px 2px 10px; border-bottom:1px solid var(--line); margin-bottom:8px; }
.review-q { opacity:.85; }
/* ★ (Driver) loading spinner for the 'bundling + generating next round' screen (round_complete / QF-24) */
.spinner { width:34px; height:34px; border-radius:50%; border:3px solid var(--line); border-top-color:var(--accent);
animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#000c; color:#fff;
padding:9px 16px; border-radius:20px; font-size:.85rem; display:none; z-index:10; }
/* the treasure-chest DONE screen */
/* ★ SCROLL FIX (Driver: 'can't scroll up to see the whole done page'): justify-content:center + overflow-y:auto is a
flexbox trap — when content is taller than the viewport, centering pushes the TOP above the scroll area so you
can't reach it. Use flex-start (content starts at top, scrolls fully) with generous vertical padding so a SHORT
summary still sits nicely; a long one scrolls from the top. */
.done-wrap { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
text-align:center; padding:32px 24px 40px; gap:10px; overflow-y:auto; -webkit-overflow-scrolling:touch; }
.done-chest { font-size:4rem; }
.done-wrap h2 { margin:0; font-weight:700; }
.done-stats { color:var(--muted); font-size:.95rem; margin:0; }
.done-stats b { color:var(--accent); }
.done-summary { max-width:520px; text-align:left; font-size:.9rem; line-height:1.5; white-space:pre-wrap;
background:var(--surface); border-radius:12px; padding:14px; margin:6px 0; }
#dlQA { padding:13px 22px; border:0; border-radius:0; background:var(--grad); color:#062012; font-weight:700;
font-size:1rem; cursor:pointer; }
</style>
</head>
<body>
<div id="app">
<header>
<div class="h-left">
<span class="h-title">Quantum Forms QA</span>
<span class="h-neon">quantum-resistant · E2E encrypted</span>
</div>
<button class="gear-btn" id="gearBtn" title="Settings"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M19.4 13a7.8 7.8 0 0 0 0-2l2-1.6a.5.5 0 0 0 .1-.6l-1.9-3.3a.5.5 0 0 0-.6-.2l-2.4 1a7.3 7.3 0 0 0-1.7-1l-.4-2.5a.5.5 0 0 0-.5-.4h-3.8a.5.5 0 0 0-.5.4L9 4.7a7.3 7.3 0 0 0-1.7 1l-2.4-1a.5.5 0 0 0-.6.2L2.4 8.2a.5.5 0 0 0 .1.6L4.6 11a7.8 7.8 0 0 0 0 2l-2 1.6a.5.5 0 0 0-.1.6l1.9 3.3a.5.5 0 0 0 .6.2l2.4-1a7.3 7.3 0 0 0 1.7 1l.4 2.5a.5.5 0 0 0 .5.4h3.8a.5.5 0 0 0 .5-.4l.4-2.5a7.3 7.3 0 0 0 1.7-1l2.4 1a.5.5 0 0 0 .6-.2l1.9-3.3a.5.5 0 0 0-.1-.6zM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7z"/></svg></button>
<div class="h-right">
<div class="alarm-clock"><span class="digi-timer" id="timer">--:--</span></div>
<span class="round-line" id="roundNum">Round —</span>
<span class="total-line" id="totalLine"></span>
</div>
</header>
<!-- join gate: code + auto-generated name + avatar preview -->
<div id="gate">
<div class="box">
<h2>Join the session</h2>
<input class="code" id="code" maxlength="12" placeholder="CODE" autocapitalize="characters" autocomplete="off">
<div class="name-row">
<span id="namePreview"></span>
<input id="name" maxlength="40" placeholder="Your name" autocomplete="off">
</div>
<p class="hint">A name + avatar are generated for you — edit if you like.</p>
<button id="joinBtn">Join</button>
</div>
</div>
<!-- the session: TOP = questions-as-a-form (each question has its answer box under it) · BOTTOM-LEFT = who's here ·
BOTTOM-RIGHT = a tabbed panel (Chat | Pending Decisions) -->
<div id="session">
<!-- TOP: the form — questions with their answer textareas directly underneath (the form-layout redesign) -->
<div class="pane" id="questions">
<h3>Questions</h3>
<!-- ★ (Driver) Type/Talk tabs removed — the panel just outputs the questions as a form. -->
<div id="qList"><div class="waiting">Waiting for the first round…</div></div>
<button id="submitAns" disabled style="display:none">Submit answers</button>
<div class="waiting" id="waitMsg" style="display:none"></div>
</div>
<!-- BOTTOM-LEFT: who's here -->
<div class="pane" id="roster"><h3>Who's here</h3><div id="rList"></div></div>
<!-- BOTTOM-RIGHT: the tabbed side panel — Chat + Pending Decisions -->
<div class="pane" id="sidePanel">
<div class="side-tabs">
<button class="side-tab active" id="tabChat" data-side="chat">Chat</button>
<button class="side-tab" id="tabDecisions" data-side="decisions">Decisions</button>
</div>
<!-- Chat tab -->
<div class="side-body" id="chatBody">
<div id="chatLog"></div>
<div class="chat-input-row">
<input id="chatInput" type="text" enterkeyhint="send" placeholder="Type a message…" maxlength="2000" autocomplete="off" />
</div>
</div>
<!-- Decisions tab (the conflicting-answer decisions table) -->
<div class="side-body" id="decisionsBody" style="display:none">
<div id="decisionsList"><div class="waiting">No decisions yet. When your answers to the same question disagree, it shows up here as a decision to resolve.</div></div>
</div>
</div>
</div>
<!-- decision modal (mobile-friendly, centered) -->
<div id="decisionModal" class="modal-overlay" style="display:none">
<div class="modal-card">
<div class="modal-head"><span id="dmTitle"></span><button id="dmClose" class="modal-x">✕</button></div>
<div id="dmDesc" class="modal-desc"></div>
<details class="modal-answers"><summary>Conflicting answers so far</summary><div id="dmAnswers"></div></details>
<textarea id="dmTake" placeholder="Submit your take on this decision…" rows="3"></textarea>
<button id="dmSubmit">Submit my take</button>
</div>
</div>
<div class="toast" id="toast"></div>
</div>
<script src="./trust-panel.js?v=7"></script>
<!-- ★ CACHE-BUST: bump this ?v= on EVERY assembly.js change, or browsers keep serving the stale cached bundle even
with no-cache headers (the moderator kept seeing old code because ?v=37 never changed across ~a dozen ships). -->
<script type="module" src="./assembly.js?v=60"></script>
</body>
</html>

810
assembly.js Normal file
View file

@ -0,0 +1,810 @@
// assembly.js — the 3-pane multiplayer Q&A session (Phase 1: named join + roster + questions + answer boxes).
// TOP = questions (scroll), BOTTOM-LEFT = roster (who joined, chosen names), BOTTOM-RIGHT = answer textareas + submit.
// Round-loop (agent-driven): the agent posts questions → everyone answers → backend waits-for-all → next round.
const el = (id) => document.getElementById(id)
const toast = (t) => { const e = el('toast'); e.textContent = t; e.style.display = 'block'; setTimeout(() => e.style.display = 'none', 2200) }
// ── subtle notification sounds (Web Audio — on-device, NO files, NO CDN). Soft, pleasant, not harsh. ──
let audioCtx = null
function beep(freq, durMs, type, gainPeak) {
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)()
const now = audioCtx.currentTime
const osc = audioCtx.createOscillator(), g = audioCtx.createGain()
osc.type = type || 'sine'; osc.frequency.value = freq
// gentle attack + decay envelope so it's soft, not a harsh click
g.gain.setValueAtTime(0, now)
g.gain.linearRampToValueAtTime(gainPeak || 0.06, now + 0.02)
g.gain.exponentialRampToValueAtTime(0.0001, now + durMs / 1000)
osc.connect(g); g.connect(audioCtx.destination)
osc.start(now); osc.stop(now + durMs / 1000)
} catch (_) {}
}
// ★ SOUND DESIGN (Driver's spec QF-28): sound on JOIN, LEAVE, and NEXT-ROUND-START; NO sound on chat messages.
const sounds = {
newRound: () => { beep(523, 140, 'sine'); setTimeout(() => beep(659, 180, 'sine'), 120) }, // gentle two-note rise (round start)
join: () => beep(440, 120, 'sine', 0.05), // soft single rising tone
leave: () => beep(330, 140, 'sine', 0.05), // soft single falling tone (someone left)
submitted: () => beep(392, 100, 'sine', 0.05), // low soft blip
// (intentionally NO chat sound — Driver: no audio for chat messages)
}
// ── solid SVG icons (NO emoji — Driver's rule: real solid icons, small). Inline so no external asset. ──
const ICON = {
speaker: '<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>',
chest: '<svg viewBox="0 0 24 24" width="44" height="44" fill="currentColor"><path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3H4V6zm0 5h7v2h2v-2h7v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-7zm7 0v-1h2v1h-2z"/></svg>',
download: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" style="vertical-align:-2px"><path d="M12 3v10l4-4 1.4 1.4L12 16 6.6 10.4 8 9l4 4V3h0zm-7 15h14v2H5v-2z"/></svg>',
up: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 21h2V9H2v12zm20-11a2 2 0 0 0-2-2h-6.3l1-4.6v-.3a1.5 1.5 0 0 0-.4-1L13 1 6.6 7.4A2 2 0 0 0 6 9v10a2 2 0 0 0 2 2h9a2 2 0 0 0 1.8-1.2l3-7a2 2 0 0 0 .2-.8v-2z"/></svg>',
down: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M22 3h-2v12h2V3zM2 14a2 2 0 0 0 2 2h6.3l-1 4.6v.3a1.5 1.5 0 0 0 .4 1L11 23l6.4-6.4A2 2 0 0 0 18 15V5a2 2 0 0 0-2-2H7a2 2 0 0 0-1.8 1.2l-3 7a2 2 0 0 0-.2.8v2z"/></svg>',
mic: '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21h2v-3.1A7 7 0 0 0 19 11h-2z"/></svg>',
}
// ── random username (no login needed) — adjective + animal + number, like GitHub's suggested handles ──
const ADJ = ['brave', 'quiet', 'swift', 'clever', 'gentle', 'bold', 'lunar', 'amber', 'cosmic', 'noble', 'vivid', 'calm', 'bright', 'keen']
const NOUN = ['otter', 'falcon', 'cedar', 'comet', 'willow', 'lynx', 'heron', 'maple', 'ibex', 'raven', 'koi', 'fox']
function randomName() {
const r = (a) => a[Math.floor(Math.random() * a.length)]
return r(ADJ) + '-' + r(NOUN) + '-' + (10 + Math.floor(Math.random() * 90))
}
// ── deterministic identicon (GitHub/Forgejo-style) — a 5x5 mirrored pixel grid from a hash of the name ──
function hashStr(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) } return h >>> 0 }
function identiconSVG(seed, size = 28) {
const h = hashStr(seed)
const hue = h % 360
const color = `hsl(${hue},60%,55%)`
const cell = size / 5
let rects = ''
for (let x = 0; x < 3; x++) {
for (let y = 0; y < 5; y++) {
if (((h >> (x * 5 + y)) & 1) === 1) {
for (const xx of [x, 4 - x]) rects += `<rect x="${xx * cell}" y="${y * cell}" width="${cell}" height="${cell}" fill="${color}"/>`
}
}
}
return `<svg class="ident" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="#1b1d21"/>${rects}</svg>`
}
let sessionId = null, token = null, guestId = null, myName = null
let currentQuestions = [] // this round's questions
let votes = {} // question index -> 'up' | 'down' | null (the user's like/dislike per question)
let expiresAt = 0
let roundNum = 0 // which round we're on (updated when a new round of questions arrives)
// show a skribbl-style pill "[name] submitted their answers" in the questions pane, briefly.
function showAnsweredPill(name) {
const q = el('qList'); if (!q) return
let bar = document.getElementById('answeredBar')
if (!bar) { bar = document.createElement('div'); bar.id = 'answeredBar'; bar.className = 'answered-bar'; q.parentNode.insertBefore(bar, q) }
const pill = document.createElement('span'); pill.className = 'answered-pill'
pill.textContent = '✓ ' + name + ' submitted'
bar.appendChild(pill)
}
let totalRounds = 0 // set from the round payload when known ("Round 1 of 5")
function updateRound() {
const r = el('roundNum'); if (!r) return
// always show "of N" — if the total isn't known yet, show a ? placeholder so it never looks incomplete.
r.textContent = roundNum ? ('Round ' + roundNum + ' of ' + (totalRounds || '?')) : 'Round —'
}
// ── join with a chosen name ──
async function join() {
const code = el('code').value.trim().toUpperCase()
const name = el('name').value.trim()
if (!code) { toast('Enter the code'); return }
if (!name) { toast('Enter your name'); return }
el('joinBtn').disabled = true
try {
// ★ STABLE client_id (Driver ghost-on-rejoin bug): persist a random id in localStorage so a reload/rejoin
// RECLAIMS the same presence slot (the server derives a deterministic guest id from it) instead of leaving a
// ghost of the old user with a stale green check. One id per browser, reused across reloads.
let clientId
try {
clientId = localStorage.getItem('qf-client-id')
if (!clientId) { clientId = 'c_' + Math.random().toString(36).slice(2) + Date.now().toString(36); localStorage.setItem('qf-client-id', clientId) }
} catch (_) { clientId = 'c_' + Date.now().toString(36) }
const r = await fetch('/baas/sessions/join-by-code', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, client_id: clientId })
})
if (!r.ok) { toast(r.status === 404 ? 'No open session for that code' : 'Could not join'); el('joinBtn').disabled = false; return }
const d = await r.json()
sessionId = d.session_id; token = d.subscribe_token; guestId = d.guest; myName = name; expiresAt = d.expires_at || 0
// register the chosen name (roster broadcasts to everyone)
const nr = await registerName()
if (nr) renderRoster(nr.roster || [])
el('gate').style.display = 'none'; el('session').style.display = 'grid'
loadDecisions() // ★ restore any decisions from a prior load of this session (they don't disappear on reload now)
// ★ fetch the SERVER-AUTHORITATIVE session info (read-only) — total rounds + the SHARED timer end. This fixes
// "Round 1 of ?" and gives every guest the SAME clock (the server owns it; the browser just displays it).
try {
const ir = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/info?token=${encodeURIComponent(token)}`)
if (ir.ok) {
const info = await ir.json()
if (info.total_rounds) totalRounds = info.total_rounds
if (info.round_seconds) roundSeconds = info.round_seconds
if (info.current_round) roundNum = info.current_round - 1
if (info.round_ends_at) serverRoundEndsAt = info.round_ends_at // server-owned shared timer end
updateRound()
// ★ INSTANT ROUND (kills the ~10s "waiting for round 1" wait): the agent pre-staged the current round's
// questions into /info at session-create. If they're here, render them NOW — no waiting for the live SSE
// broadcast (which only arrives after the agent's ~10s claude call). showQuestions is idempotent, so when the
// SSE broadcast of the same round arrives it's a no-op and won't wipe anything.
if (Array.isArray(info.questions) && info.questions.length) {
showQuestions(info.questions)
}
}
} catch (_) {}
startTimer(); openStream()
} catch (e) { toast('Network error'); el('joinBtn').disabled = false }
}
// registerName — (re)register my chosen name so I appear in the roster. Called at join AND on every SSE reconnect
// (es.onopen) so a mobile disconnect/reconnect restores my presence instead of dropping me as a ghost.
async function registerName() {
if (!sessionId || !token || !myName) return null
try {
const nr = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/name?token=${encodeURIComponent(token)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: myName })
})
return nr.ok ? await nr.json() : null
} catch (_) { return null }
}
// ── the roster (bottom-left) ──
function renderRoster(roster) {
const list = el('rList'); list.innerHTML = ''
roster.forEach((p, idx) => {
const row = document.createElement('div')
row.className = 'r-item' + (p.guest === guestId ? ' me' : '')
// identicon avatar + name; the FIRST joiner is the moderator (crown). (moderator/kick wired in Task #6)
const isMod = idx === 0
const isReady = p.ready === 'true' || p.ready === true
// ★ (Driver) the ready state is a small CHECK BADGE overlaid on the corner of the AVATAR image (not a separate
// standalone check). role badge (agent) still sits after the name.
const roleBadge = p.role === 'agent' ? '<span class="role-tag">agent</span>' : ''
const avatarBadge = isReady ? '<span class="avatar-ready" title="submitted — ready">✓</span>' : ''
row.innerHTML = '<span class="avatar-wrap">' + identiconSVG(p.name) + avatarBadge + '</span>' +
'<span class="r-name"></span>' + roleBadge + (isMod ? '<span class="mod-tag">mod</span>' : '')
row.querySelector('.r-name').textContent = p.name + (p.guest === guestId ? ' (you)' : '')
// ★ VOTE-TO-KICK (Driver, asked for hours): click another user → a small menu → "Vote to kick". A kick REVOKES
// their token server-side (they lose API access), so it's a real security control. You can't kick yourself or an
// agent-role member from here (agents are managed by their operator).
if (p.guest !== guestId && p.role === 'user') {
row.style.cursor = 'pointer'
row.title = 'Click to vote to kick'
row.addEventListener('click', () => openKickMenu(p))
}
list.appendChild(row)
})
if (!roster.length) list.innerHTML = '<div class="waiting">No one yet…</div>'
}
function openKickMenu(p) {
const ov = document.createElement('div'); ov.className = 'modal-overlay'
const card = document.createElement('div'); card.className = 'modal-card'
card.innerHTML = '<div style="font-weight:600;margin-bottom:6px;">' + (p.name || 'this user') + '</div>' +
'<div style="font-size:.8rem;opacity:.75;margin-bottom:12px;">Vote to remove them from the session. A kick revokes their access — a majority of the people here must agree.</div>'
const kick = document.createElement('button'); kick.textContent = 'Vote to kick'
kick.style.cssText = 'width:100%;padding:9px;border-radius:6px;border:1px solid var(--danger);background:transparent;color:var(--danger);cursor:pointer;font-weight:600;'
kick.addEventListener('click', async () => {
kick.disabled = true; kick.textContent = 'Voting…'
try {
const r = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/kick?token=${encodeURIComponent(token)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: p.guest })
})
const d = await r.json().catch(() => ({}))
toast(d.kicked ? `${p.name} was kicked` : `Vote counted (${d.votes || 1}/${d.needed || '?'} to kick)`)
} catch (_) { toast('Kick failed') }
ov.remove()
})
const close = document.createElement('button'); close.textContent = 'Cancel'
close.style.cssText = 'width:100%;margin-top:8px;padding:8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--fg);cursor:pointer;'
close.addEventListener('click', () => ov.remove())
card.appendChild(kick); card.appendChild(close)
ov.appendChild(card); ov.addEventListener('click', (e) => { if (e.target === ov) ov.remove() })
document.body.appendChild(ov)
}
// ── questions (top) + answer boxes (bottom-right) ──
function parseQuestions(text) {
// a round's questions may arrive as a JSON array, or newline-separated text — handle both.
try { const j = JSON.parse(text); if (Array.isArray(j)) return j } catch (_) {}
return String(text).split('\n').map(s => s.trim()).filter(Boolean)
}
function showQuestions(qs) {
// ★ P0 DATA-LOSS FIX (SURVEYINATOR bugs #1/#2): the runner REBROADCASTS the current round on EVERY roster event
// (any user join/leave) and on a late-joiner catch-up. Previously this re-ran showQuestions() unconditionally, which
// did aList.innerHTML='' → WIPED every user's in-progress answer textareas (and re-advanced the round + reset the
// timer). So a peer joining/leaving destroyed your typing. FIX: showQuestions is now IDEMPOTENT on the SAME round —
// if the incoming questions match what's already rendered, it's a rebroadcast, NOT a new round → do NOTHING to the
// answer area (preserve every user's unsent typing), do NOT re-advance, do NOT reset the timer.
const sameRound = Array.isArray(currentQuestions) && currentQuestions.length === qs.length &&
currentQuestions.every((q, i) => q === qs[i])
if (sameRound && el('qList') && el('qList').querySelector('textarea[data-q]')) {
// already showing this exact round with the form built → a rebroadcast (join/leave/late-joiner). No-op:
// the per-question textareas (client-owned until submit) are preserved. Don't wipe anyone's typing.
return
}
currentQuestions = qs
votes = {}
roundNum += 1; updateRound() // a new round of questions arrived → advance + show the round number
startRoundTimer() // reset the 5:00 round countdown for the new round
sounds.newRound() // soft two-note chime when a new round's questions come in
const bar = document.getElementById('answeredBar'); if (bar) bar.innerHTML = '' // clear last round's "answered" pills
const qList = el('qList'); qList.innerHTML = ''
const aList = el('aList'); if (aList) aList.innerHTML = '' // #aList removed in the form-layout redesign — guard null
// a "Play all" button reads every question aloud in order (on-device TTS, nothing sent out).
if (qs.length && 'speechSynthesis' in window) {
const playAll = document.createElement('button'); playAll.className = 'play-all'
playAll.innerHTML = ICON.speaker + '<span>Play all questions</span>'
playAll.addEventListener('click', () => speakAll(qs))
qList.appendChild(playAll)
}
qs.forEach((q, i) => {
const qi = document.createElement('div'); qi.className = 'q-item'
// question text + a 🔊 speaker (read aloud) + 👍 / 👎 vote control
const txt = document.createElement('div')
txt.innerHTML = '<span class="q-num">Q' + (i + 1) + '</span><span></span>'
txt.lastChild.textContent = q
const vote = document.createElement('div'); vote.className = 'q-vote'
if ('speechSynthesis' in window) {
const spk = document.createElement('button'); spk.className = 'vote-btn spk'; spk.innerHTML = ICON.speaker
spk.title = 'Read this question aloud'
spk.addEventListener('click', () => speakOne(q))
vote.appendChild(spk)
}
const up = document.createElement('button'); up.className = 'vote-btn up'; up.innerHTML = ICON.up; up.title = 'Like'
const down = document.createElement('button'); down.className = 'vote-btn down'; down.innerHTML = ICON.down; down.title = 'Dislike'
up.addEventListener('click', () => setVote(i, 'up', up, down))
down.addEventListener('click', () => setVote(i, 'down', up, down))
vote.appendChild(up); vote.appendChild(down)
qi.appendChild(txt); qi.appendChild(vote)
qList.appendChild(qi)
// ★ FORM LAYOUT (Driver): the answer textarea goes DIRECTLY UNDER its question (not a separate pane). Full width
// of the question area, 1 row tall by default (resizable), collapsible. This makes it read like a form.
if (mode === 'type' && !settings.typeOneBox) {
const aWrap = document.createElement('div'); aWrap.className = 'q-answer'
const ta = document.createElement('textarea'); ta.dataset.q = i; ta.rows = 1; ta.placeholder = 'Your answer…'
ta.className = 'q-answer-box'
aWrap.appendChild(ta); qList.appendChild(aWrap)
}
})
// one-box + talk modes still render a single combined input area under the questions
if (mode !== 'type' || settings.typeOneBox) renderAnswerArea(qList)
// ★ (Driver) a trailing OPTIONAL "Additional Notes" box at the END of the questions — for anything off-topic.
if (qs.length && mode === 'type' && !settings.typeOneBox) {
const nWrap = document.createElement('div'); nWrap.className = 'q-answer notes-block'
const lbl = document.createElement('div'); lbl.className = 'notes-label'; lbl.textContent = 'Optional: additional notes about anything'
const ta = document.createElement('textarea'); ta.id = 'notesBox'; ta.rows = 1; ta.className = 'q-answer-box'
ta.placeholder = 'Anything else you want to add…'
nWrap.appendChild(lbl); nWrap.appendChild(ta); qList.appendChild(nWrap)
}
// ★ (Driver) only SHOW the submit button when there are actually questions — no floating button on an empty screen.
el('submitAns').style.display = qs.length ? 'block' : 'none'
el('submitAns').disabled = qs.length === 0
el('submitAns').textContent = 'Submit answers'
el('waitMsg').style.display = 'none'
}
// setVote records a like/dislike for a question; clicking the same vote again clears it (toggle).
function setVote(i, dir, upBtn, downBtn) {
votes[i] = votes[i] === dir ? null : dir
upBtn.classList.toggle('active', votes[i] === 'up')
downBtn.classList.toggle('active', votes[i] === 'down')
}
// ── submit this round's answers ──
async function submitAnswers() {
let payload
if (mode === 'talk') {
// Talk mode: send the WHOLE transcript; the agent sorts it into the questions (Driver's design).
const transcript = (el('talkTranscript') || {}).textContent || ''
if (!transcript.trim()) { toast('Nothing transcribed yet'); return }
payload = { type: 'answers', name: myName, mode: 'talk', transcript, questions: currentQuestions, votes }
} else if (settings.typeOneBox && el('oneBox')) {
// one-box type mode: send the whole text as a transcript for the agent to sort (same as talk mode)
const transcript = el('oneBox').value || ''
if (!transcript.trim()) { toast('Enter your answers'); return }
payload = { type: 'answers', name: myName, mode: 'talk', transcript, questions: currentQuestions, votes }
} else {
const answers = currentQuestions.map((q, i) => ({
q, a: (el('qList').querySelector(`textarea[data-q="${i}"]`) || {}).value || '',
vote: votes[i] || null // like/dislike rides in the submission metadata
}))
const notes = (el('notesBox') || {}).value || '' // ★ the trailing Additional Notes box (Driver)
payload = { type: 'answers', name: myName, round_answers: answers, notes }
}
el('submitAns').disabled = true
const ok = await postSubmission(payload)
if (ok) {
clearDraftsForRound() // ★ submitted successfully → the drafts for THIS round are safe to drop
el('submitAns').textContent = 'Submitted ✓'
el('waitMsg').style.display = 'block'
// ★ (Driver) the message must be AWARE of the final round — on the LAST round we're NOT generating another round,
// we're wrapping up + building the summary (the treasure-chest done screen), so don't mislead.
const isFinal = totalRounds && roundNum >= totalRounds
const msg = isFinal
? '<b>Submitted — final round.</b> Wrapping up your session and building the summary…'
: '<b>Submitted — waiting on other users to submit their answers.</b> When everyone’s in, the round advances.'
el('waitMsg').innerHTML = msg
// ★ QF-29 (Driver): DIM the questions area + show the waiting message overlaid, so it's clear you're waiting on
// others (not that something's broken). The overlay is cleared when the next round's questions render.
showWaitingDim(msg)
} else { toast('Failed to submit'); el('submitAns').disabled = false }
}
// ★ QF-29 waiting-dim with a 'Review questions' escape (Driver): the grey waiting screen isn't a dead-end — you can
// go back and RE-READ this round's questions (read-only, you've already submitted) while waiting on others.
function showWaitingDim(msg) {
const q = el('qList'); if (!q) return
window.__submittedQuestions = currentQuestions.slice() // remember what to show on "review"
q.innerHTML = '<div class="waiting-dim"><div class="agent-thinking">' + msg +
'</div><button id="reviewQ" class="review-btn">Go back to review the questions</button></div>'
const rv = document.getElementById('reviewQ')
if (rv) rv.addEventListener('click', () => showReviewQuestions(msg))
}
function showReviewQuestions(waitMsgText) {
const q = el('qList'); if (!q) return
q.innerHTML = '' // rebuild the questions READ-ONLY (already submitted this round)
const banner = document.createElement('div'); banner.className = 'review-banner'
banner.innerHTML = '<span>Reviewing your submitted questions (waiting on others)</span>'
const back = document.createElement('button'); back.className = 'review-btn'; back.textContent = 'Back to waiting'
back.addEventListener('click', () => showWaitingDim(waitMsgText))
banner.appendChild(back); q.appendChild(banner)
;(window.__submittedQuestions || currentQuestions).forEach((qq, i) => {
const blk = document.createElement('div'); blk.className = 'q-item review-q'
const num = document.createElement('span'); num.className = 'q-num'; num.textContent = 'Q' + (i + 1)
const txt = document.createElement('span'); txt.textContent = ' ' + qq
blk.appendChild(num); blk.appendChild(txt)
q.appendChild(blk)
})
}
async function postSubmission(payload) {
try {
const r = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/submit?token=${encodeURIComponent(token)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sealed: false, payload })
})
return r.ok
} catch (e) { return false }
}
// ── on-device text-to-speech (reads questions aloud). 100% local — uses the phone's built-in voices,
// sends NOTHING to any server. This is TTS only (speaking), not STT (no mic, no recording, no cloud). ──
function speakOne(text) {
if (!('speechSynthesis' in window)) return
window.speechSynthesis.cancel() // stop anything currently speaking
const u = new SpeechSynthesisUtterance(text)
u.rate = 1.0; u.pitch = 1.0
window.speechSynthesis.speak(u)
}
function speakAll(questions) {
if (!('speechSynthesis' in window)) return
window.speechSynthesis.cancel()
questions.forEach((q, i) => {
const u = new SpeechSynthesisUtterance('Question ' + (i + 1) + '. ' + q)
u.rate = 1.0
window.speechSynthesis.speak(u) // queued — they play in order
})
}
// ── the treasure-chest DONE screen (session complete) ──
function showDone(p) {
const s = el('session')
s.style.display = 'flex'; s.style.flexDirection = 'column'
s.innerHTML =
'<div class="done-wrap">' +
'<div class="done-chest">' + ICON.chest + '</div>' +
'<h2>Your Q&amp;A session is done</h2>' +
'<p class="done-stats">You did <b>' + (p.rounds || 0) + '</b> round' + (p.rounds === 1 ? '' : 's') +
' · answered <b>' + (p.questions || 0) + '</b> question' + (p.questions === 1 ? '' : 's') + '</p>' +
'<div class="done-summary"></div>' +
'<button id="dlQA">' + ICON.download + ' Download your QA file</button>' +
'</div>'
s.querySelector('.done-summary').textContent = p.summary || ''
el('dlQA').addEventListener('click', () => {
const md = p.qa_markdown || ('# QA Session\n\n' + (p.summary || ''))
const blob = new Blob([md], { type: 'text/markdown' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = 'my-qa-session.md'; a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 1000)
})
el('roundTag').textContent = 'Session complete'
}
// ── live stream: roster updates + new question rounds ──
function openStream() {
if (typeof EventSource === 'undefined') return
const es = new EventSource(`/baas/sessions/${encodeURIComponent(sessionId)}/stream?token=${encodeURIComponent(token)}`)
// ★ PRESENCE-RECONNECT FIX (SURVEYINATOR/mobile bug: "my character disappeared from Who's here after a mobile
// disconnect"). On mobile the SSE stream drops when the screen locks / app backgrounds / network blips → the
// server stops getting heartbeats → after the 45s TTL it prunes you as a ghost → you vanish from the roster even
// though your page still works. EventSource auto-reconnects, but the name was only registered ONCE at join, so a
// reconnect came back nameless/ghosted. FIX: re-register the name on EVERY (re)open → you reappear immediately.
es.onopen = () => { registerName().then(r => { if (r) renderRoster(r.roster || []) }) }
es.addEventListener('roster', (e) => {
try {
const r = JSON.parse(e.data).roster || []
// ★ JOIN/LEAVE DETECTION (Driver QF-23/QF-28): diff the roster BY NAME so we catch who joined + who left (not
// just a count delta), post a system line to chat + flash a banner, and play the join/leave sound.
const now = new Set(r.map(p => p.name))
const prev = window.__rosterNames instanceof Set ? window.__rosterNames : new Set()
if (window.__rosterNames) { // skip the very first roster (initial load isn't a "join" event)
r.forEach(p => { if (!prev.has(p.name) && p.name !== myName) { sounds.join(); addChatMsg('system', `${p.name} joined`, false); flashActivity(`${p.name} joined`) } })
prev.forEach(name => { if (!now.has(name) && name !== myName) { sounds.leave(); addChatMsg('system', `${name} left`, false); flashActivity(`${name} left`) } })
}
window.__rosterNames = now
renderRoster(r)
} catch (_) {}
})
// when a user submits, show a little pill "[name] submitted their answers" above the answer area (skribbl-style).
es.addEventListener('submission', (e) => {
// ★ (Driver) the "X submitted" notice belongs in the bottom-right ACTIVITY FEED (flashActivity), NOT a bar at the
// top-left of the questions. It's a transient log entry like join/leave, so it uses the same stacking banners.
try { const d = JSON.parse(e.data); if (d.payload && d.payload.type === 'answers') { sounds.submitted(); flashActivity(`${d.payload.name || 'someone'} submitted`) } } catch (_) {}
})
// ★ in-session CHAT: a `chat` event from any user → show it in the Chat tab.
es.addEventListener('chat', (e) => {
try { const d = JSON.parse(e.data); if (d.text) addChatMsg(d.name || 'someone', d.text, d.guest === guestId) } catch (_) {}
})
// ★ DECISIONS: the QA-loop agent, after folding a round's answers, emits `decision` events for any QUESTION where
// users DISAGREED (divergent answers → something to reconcile). The Decisions tab charts these: each is a card
// with status `conflict` (needs agreement) or `agreed` (resolved). This is what the tab exists to chart — not a
// generic to-do list, but the specific questions where the group's answers don't yet line up.
es.addEventListener('decision', (e) => {
try { const d = JSON.parse(e.data); if (d && d.question) upsertDecision(d) } catch (_) {}
})
// ★ VOTE-TO-KICK events. `kick_vote` = a vote landed (show progress); `kicked` = a member was removed. If the
// kicked member is ME, my token is now revoked → close the stream + show a removed screen (can't keep pulling API).
es.addEventListener('kick_vote', (e) => {
try { const d = JSON.parse(e.data)
toast(`${d.voter || 'someone'} voted to kick ${d.name || 'a user'} (${d.votes}/${d.needed})`)
// ★ (Driver) post it into the chat as a visible record, ATTRIBUTING the voter — 'X voted to kick Y'.
addChatMsg('system', `${d.voter || 'Someone'} voted to kick ${d.name || 'a user'} — ${d.votes} of ${d.needed} needed`, false)
} catch (_) {}
})
es.addEventListener('kicked', (e) => {
try {
const d = JSON.parse(e.data)
addChatMsg('system', `${d.name || 'A user'} was kicked by group vote (final vote by ${d.voter || 'someone'}) — access revoked.`, false)
if (d.guest === guestId) {
es.close()
document.getElementById('session').innerHTML = '<div class="waiting" style="padding:40px;text-align:center;">You were removed from this session by a group vote. Your access has been revoked.</div>'
} else {
toast(`${d.name || 'A user'} was kicked`)
}
} catch (_) {}
})
es.addEventListener('message', (e) => {
try {
const d = JSON.parse(e.data)
if (d.from !== 'agent' || !d.text) return
// ★ is this the COMPLETION signal? (a JSON object with kind:'complete') → show the done-screen, NOT questions.
let payload = null
try { payload = JSON.parse(d.text) } catch (_) {}
if (payload && payload.kind === 'complete') { showDone(payload); return }
// ★ a DECISIONS batch: the agent found questions where users disagreed → render them in the Decisions tab.
if (payload && payload.kind === 'decisions' && Array.isArray(payload.items)) {
payload.items.forEach((it) => { if (it && it.question) upsertDecision(it) }); return
}
// a round object {questions:[...], round:N, total:M} carries the counts so we can show "Round N of M".
if (payload && Array.isArray(payload.questions)) {
if (payload.total) totalRounds = payload.total
if (payload.round) { roundNum = payload.round - 1 } // showQuestions() will +1
showQuestions(payload.questions); return
}
// otherwise it's a plain questions array (legacy).
showQuestions(parseQuestions(d.text))
} catch (_) {}
})
es.addEventListener('config', (e) => {
try { const c = JSON.parse(e.data)
if (c.total_rounds) totalRounds = c.total_rounds
if (c.round_seconds) roundSeconds = c.round_seconds
if (c.round_ends_at) { serverRoundEndsAt = c.round_ends_at; roundEndsAt = c.round_ends_at }
updateRound()
} catch (_) {}
})
// ★ (Driver) ROUND_COMPLETE = the server confirmed EVERYONE submitted → show the "bundling + generating" screen with
// a spinner (distinct from the "waiting on others" state after only YOU submit). The next round's questions arriving
// will replace it. This is the server-authoritative signal (QF-24) surfaced to the UI.
es.addEventListener('round_complete', (e) => {
const q = el('qList'); if (!q) return
const isFinal = totalRounds && roundNum >= totalRounds
const line = isFinal
? 'All answers are in — bundling your session and building the summary…'
: 'All answers are in — bundling them and sending to the agent. The next round of questions is generating…'
q.innerHTML = '<div class="waiting-dim"><div class="spinner" aria-hidden="true"></div>' +
'<div class="agent-thinking">' + line + '</div></div>'
})
es.addEventListener('closed', () => { el('roundTag').textContent = 'Session closed'; es.close() })
}
// ── the ROUND timer (the alarm clock) — a 5:00 countdown PER ROUND, shown as MM:SS in the square clock.
// (The session total is shown small underneath.) The round timer resets each round.
let roundSeconds = 300 // 5:00 default per round
let roundEndsAt = 0 // epoch seconds when this round's timer ends
let serverRoundEndsAt = 0 // ★ the SERVER's shared round-end (from /info or a config event) — authoritative
function startRoundTimer() {
// prefer the SERVER's shared end-time (all guests share ONE clock); fall back to local only if unknown.
roundEndsAt = serverRoundEndsAt || (Math.floor(Date.now() / 1000) + roundSeconds)
tickRound()
}
function tickRound() {
const left = Math.max(0, roundEndsAt - Math.floor(Date.now() / 1000))
const m = Math.floor(left / 60), s = left % 60
el('timer').textContent = m + ':' + String(s).padStart(2, '0') // the alarm-clock digits = the round countdown
// the tiny total-line = the whole session time remaining (labelled)
if (expiresAt) {
const sess = Math.max(0, expiresAt - Math.floor(Date.now() / 1000))
const sh = Math.floor(sess / 3600), sm = Math.floor((sess % 3600) / 60)
const t = el('totalLine'); if (t) t.textContent = 'session: ' + (sh ? sh + 'h' : '') + sm + 'm left'
}
if (left > 0) setTimeout(tickRound, 1000)
}
function startTimer() { /* session tracking handled inside tickRound via expiresAt */ startRoundTimer() }
// ── Talk / Type mode (Type is default). Type = per-question boxes. Talk = mic + streaming transcription (STT
// is the owed piece — Moonshine Web on-device; for now Talk shows the mic UI + a note). ──
let mode = 'type'
function setMode(m) {
mode = m
document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === m))
// re-render the answer area for the current questions in the chosen mode
if (currentQuestions.length) renderAnswerArea()
}
// ★ DRAFT PERSISTENCE (QF-1, the P0 the moderator hit repeatedly: "I was typing and a user left and my answers
// disappeared"). Any re-render (roster change, rebroadcast, reload) rebuilds the textareas from scratch, wiping
// in-progress typing. FIX: persist every keystroke to localStorage keyed by (session, round, slot), restore it on
// render, and clear it only after a successful submit. Typing now survives ANY re-render or reload.
function draftKey(slot) { return `qf-draft:${sessionId || 's'}:${roundNum}:${slot}` }
function saveDraft(slot, val) { try { localStorage.setItem(draftKey(slot), val) } catch (_) {} }
function loadDraft(slot) { try { return localStorage.getItem(draftKey(slot)) || '' } catch (_) { return '' } }
function wireDraft(ta, slot) {
ta.value = loadDraft(slot) // restore whatever was typed before this render
ta.addEventListener('input', () => saveDraft(slot, ta.value))
}
function clearDraftsForRound() {
try {
const pre = `qf-draft:${sessionId || 's'}:${roundNum}:`
for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith(pre)) localStorage.removeItem(k) }
} catch (_) {}
}
function renderAnswerArea(container) {
const aList = container || el('qList')
if (mode === 'type' && settings.typeOneBox) {
// one big box — paste everything; the agent sorts it into the questions (talk-mode-style, but typed).
const ab = document.createElement('div'); ab.className = 'a-block'
const lbl = document.createElement('label'); lbl.textContent = 'All answers (the agent will sort them)'
const ta = document.createElement('textarea'); ta.id = 'oneBox'; ta.style.minHeight = '160px'; ta.placeholder = 'Type or paste all your answers here…'
wireDraft(ta, 'oneBox')
ab.appendChild(lbl); ab.appendChild(ta); aList.appendChild(ab)
} else if (mode === 'type') {
currentQuestions.forEach((q, i) => {
const ab = document.createElement('div'); ab.className = 'a-block'
const lbl = document.createElement('label'); lbl.textContent = 'Q' + (i + 1)
const ta = document.createElement('textarea'); ta.dataset.q = i; ta.placeholder = 'Your answer…'
wireDraft(ta, 'q' + i)
ab.appendChild(lbl); ab.appendChild(ta); aList.appendChild(ab)
})
} else {
// Talk mode: a mic + a live transcript. The whole transcript is sent to the agent, which sorts it into questions.
const panel = document.createElement('div'); panel.className = 'talk-panel'
const mic = document.createElement('button'); mic.className = 'talk-mic'; mic.id = 'talkMic'; mic.innerHTML = ICON.mic
const trans = document.createElement('div'); trans.className = 'talk-transcript'; trans.id = 'talkTranscript'
trans.textContent = ''
const note = document.createElement('div'); note.className = 'talk-note'
note.textContent = 'Speak your answers — on submit, the whole transcript goes to the agent, which sorts it into the questions. (On-device streaming STT — Moonshine — is the next build; the tunnel + sort path is ready.)'
mic.addEventListener('click', () => mic.classList.toggle('live')) // placeholder: real streaming STT = Moonshine Web (owed)
panel.appendChild(mic); panel.appendChild(trans); panel.appendChild(note)
aList.appendChild(panel)
}
}
document.querySelectorAll('.mode-tab').forEach(t => t.addEventListener('click', () => setMode(t.dataset.mode)))
// ── theme (light/dark) — persisted; ★ DEFAULT LIGHT (Driver). Dark still available in settings. [data-theme] on <html>. ──
function applyTheme(t) { document.documentElement.dataset.theme = t; try { localStorage.setItem('qf-theme', t) } catch (_) {} }
function currentTheme() { try { return localStorage.getItem('qf-theme') || 'light' } catch (_) { return 'light' } }
applyTheme(currentTheme())
// ── settings (gear button, top-right) — mode-specific options from the spec ──
const settings = { talkAutoRead: true, typeOneBox: false }
function openSettings() {
const wrap = document.createElement('div'); wrap.className = 'set-overlay'
const toggle = (on) => '<div class="set-toggle' + (on ? ' on' : '') + '"></div>'
const isLight = currentTheme() === 'light'
wrap.innerHTML =
'<div class="set-sheet">' +
'<div class="set-head"><span>Settings</span><button class="set-x">&times;</button></div>' +
'<div class="set-body">' +
'<div class="set-group">Appearance</div>' +
'<div class="set-row" data-k="theme"><div class="lbl"><b>Light mode</b>' +
'<span>Switch between light and dark. Off = dark.</span></div>' + toggle(isLight) + '</div>' +
'<div class="set-group">Answering</div>' +
'<div class="set-row" data-k="talkAutoRead"><div class="lbl"><b>Talk mode: read questions aloud</b>' +
'<span>Automatically speak each new round. Off = tap the speaker to hear them.</span></div>' + toggle(settings.talkAutoRead) + '</div>' +
'<div class="set-row" data-k="typeOneBox"><div class="lbl"><b>Type mode: single answer box</b>' +
'<span>One large box instead of a box per question — the agent sorts your answers.</span></div>' + toggle(settings.typeOneBox) + '</div>' +
'</div>' +
'</div>'
document.body.appendChild(wrap)
wrap.querySelector('.set-x').addEventListener('click', () => wrap.remove())
wrap.addEventListener('click', (e) => { if (e.target === wrap) wrap.remove() })
wrap.querySelectorAll('.set-row').forEach(row => {
row.addEventListener('click', (e) => {
if (e.target.closest('.set-x')) return
const k = row.dataset.k
if (k === 'theme') {
const next = currentTheme() === 'light' ? 'dark' : 'light'; applyTheme(next)
row.querySelector('.set-toggle').classList.toggle('on', next === 'light')
return
}
settings[k] = !settings[k]
row.querySelector('.set-toggle').classList.toggle('on', settings[k])
if (k === 'typeOneBox' && currentQuestions.length) renderAnswerArea()
})
})
}
const gear = el('gearBtn'); if (gear) gear.addEventListener('click', openSettings)
el('joinBtn').addEventListener('click', join)
el('submitAns').addEventListener('click', submitAnswers)
// ── the tabbed side panel: Chat | Pending Decisions ──
function switchSide(which) {
document.querySelectorAll('.side-tab').forEach(t => t.classList.toggle('active', t.dataset.side === which))
el('chatBody').style.display = which === 'chat' ? 'flex' : 'none'
el('decisionsBody').style.display = which === 'decisions' ? 'flex' : 'none'
}
document.querySelectorAll('.side-tab').forEach(t => t.addEventListener('click', () => switchSide(t.dataset.side)))
// ── DECISIONS: the reconcile-conflicting-answers surface ──
// A "decision" is a QUESTION where the group's answers diverged. Each card shows the question + a conflict/agreed
// status. Tapping it opens the divergent answers (who said what) so the group can come to an agreement. The agent
// re-emits a decision with status 'agreed' once the group converges; the card flips green (it never disappears —
// the resolved decisions are the record of what you agreed).
const decisions = new Map() // question -> {question, status, positions:[{name,answer}], suggestion}
// ★ DECISIONS PERSISTENCE (Driver bug: "decisions show up then DISAPPEAR"). The Map was in-memory only, so a RELOAD
// (which the moderator does constantly) wiped every decision — the agent only broadcasts a decision ONCE, so on reload
// they were gone forever. FIX: persist the decisions Map to localStorage per session; restore on load. Decisions now
// survive reloads + reconnects, and stay until resolved (they never auto-disappear).
function decisionsKey() { return `qf-decisions:${sessionId || 's'}` }
function saveDecisions() {
try { localStorage.setItem(decisionsKey(), JSON.stringify([...decisions.values()])) } catch (_) {}
}
function loadDecisions() {
try {
const raw = localStorage.getItem(decisionsKey()); if (!raw) return
JSON.parse(raw).forEach(d => { if (d && d.question) decisions.set(d.question, d) })
renderDecisions(); refreshDecisionsTab()
} catch (_) {}
}
function refreshDecisionsTab() {
const tab = el('tabDecisions'); if (!tab) return
const unresolved = [...decisions.values()].filter(x => x.status === 'conflict').length
tab.textContent = unresolved ? `Decisions (${unresolved})` : 'Decisions'
}
function upsertDecision(d) {
const key = d.question
const prev = decisions.get(key) || {}
decisions.set(key, {
question: d.question,
status: d.status || prev.status || 'conflict', // 'conflict' | 'agreed'
positions: d.positions || prev.positions || [], // [{name, answer}]
suggestion: d.suggestion || prev.suggestion || '', // the agent's proposed common ground
round: d.round || prev.round || roundNum + 1,
})
saveDecisions() // ★ persist so a reload doesn't lose it
renderDecisions()
refreshDecisionsTab() // badge the tab with the unresolved count
}
function renderDecisions() {
const list = el('decisionsList'); if (!list) return
const rows = [...decisions.values()].sort((a, b) => (a.status === b.status ? 0 : a.status === 'conflict' ? -1 : 1))
if (!rows.length) {
list.innerHTML = '<div class="waiting">No decisions yet. When your answers to the same question disagree, it shows up here as a decision to resolve.</div>'
return
}
list.innerHTML = ''
rows.forEach((d) => {
const row = document.createElement('div'); row.className = 'decision-row'
const q = document.createElement('span'); q.textContent = d.question
// ★ WORD-WRAP FIX (Driver: "it's word-wrapping every letter, each entry is 20 chars tall"). In a flex row a child
// needs min-width:0 to be allowed to shrink+ellipsize; without it (and with a non-shrinking badge) the browser
// wraps char-by-char. Give the question flex:1 + min-width:0 + single-line ellipsis; the badge never shrinks.
q.style.cssText = 'flex:1 1 auto;min-width:0;margin-right:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
const st = document.createElement('span')
st.className = 'decision-status ' + (d.status === 'agreed' ? 'solved' : 'open')
st.style.cssText = 'flex:0 0 auto;white-space:nowrap;'
st.textContent = d.status === 'agreed' ? 'agreed' : 'needs agreement'
row.appendChild(q); row.appendChild(st)
row.addEventListener('click', () => openDecision(d))
list.appendChild(row)
})
}
function openDecision(d) {
const ov = document.createElement('div'); ov.className = 'modal-overlay'
const card = document.createElement('div'); card.className = 'modal-card'
const h = document.createElement('div'); h.style.cssText = 'font-weight:600;margin-bottom:8px;'; h.textContent = d.question
card.appendChild(h)
const sub = document.createElement('div'); sub.style.cssText = 'font-size:.72rem;opacity:.7;margin-bottom:10px;'
sub.textContent = d.status === 'agreed' ? 'Resolved — the group agreed.' : 'The group gave different answers. Talk it out in Chat, then converge.'
card.appendChild(sub)
;(d.positions || []).forEach((p) => {
const pos = document.createElement('div'); pos.style.cssText = 'margin:6px 0;padding:6px 8px;background:var(--surface);border-radius:4px;font-size:.82rem;'
const nm = document.createElement('div'); nm.style.cssText = 'font-weight:600;font-size:.72rem;opacity:.8;'; nm.textContent = p.name
const an = document.createElement('div'); an.textContent = p.answer
pos.appendChild(nm); pos.appendChild(an); card.appendChild(pos)
})
if (d.suggestion) {
const sg = document.createElement('div'); sg.style.cssText = 'margin-top:10px;padding:8px;border-left:3px solid var(--accent);font-size:.82rem;'
sg.innerHTML = '<b>Suggested common ground:</b> ' + d.suggestion.replace(/</g, '&lt;')
card.appendChild(sg)
}
const close = document.createElement('button'); close.textContent = 'Close'
close.style.cssText = 'margin-top:12px;width:100%;padding:8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--fg);cursor:pointer;'
close.addEventListener('click', () => ov.remove())
card.appendChild(close)
ov.appendChild(card); ov.addEventListener('click', (e) => { if (e.target === ov) ov.remove() })
document.body.appendChild(ov)
}
// ★ ACTIVITY BANNER (Driver QF-23): a single-row toast at the bottom-right of the questions section that fades after
// a few seconds. Multiple stack into a feed — newest at the BOTTOM, older ones pushed up — like a popup stack.
function flashActivity(text) {
let stack = document.getElementById('activityStack')
if (!stack) {
stack = document.createElement('div'); stack.id = 'activityStack'
stack.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;flex-direction:column;gap:5px;pointer-events:none;'
const host = document.getElementById('questions') || document.getElementById('session') || document.body
if (getComputedStyle(host).position === 'static') host.style.position = 'relative'
host.appendChild(stack)
}
const b = document.createElement('div')
b.textContent = text
b.style.cssText = 'background:var(--header);color:var(--fg);border:1px solid var(--line);border-radius:14px;padding:4px 12px;font-size:.74rem;box-shadow:0 2px 8px rgba(0,0,0,.18);opacity:0;transform:translateY(6px);transition:opacity .2s,transform .2s;white-space:nowrap;'
stack.appendChild(b) // newest at the bottom (flex-column: appended = lowest)
requestAnimationFrame(() => { b.style.opacity = '1'; b.style.transform = 'translateY(0)' })
setTimeout(() => { b.style.opacity = '0'; b.style.transform = 'translateY(-6px)'; setTimeout(() => b.remove(), 250) }, 3200)
}
// ── in-session CHAT (so users — and the builder-agent — can talk live) ──
function addChatMsg(name, text, mine) {
const log = el('chatLog'); if (!log) return
const row = document.createElement('div'); row.className = 'chat-msg' + (mine ? ' mine' : '')
const nm = document.createElement('span'); nm.className = 'chat-name'; nm.textContent = name + ':'
const tx = document.createElement('span'); tx.textContent = text
row.appendChild(nm); row.appendChild(tx); log.appendChild(row)
log.scrollTop = log.scrollHeight
}
async function sendChat() {
const inp = el('chatInput'); const text = (inp.value || '').trim()
if (!text || !sessionId || !token) return
inp.value = ''
try {
await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/chat?token=${encodeURIComponent(token)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text })
})
} catch (_) { toast('Chat failed') }
}
;(function wireChat() {
const send = el('chatSend'); const inp = el('chatInput')
if (send) send.addEventListener('click', sendChat)
if (inp) {
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sendChat() } })
// ★ Fix (Driver): when the chat box is TAPPED/focused (keyboard opens), scroll the chat log to the bottom so the
// last messages aren't hidden behind the input/keyboard, and add a class so the app can shift the panels up.
inp.addEventListener('focus', () => {
document.getElementById('app')?.classList.add('kb-open')
const log = el('chatLog'); if (log) setTimeout(() => { log.scrollTop = log.scrollHeight }, 250)
})
inp.addEventListener('blur', () => { document.getElementById('app')?.classList.remove('kb-open') })
}
})()
const qs = new URLSearchParams(location.search)
if (qs.get('c')) el('code').value = qs.get('c')
// auto-generate a random username (editable) + preview its identicon — no login needed.
;(function initName() {
const n = el('name'); if (!n.value) n.value = randomName()
const prev = el('namePreview')
const paint = () => { if (prev) prev.innerHTML = identiconSVG(n.value || 'guest') }
n.addEventListener('input', paint); paint()
})()

195
chat.js Normal file
View file

@ -0,0 +1,195 @@
// chat.js — the SimpleX-style CHAT RENDERER for Quantum Forms. Each message the guest sends is a SUBMISSION through
// the quantum tunnel (POST /baas/sessions/{id}/submit). Backend→guest messages (agent questions, other users, system)
// arrive through the SAME tunnel over SSE (GET /baas/sessions/{id}/stream). A message IS a form submission; the chat
// is just one renderer over the same session pipe.
//
// Bidirectional over the quantum tunnel: guest→backend = POST-up (sealed submission); backend→guest = SSE-down.
const el = (id) => document.getElementById(id)
const msgs = el('msgs')
let sessionId = null, token = null, guestId = null, expiresAt = 0
function toast(t) { const e = el('toast'); e.textContent = t; e.style.display = 'block'; setTimeout(() => e.style.display = 'none', 2200) }
function addBubble(kind, text, meta, fileName) {
const b = document.createElement('div')
b.className = 'bubble ' + (kind === 'sent' ? 'sent' : kind === 'sys' ? 'sys' : 'recv')
if (kind === 'sys') { b.textContent = text; msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight; return b }
if (text) b.appendChild(document.createTextNode(text))
if (fileName) {
const chip = document.createElement('div'); chip.className = 'file-chip'; chip.textContent = '📎 ' + fileName
b.appendChild(chip)
}
if (meta) { const m = document.createElement('span'); m.className = 'meta'; m.textContent = meta; b.appendChild(m) }
msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight
return b
}
function hhmm() { const d = new Date(); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') }
// ── countdown timer (session is live until expires_at) ──
function startTimer() {
const tick = () => {
if (!expiresAt) return
const secs = expiresAt - Math.floor(Date.now() / 1000)
if (secs <= 0) { el('timer').textContent = 'session ended'; addBubble('sys', 'This session has ended.'); return }
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60
el('timer').textContent = 'live · ' + (h ? h + 'h ' : '') + m + 'm ' + String(s).padStart(2, '0') + 's left'
setTimeout(tick, 1000)
}
tick()
}
// ── join by code ──
async function join() {
const code = el('code').value.trim().toUpperCase()
if (!code) { toast('Enter the code'); return }
el('joinBtn').disabled = true
try {
const r = await fetch('/baas/sessions/join-by-code', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code })
})
if (!r.ok) { toast(r.status === 404 ? 'No open session for that code' : 'Could not join'); el('joinBtn').disabled = false; return }
const d = await r.json()
// ★ DISPATCHER: one entry point (the forms host). Route by the session's KIND to the right renderer —
// you never go to a different URL; you type the code here and the app loads the correct form type.
if (d.kind === 'qa-rounds' || d.kind === 'assembly') {
location.href = '/assembly.html?c=' + encodeURIComponent(code)
return
}
sessionId = d.session_id; token = d.subscribe_token; guestId = d.guest
expiresAt = d.expires_at || 0
el('gate').style.display = 'none'; el('chat').style.display = 'flex'
startTimer()
addBubble('sys', 'You joined the session. This chat is quantum-resistant end-to-end encrypted.')
// show the opening prompt ONCE at join (not on every reconnect — that was the repeating-message bug).
if (d.question) addBubble('recv', d.question, hhmm())
openStream()
} catch (e) { toast('Network error'); el('joinBtn').disabled = false }
}
// ── SSE: backend→guest messages through the tunnel ──
function openStream() {
if (typeof EventSource === 'undefined') return
const es = new EventSource(`/baas/sessions/${encodeURIComponent(sessionId)}/stream?token=${encodeURIComponent(token)}`)
// NOTE: the stream re-sends the opening 'question' on every (re)connect. We already showed it once at join,
// so we IGNORE the streamed 'question' event here — otherwise it repeats every reconnect (the "keeps telling me
// to send the file" bug). Only genuine 'message' events (team/agent replies) are shown live.
es.addEventListener('question', () => { /* intentionally ignored — shown once at join, not on every reconnect */ })
es.addEventListener('message', (e) => {
try { const d = JSON.parse(e.data); addBubble('recv', d.text || '', hhmm()) } catch (_) {}
})
es.addEventListener('closed', () => { addBubble('sys', 'The host closed this session.'); es.close() })
es.onerror = () => { /* the browser auto-reconnects EventSource */ }
}
// ── staged attachment (SimpleX pattern: picking a file STAGES it; send transmits it) ──
let stagedFile = null
function fmtSize(n) { return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB' }
function stageFile(file) {
if (!file) return
if (file.size > 40 * 1024 * 1024) { toast('File too large (40 MB max)'); return }
stagedFile = file
el('pvName').textContent = file.name
el('pvSize').textContent = fmtSize(file.size)
el('preview').style.display = 'flex'
}
function clearStaged() { stagedFile = null; el('preview').style.display = 'none'; el('fileInput').value = '' }
// ── send: transmits the typed message AND/OR the staged file through the tunnel (one action, like SimpleX) ──
async function send() {
const ta = el('input'); const text = ta.value.trim()
if (!text && !stagedFile) return
el('sendBtn').disabled = true
// send the file first (if staged), then the text — each is one submission through the tunnel.
try {
if (stagedFile) {
const file = stagedFile
// Signal-style: a bubble with a live progress ring + a cancel (✕) button while the raw bytes stream to Garage.
const bubble = addFileBubble(file.name)
const ok = await uploadWithProgress(file, bubble)
if (ok === 'cancelled') { bubble.remove(); toast('Upload cancelled'); el('sendBtn').disabled = false; return }
setBubbleDone(bubble, ok)
if (ok) clearStaged(); else { toast('File failed'); el('sendBtn').disabled = false; return }
}
if (text) {
const bubble = addBubble('sent', text, hhmm() + ' · sending…')
const ok = await submitPayload({ type: 'message', text })
if (bubble) { const m = bubble.querySelector('.meta'); if (m) m.textContent = ok ? hhmm() + ' · sent ✓' : hhmm() + ' · failed' }
if (ok) { ta.value = ''; autoGrow() } else toast('Failed to send')
}
} catch (e) { toast('Network error') } finally { el('sendBtn').disabled = false }
}
// ── Signal-style file bubble: a progress ring + % + a cancel (✕) button ──
function addFileBubble(name) {
const b = document.createElement('div')
b.className = 'bubble sent filebub'
b.innerHTML =
'<div class="fb-row">' +
'<svg class="fb-ring" viewBox="0 0 36 36"><circle class="fb-bg" cx="18" cy="18" r="15"/>' +
'<circle class="fb-fg" cx="18" cy="18" r="15"/></svg>' +
'<span class="fb-name"></span>' +
'<button class="fb-cancel" title="Cancel">✕</button>' +
'</div><span class="meta">' + hhmm() + ' · 0%</span>'
b.querySelector('.fb-name').textContent = '📎 ' + name
msgs.appendChild(b); msgs.scrollTop = msgs.scrollHeight
return b
}
function setBubbleProgress(b, pct) {
const circ = 2 * Math.PI * 15
const fg = b.querySelector('.fb-fg')
if (fg) { fg.style.strokeDasharray = circ; fg.style.strokeDashoffset = circ * (1 - pct / 100) }
const m = b.querySelector('.meta'); if (m) m.textContent = hhmm() + ' · ' + Math.round(pct) + '%'
}
function setBubbleDone(b, ok) {
const c = b.querySelector('.fb-cancel'); if (c) c.remove()
const m = b.querySelector('.meta'); if (m) m.textContent = hhmm() + (ok ? ' · sent ✓' : ' · failed')
if (ok) setBubbleProgress(b, 100)
}
// uploadWithProgress streams the raw file to /upload via XHR (real upload-progress events) + supports CANCEL.
function uploadWithProgress(file, bubble) {
return new Promise((resolve) => {
const q = `token=${encodeURIComponent(token)}&name=${encodeURIComponent(file.name)}&type=${encodeURIComponent(file.type || 'application/octet-stream')}`
const xhr = new XMLHttpRequest()
xhr.open('POST', `/baas/sessions/${encodeURIComponent(sessionId)}/upload?${q}`)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.upload.onprogress = (e) => { if (e.lengthComputable) setBubbleProgress(bubble, (e.loaded / e.total) * 100) }
xhr.onload = () => resolve(xhr.status >= 200 && xhr.status < 300)
xhr.onerror = () => resolve(false)
xhr.onabort = () => resolve('cancelled')
// the cancel (✕) button aborts the in-flight upload — like Signal's pause/cancel.
const cancelBtn = bubble.querySelector('.fb-cancel')
if (cancelBtn) cancelBtn.addEventListener('click', () => xhr.abort())
xhr.send(file)
})
}
// submitPayload posts ONE submission through the quantum tunnel; returns true on success.
async function submitPayload(payload) {
try {
const r = await fetch(`/baas/sessions/${encodeURIComponent(sessionId)}/submit?token=${encodeURIComponent(token)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sealed: false, payload })
})
if (r.status === 429) { toast('Slow down'); return false }
return r.ok
} catch (e) { return false }
}
// ── composer wiring ──
function autoGrow() { const ta = el('input'); ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px' }
el('joinBtn').addEventListener('click', join)
el('code').addEventListener('keydown', (e) => { if (e.key === 'Enter') join() })
el('sendBtn').addEventListener('click', send)
el('input').addEventListener('input', autoGrow)
el('input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } })
el('attachBtn').addEventListener('click', () => el('fileInput').click())
el('fileInput').addEventListener('change', (e) => { if (e.target.files[0]) stageFile(e.target.files[0]) }) // STAGE, don't send
el('pvCancel').addEventListener('click', clearStaged)
// prefill code from ?c=
const qs = new URLSearchParams(location.search)
if (qs.get('c')) el('code').value = qs.get('c')

141
index.html Normal file
View file

@ -0,0 +1,141 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- interactive-widget=resizes-content: the BROWSER shrinks the layout viewport when ANY keyboard opens (incl. custom
keyboards like FUTO with a header). This is the platform mechanism — don't hand-roll visualViewport offset math,
which breaks on non-standard keyboards. With this, 100dvh + flexbox just works. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<title>Quantum Forms</title>
<style>
/* SimpleX-inspired: clean, minimal, dark. Sent bubbles right (accent), received left (surface). */
:root {
--bg:#111214; --header:#1b1d21; --surface:#26292e; --sent:#2b6cb0; --sent-2:#3182ce;
--fg:#e8eaed; --muted:#9aa0a6; --line:#33363b; --ok:#48bb78; --accent:#63b3ed;
}
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { margin:0; padding:0; height:100%; overflow:hidden; }
/* With interactive-widget=resizes-content, the browser shrinks the viewport for the keyboard (FUTO incl.), so a
plain 100dvh flex column keeps the composer pinned to the bottom of the VISIBLE area — no JS offset math. */
#app { height:100dvh; background:var(--bg); color:var(--fg);
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; display:flex; flex-direction:column; overflow:hidden; }
/* ── header: title + E2E banner + countdown ── (respects the Pixel notch/status bar) ── */
header { background:var(--header); border-bottom:1px solid var(--line);
padding:calc(10px + env(safe-area-inset-top)) 14px 10px; flex:0 0 auto; }
.h-row { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.h-title { font-weight:600; font-size:1rem; }
.h-timer { font-size:.78rem; color:var(--muted); font-variant-numeric:tabular-nums; }
.e2e { margin-top:6px; font-size:.72rem; color:var(--ok); display:flex; align-items:center; gap:6px; }
.e2e .dot { width:7px; height:7px; border-radius:50%; background:var(--ok); box-shadow:0 0 6px var(--ok); }
/* ── join gate ── */
#gate { flex:1; display:flex; align-items:center; justify-content:center; padding:24px; }
#gate .box { width:100%; max-width:360px; text-align:center; }
#gate h2 { font-weight:600; margin:0 0 6px; }
#gate p { color:var(--muted); font-size:.85rem; margin:0 0 18px; }
#gate input { width:100%; padding:13px; border-radius:10px; border:1px solid var(--line); background:#0c0d0f;
color:var(--fg); font-size:1.1rem; text-align:center; letter-spacing:3px; text-transform:uppercase; }
#gate button { width:100%; margin-top:14px; padding:13px; border:0; border-radius:10px; background:var(--sent);
color:#fff; font-weight:600; font-size:1rem; cursor:pointer; }
/* ── chat ── */
#chat { flex:1; display:none; flex-direction:column; min-height:0; }
.msgs { flex:1; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:12px 12px 4px;
display:flex; flex-direction:column; gap:6px; }
.bubble { max-width:80%; padding:8px 12px; border-radius:18px; font-size:.95rem; line-height:1.4; word-wrap:break-word; }
.bubble .meta { display:block; font-size:.64rem; color:rgba(255,255,255,.5); margin-top:3px; text-align:right; }
.recv { align-self:flex-start; background:var(--surface); border-bottom-left-radius:5px; }
.sent { align-self:flex-end; background:var(--sent); border-bottom-right-radius:5px; }
.sys { align-self:center; background:rgba(255,255,255,.06); color:var(--muted); font-size:.74rem; text-align:center;
max-width:90%; border-radius:12px; padding:5px 12px; }
.file-chip { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border-radius:10px;
background:rgba(255,255,255,.12); font-size:.85rem; margin-top:2px; }
/* Signal-style file upload: a progress ring + name + cancel ✕ */
.filebub .fb-row { display:flex; align-items:center; gap:10px; }
.fb-ring { width:30px; height:30px; flex:0 0 auto; transform:rotate(-90deg); }
.fb-ring .fb-bg { fill:none; stroke:rgba(255,255,255,.25); stroke-width:3; }
.fb-ring .fb-fg { fill:none; stroke:#fff; stroke-width:3; stroke-linecap:round;
transition:stroke-dashoffset .2s linear; }
.fb-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.9rem; }
.fb-cancel { flex:0 0 auto; width:26px; height:26px; border-radius:50%; border:0; background:rgba(0,0,0,.25);
color:#fff; font-size:.85rem; cursor:pointer; }
/* ── staged attachment preview (SimpleX: file shows above the input; send transmits it) ── */
.preview { flex:0 0 auto; display:flex; align-items:center; gap:10px; padding:8px 12px; background:var(--header);
border-top:1px solid var(--line); }
.preview .pv-icon { font-size:1.1rem; }
.preview .pv-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.9rem; }
.preview .pv-size { color:var(--muted); font-size:.75rem; flex:0 0 auto; }
.preview .pv-x { flex:0 0 auto; width:28px; height:28px; border-radius:50%; border:0; background:var(--surface);
color:var(--fg); font-size:.9rem; cursor:pointer; }
/* ── composer: a real phone footer. safe-area padding clears the Pixel gesture bar; JS keeps it above the keyboard ── */
.composer { flex:0 0 auto; border-top:1px solid var(--line); background:var(--header);
padding:8px 8px calc(8px + env(safe-area-inset-bottom));
display:flex; align-items:flex-end; gap:8px; }
.composer .attach { width:42px; height:42px; border-radius:50%; border:1px solid var(--line); background:var(--surface);
color:var(--fg); font-size:1.3rem; cursor:pointer; flex:0 0 auto; line-height:1; }
.composer textarea { flex:1 1 auto; min-width:0; resize:none; max-height:120px; min-height:42px; padding:10px 14px;
border-radius:21px; border:1px solid var(--line); background:#0c0d0f; color:var(--fg);
font-size:1rem; font-family:inherit; line-height:1.3; }
.composer .send { width:42px; height:42px; border-radius:50%; border:0; background:var(--sent); color:#fff;
font-size:1.2rem; cursor:pointer; flex:0 0 auto; line-height:1; }
.composer .send:disabled { opacity:.4; }
#fileInput { display:none; }
.toast { position:fixed; bottom:90px; left:50%; transform:translateX(-50%); background:#000c; color:#fff;
padding:9px 16px; border-radius:20px; font-size:.85rem; display:none; z-index:10; }
</style>
</head>
<body>
<div id="app">
<header>
<div class="h-row">
<span class="h-title" id="title">Quantum Forms</span>
<span class="h-timer" id="timer"></span>
</div>
<div class="e2e"><span class="dot"></span> Quantum-resistant · end-to-end encrypted</div>
</header>
<!-- join gate -->
<div id="gate">
<div class="box">
<h2>Join session</h2>
<p>Enter the session code you were given.</p>
<input id="code" maxlength="12" placeholder="CODE" autocomplete="off" autocapitalize="characters" inputmode="text">
<button id="joinBtn">Join</button>
</div>
</div>
<!-- chat -->
<div id="chat">
<div class="msgs" id="msgs"></div>
<!-- SimpleX-style: a selected file STAGES here as a preview chip above the input; send transmits it -->
<div class="preview" id="preview" style="display:none">
<span class="pv-icon">📎</span>
<span class="pv-name" id="pvName"></span>
<span class="pv-size" id="pvSize"></span>
<button class="pv-x" id="pvCancel" title="Remove">✕</button>
</div>
<div class="composer">
<button class="attach" id="attachBtn" title="Attach a file">+</button>
<input type="file" id="fileInput">
<textarea id="input" rows="1" placeholder="Message…" enterkeyhint="send"></textarea>
<button class="send" id="sendBtn" title="Send">➤</button>
</div>
</div>
<div class="toast" id="toast"></div>
</div>
<script>
// The browser handles the keyboard (interactive-widget=resizes-content). We only keep the message list scrolled
// to the bottom when the layout changes (keyboard open/close) — no viewport offset math to fight the browser.
(function () {
function toBottom() { var m = document.getElementById('msgs'); if (m) m.scrollTop = m.scrollHeight; }
window.addEventListener('resize', function () { setTimeout(toBottom, 50); });
document.addEventListener('focusin', function (e) { if (e.target && e.target.id === 'input') setTimeout(toBottom, 300); });
})();
</script>
<script type="module" src="./chat.js?v=12"></script>
</body>
</html>

10
plugin.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "quantum-forms",
"kind": "frontend-plugin",
"entry": "index.html",
"renderers": { "chat": "index.html", "qa-rounds": "assembly.html" },
"consumes": ["quantum-tunnel-baas"],
"baas_proxy_prefix": "/baas",
"description": "A parameterized form/QA plugin (chat + multiplayer QA rounds) on the shared Quantum Tunnel BaaS. Guest joins by code, no login; data is PQ-TLS + FORCE-RLS tenant-isolated.",
"buildable_by": "any team agent with git + BaaS access (no container access needed)"
}

85
trust-panel.js Normal file
View file

@ -0,0 +1,85 @@
// trust-panel.js — the TRUST TOPOLOGY MAP. For the trust-nobody, non-technical user: a VISUAL diagram of where
// their data goes (You → quantum tunnel → self-hosted server → back), what's protecting it, what is NOT collected,
// and an honest AI disclaimer. Self-contained inline SVG + styles (light+dark), no CDN.
(function () {
const PROTO_URL = 'https://datatracker.ietf.org/doc/draft-kwiatkowski-tls-ecdhe-mlkem/'
function injectStyles() {
if (document.getElementById('trust-panel-styles')) return
const s = document.createElement('style'); s.id = 'trust-panel-styles'
s.textContent = [
'.tp-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:flex-end;justify-content:center}',
'.tp-modal{width:100%;max-width:520px;max-height:90dvh;overflow-y:auto;background:#16181d;color:#eef1f4;border:1px solid #2a2e35}',
'html[data-theme="light"] .tp-modal{background:#fff;color:#141821;border-color:#e2e5ea}',
'.tp-head{display:flex;justify-content:space-between;align-items:center;padding:13px 16px;border-bottom:1px solid #2a2e35;position:sticky;top:0;background:inherit;font-weight:700}',
'html[data-theme="light"] .tp-head{border-color:#e2e5ea}',
'.tp-x{background:none;border:0;color:inherit;font-size:1.5rem;line-height:1;cursor:pointer;opacity:.55}',
'.tp-sec{padding:14px 16px;border-bottom:1px solid #2a2e35}',
'html[data-theme="light"] .tp-sec{border-color:#eef0f3}',
'.tp-sec:last-child{border-bottom:0}',
'.tp-h{font-weight:700;font-size:.82rem;margin-bottom:6px}',
'.tp-p{font-size:.8rem;opacity:.82;line-height:1.5}',
'.tp-map{width:100%;margin:2px 0 6px}',
'.tp-node text{font-size:9px;font-weight:700}',
'.tp-badge{display:inline-block;font-family:ui-monospace,monospace;font-weight:700;font-size:.76rem;color:#4f9bf0}',
'html[data-theme="light"] .tp-badge{color:#2563eb}',
'.tp-link{color:#4f9bf0;font-size:.78rem;text-decoration:none;border-bottom:1px solid currentColor}',
'html[data-theme="light"] .tp-link{color:#2563eb}',
'.tp-list{list-style:none;padding:0;margin:6px 0 0}',
'.tp-list li{font-size:.8rem;line-height:1.5;padding-left:18px;position:relative;opacity:.85}',
'.tp-list li::before{content:"\\2713";position:absolute;left:0;color:#3fb27f;font-weight:700}',
'.tp-warn{background:rgba(224,180,60,.12);border:1px solid #d8a83c;color:#e0b43c;padding:9px 11px;font-size:.78rem;line-height:1.5}',
].join('')
document.head.appendChild(s)
}
const esc = (s) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML }
async function pqStatus() { try { const r = await fetch('/pq-attest', { cache: 'no-store' }); if (!r.ok) return { pq: null }; return await r.json() } catch (_) { return { pq: null } } }
// the topology SVG: You ── tunnel ── Server ── tunnel ── You (round trip), self-contained, theme-aware via currentColor.
function topologySVG() {
const acc = '#4f9bf0', ok = '#3fb27f', mut = 'currentColor'
return '<svg class="tp-map" viewBox="0 0 320 150" fill="none">' +
// device (left)
'<g class="tp-node"><rect x="8" y="55" width="70" height="40" rx="4" fill="none" stroke="'+acc+'" stroke-width="1.5"/>' +
'<text x="43" y="72" text-anchor="middle" fill="'+mut+'">YOUR</text><text x="43" y="84" text-anchor="middle" fill="'+mut+'">DEVICE</text></g>' +
// server (right)
'<g class="tp-node"><rect x="242" y="55" width="70" height="40" rx="4" fill="none" stroke="'+ok+'" stroke-width="1.5"/>' +
'<text x="277" y="70" text-anchor="middle" fill="'+mut+'">SELF-HOSTED</text><text x="277" y="82" text-anchor="middle" fill="'+mut+'">SERVER</text></g>' +
// tunnel forward (top arrow)
'<path d="M80 66 L240 66" stroke="'+acc+'" stroke-width="1.5"/><path d="M234 62 L240 66 L234 70" stroke="'+acc+'" stroke-width="1.5"/>' +
'<text x="160" y="40" text-anchor="middle" class="tp-node" fill="'+acc+'">quantum tunnel →</text>' +
// tunnel back (bottom arrow)
'<path d="M240 84 L80 84" stroke="'+ok+'" stroke-width="1.5"/><path d="M86 80 L80 84 L86 88" stroke="'+ok+'" stroke-width="1.5"/>' +
'<text x="160" y="112" text-anchor="middle" class="tp-node" fill="'+ok+'">← quantum tunnel</text>' +
'</svg>'
}
async function openPanel() {
injectStyles()
const st = await pqStatus()
const usesAI = document.body.dataset.usesAi === '1' || true // most sessions here run an AI; show the honest disclaimer
const wrap = document.createElement('div'); wrap.className = 'tp-overlay'
wrap.innerHTML =
'<div class="tp-modal">' +
'<div class="tp-head"><span>How your data is protected</span><button class="tp-x">&times;</button></div>' +
// 1. the topology map
'<div class="tp-sec"><div class="tp-h">Where your data goes</div>' + topologySVG() +
'<div class="tp-p">Your data travels to a server and back — both directions through a quantum tunnel. The server is <b>self-hosted</b> by whoever shared this link.</div></div>' +
// 2. your browser's built-in PQ
'<div class="tp-sec"><div class="tp-h">Your browser already protects you</div>' +
'<div class="tp-p">Your browser has built-in <span class="tp-badge">X25519MLKEM768</span> — post-quantum encryption. It is designed to resist quantum computers \u2014 the kind of attack that breaks today\u2019s classical encryption. It is the industry\u2019s current standard, used by Cloudflare, Google and Chrome by default (NIST FIPS 203).</div>' +
'<div style="margin-top:6px"><a class="tp-link" href="'+PROTO_URL+'" target="_blank" rel="noopener">Read the protocol</a></div></div>' +
// 3. what we DON'T collect
'<div class="tp-sec"><div class="tp-h">What we do NOT collect</div><ul class="tp-list">' +
'<li>Your IP address is not stored.</li><li>No fingerprint of your browser or device.</li>' +
'<li>Nothing personally identifying — only the answers you choose to type.</li>' +
'<li>Your data is severed from your identity on our end.</li></ul></div>' +
// 4. honest AI disclaimer (conditional)
(usesAI ? '<div class="tp-sec"><div class="tp-warn"><b>This form uses AI.</b> Your answers travel over the quantum tunnel, but they are sent to an AI provider that generates the questions. That provider is not under your name or account — you are safer, but not zero-exposure. Not every Quantum Form uses AI.</div></div>' : '') +
'</div>'
document.body.appendChild(wrap)
wrap.querySelector('.tp-x').addEventListener('click', () => wrap.remove())
wrap.addEventListener('click', (e) => { if (e.target === wrap) wrap.remove() })
}
function wire() { document.querySelectorAll('.e2e, .h-neon').forEach(el => { el.style.cursor = 'pointer'; el.title = 'Tap to see how your data is protected'; el.addEventListener('click', openPanel) }) }
if (document.readyState !== 'loading') wire(); else document.addEventListener('DOMContentLoaded', wire)
})();