quantum-forms-dist/assembly.js
2026-09-24 02:21:39 +00:00

810 lines
50 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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