/* Bureaucat custom frontend (Phase 4, Off-Brand badge). * * ZeroGPU contract: ALL API calls go through @gradio/client — it forwards the * HF iframe auth headers (X-IP-Token) that ZeroGPU quota attribution needs. * A raw fetch() here would silently burn the anonymous quota tier. */ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; const $ = (id) => document.getElementById(id); const MASCOT = (state) => `/assets/mascot/${state}.png`; // MUST mirror EXAMPLE_LETTERS in app.py IN ORDER — the gallery sends the array // index to /example, which indexes app.py's list. If the orders drift, clicking a // thumbnail shows a different letter's result. const EXAMPLES = [ { slug: "skatteverket-slutskattebesked", label: "Skatteverket — tax refund (severity 1)" }, { slug: "vardcentral-kallelse", label: "Vårdcentral — appointment (severity 2)" }, { slug: "forsakringskassan-komplettering", label: "Försäkringskassan — submit documents (severity 3)" }, { slug: "csn-aterkrav", label: "CSN — repayment demand (severity 4)" }, { slug: "migrationsverket-uppehallstillstand", label: "Migrationsverket — permit at risk (severity 5)" }, ]; let client = null; let pickedFiles = []; let busy = false; let pendingSource = null; // { name, thumbUrl } for the letter currently being read let lastThumbUrl = null; // object URL to revoke when it's replaced let lastPayload = null; // last rendered verdict (for the shareable card) let _stepTimer = null; // rotating reading-step interval /* ---------- tiny safe renderers ---------- */ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); /* Escape first, then allow exactly **bold** and *italic*; paragraphs on blank lines. */ function mdLite(text) { const esc = escapeHtml(text.trim()); const inline = esc .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1"); return inline .split(/\n{2,}/) .map((p) => `

${p.replace(/\n/g, "
")}

`) .join(""); } function setMascot(state, statusText) { const m = $("mascot"); m.src = MASCOT(state); m.alt = `Bureaucat is ${state.replace("_", " ")}`; m.className = `mascot mascot--${state}`; if (statusText !== undefined) $("status-line").textContent = statusText; } // ---- Delightful loading: rotating step messages + skeleton shimmer ---- const READING_STEPS = [ "🤓 Putting on my reading glasses…", "🔍 Decoding the bureaucratese…", "📅 Hunting for every deadline…", "🧮 Checking every number twice…", "🐾 Forming my expert opinion…", ]; function startReadingSteps() { setMascot("reading", READING_STEPS[0]); $("skeleton").hidden = false; let i = 0; clearInterval(_stepTimer); _stepTimer = setInterval(() => { i = (i + 1) % READING_STEPS.length; $("status-line").textContent = READING_STEPS[i]; }, 2200); } function stopReadingSteps() { clearInterval(_stepTimer); _stepTimer = null; $("skeleton").hidden = true; } // ---- Panic-o-meter: a cartoon gauge dial with a wobbling needle ---- const FACES = { 1: "😌", 2: "🙂", 3: "😬", 4: "😰", 5: "😱" }; const SEG_COLORS = ["#27AE60", "#8BC34A", "#F39C12", "#E67E22", "#C0392B"]; function polar(cx, cy, r, deg) { const a = (deg * Math.PI) / 180; return [cx + r * Math.cos(a), cy - r * Math.sin(a)]; } // Sample the true circle as a polyline (no SVG arc-flag ambiguity). function arcPoints(cx, cy, r, t1, t2) { const pts = []; const step = t1 > t2 ? -3 : 3; for (let t = t1; step < 0 ? t > t2 : t < t2; t += step) { const [x, y] = polar(cx, cy, r, t); pts.push(`${x.toFixed(1)},${y.toFixed(1)}`); } const [xe, ye] = polar(cx, cy, r, t2); pts.push(`${xe.toFixed(1)},${ye.toFixed(1)}`); return pts.join(" "); } function buildGauge(severity, word, color) { // severity may be null/undefined when a dense letter read fine but lost its // SEVERITY line — render an "unclear" dial (greyed band, no needle/marker). const known = severity != null && severity >= 1 && severity <= 5; const s = known ? severity : 0; const CX = 100, CY = 105, R = 78; // Equal-thickness zones forming one continuous curved band (no "active bar" — // a thickened top zone read as a flat horizontal line). The needle + a marker // dot on the band indicate the level instead. let segs = ""; for (let k = 1; k <= 5; k++) { const tc = 180 - (k - 0.5) * 36; // segment centre angle (1=left … 5=right) const pts = arcPoints(CX, CY, R, tc + 16, tc - 16); const active = known && k === s; segs += ``; const [nx, ny] = polar(CX, CY, R + 17, tc); segs += `${k}`; } let marker = "", needle = ""; if (known) { // Glowing marker dot sitting on the band at the active zone — a dot, not a bar. const [mx, my] = polar(CX, CY, R, 180 - (s - 0.5) * 36); marker = ``; const phi = (s - 3) * 36; // needle rotation; 0 = straight up (level 3) // Static transform = correct end state even if SMIL is unavailable; SMIL adds the wobble. needle = `` + ``; } else { // unclear: a "?" where the needle would be, no hub-needle needle = `?`; } const face = known ? FACES[s] : "🤔"; const num = known ? `${s}/5` : `?/5`; return ``; } function setPanic(payload) { const panic = $("panic"); const stage = $("stage"); if (!payload) { panic.innerHTML = '
🐾 Feed me a letter and I\'ll tell you how worried to be
'; stage.style.removeProperty("--sev"); stage.classList.remove("stage--alarm"); return; } const label = payload.severity_label || "Panic level unclear"; const word = label.includes("—") ? label.split("—")[1].trim() : label; const color = payload.severity_color || "#9E9E9E"; panic.innerHTML = buildGauge(payload.severity, word, color); // Severity colour-wash over the stage + a red alarm aura at the high end. stage.style.setProperty("--sev", color); stage.classList.toggle("stage--alarm", (payload.severity || 0) >= 4); rollPanicNumber(payload.severity); } // Roll the panic number 1→N for a little gauge-spinning drama (skipped on reduced motion; // buildGauge already shows the final number, so nothing is lost). function rollPanicNumber(sev) { if (sev == null || REDUCE_MOTION) return; const el = document.querySelector(".panic-gauge__num"); if (!el) return; const suffix = '/5'; let cur = 1; el.innerHTML = `1${suffix}`; const iv = setInterval(() => { cur += 1; if (cur > sev) { clearInterval(iv); return; } el.innerHTML = `${cur}${suffix}`; el.classList.remove("num-pop"); void el.offsetWidth; el.classList.add("num-pop"); }, 150); } function setQuip(quip) { const q = $("quip"); if (quip && quip.trim()) { q.innerHTML = `Bureaucat says: ${escapeHtml(quip.trim())}`; q.hidden = false; } else { q.hidden = true; } } function renderChecklist(actions) { const box = $("actions"); box.innerHTML = ""; const lines = actions.split("\n").map((l) => l.trim()).filter(Boolean); for (const line of lines) { const m = line.match(/^(?:[-*•]|\d+[.)])\s+(.*)$/); if (m) { const label = document.createElement("label"); label.className = "check-item"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.addEventListener("change", () => label.classList.toggle("done", cb.checked)); const span = document.createElement("span"); span.innerHTML = mdLite(m[1]); label.append(cb, span); box.appendChild(label); } else { const p = document.createElement("p"); p.className = "prose"; p.innerHTML = mdLite(line); box.appendChild(p); } } if (!box.children.length) box.innerHTML = '

Nothing to do. Enjoy your fika. ☕

'; } // ---- Deadline countdowns: turn a date string into "in 12 days" / "overdue" ---- const SV_MONTHS = { januari: 0, februari: 1, mars: 2, april: 3, maj: 4, juni: 5, juli: 6, augusti: 7, september: 8, oktober: 9, november: 10, december: 11, }; function parseLetterDate(s) { if (!s) return null; let m = s.match(/(\d{4})-(\d{2})-(\d{2})/); // ISO 2026-06-22 if (m) return new Date(+m[1], +m[2] - 1, +m[3]); m = s.toLowerCase().match( // "den 5 maj 2026" /(\d{1,2})\s+(januari|februari|mars|april|maj|juni|juli|augusti|september|oktober|november|december)\s+(\d{4})/); if (m) return new Date(+m[3], SV_MONTHS[m[2]], +m[1]); return null; } function countdownBadge(value) { const date = parseLetterDate(value); if (!date) return null; const today = new Date(); today.setHours(0, 0, 0, 0); const n = Math.round((date - today) / 86400000); if (n < 0) return { text: `overdue by ${-n} day${-n === 1 ? "" : "s"}`, cls: "overdue" }; if (n === 0) return { text: "today", cls: "soon" }; if (n === 1) return { text: "tomorrow", cls: "soon" }; if (n <= 7) return { text: `in ${n} days`, cls: "soon" }; if (n <= 30) return { text: `in ${n} days`, cls: "near" }; return { text: `in ${Math.round(n / 30)} month${Math.round(n / 30) === 1 ? "" : "s"}`, cls: "far" }; } function renderDeadlines(payload) { const ul = $("deadlines"); ul.innerHTML = ""; const banner = $("deadline-banner"); banner.hidden = true; if (!payload.deadline_items.length) { ul.innerHTML = '
  • None found.
  • '; return; } let firstDated = null; for (const item of payload.deadline_items) { const cd = countdownBadge(item.value); if (cd && !firstDated) firstDated = { item, cd }; const li = document.createElement("li"); li.innerHTML = `${escapeHtml(item.value)}` + (item.note ? ` — ${escapeHtml(item.note)}` : "") + (cd ? ` ${cd.text}` : ""); ul.appendChild(li); } // Banner highlights the soonest dated deadline (not just the first listed value). const lead = firstDated || { item: payload.deadline_items[0], cd: null }; banner.innerHTML = `⏰ Mark this: ${escapeHtml(lead.item.value)}` + (lead.item.note ? ` — ${escapeHtml(lead.item.note)}` : "") + (lead.cd ? ` ${lead.cd.text}` : ""); banner.hidden = false; } function renderGrounding(payload) { const g = $("grounding"); if (payload.grounded) { g.className = "grounding ok"; g.textContent = "✓ Verified: every value above appears verbatim in the letter — nothing invented."; } else { g.className = "grounding fail"; g.textContent = "⚠ Verification failed for: " + payload.invented_values.join(", ") + " — these were NOT found in the letter. Double-check against the original."; } g.hidden = false; } /* ---------- celebration: reactive visuals + synth sound ---------- * Sound is synthesized in-browser via Web Audio (no audio files, no CDN) so it * stays fully "Off the Grid". A mute toggle persists in localStorage. Visuals * respect prefers-reduced-motion. */ const REDUCE_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const BCAT_COLORS = ["#E91E63", "#84CC16", "#FFD166", "#FFFFFF", "#FF8A3D"]; let muted = localStorage.getItem("bcat-muted") === "1"; let _actx = null; function audioCtx() { if (!_actx) { try { _actx = new (window.AudioContext || window.webkitAudioContext)(); } catch { return null; } } if (_actx.state === "suspended") _actx.resume(); // must be primed by a user gesture return _actx; } // One enveloped oscillator note. start/dur in seconds, relative to now. function tone(freq, start, dur, type = "sine", peak = 0.16) { const ac = audioCtx(); if (!ac) return; const t0 = ac.currentTime + start; const osc = ac.createOscillator(), g = ac.createGain(); osc.type = type; osc.frequency.value = freq; osc.connect(g); g.connect(ac.destination); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(peak, t0 + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); osc.start(t0); osc.stop(t0 + dur + 0.03); } const chimeGood = () => { if (!muted) [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => tone(f, i * 0.09, 0.4, "triangle", 0.15)); }; const chimeAlert = () => { if (!muted) { tone(311.13, 0, 0.26, "sawtooth", 0.1); tone(233.08, 0.22, 0.45, "sawtooth", 0.1); } }; const chimePop = () => { if (!muted) { tone(880, 0, 0.12, "sine", 0.12); tone(1318.5, 0.08, 0.16, "sine", 0.1); } }; // Calm "noted" cue for action-needed-but-manageable — neutral, NOT celebratory. const noteTone = () => { if (!muted) { tone(587.33, 0, 0.16, "sine", 0.12); tone(440, 0.14, 0.26, "sine", 0.11); } }; function fireworks() { if (!window.confetti || REDUCE_MOTION) return; // dual side-cannons streaming for ~1.3s … const end = Date.now() + 1300; (function frame() { window.confetti({ particleCount: 7, angle: 60, spread: 72, startVelocity: 55, origin: { x: 0, y: 0.7 }, colors: BCAT_COLORS }); window.confetti({ particleCount: 7, angle: 120, spread: 72, startVelocity: 55, origin: { x: 1, y: 0.7 }, colors: BCAT_COLORS }); if (Date.now() < end) requestAnimationFrame(frame); })(); // … plus staggered center bursts for a real "show" window.confetti({ particleCount: 150, spread: 100, origin: { y: 0.62 }, colors: BCAT_COLORS, scalar: 1.1 }); setTimeout(() => window.confetti({ particleCount: 130, spread: 130, startVelocity: 45, origin: { y: 0.55 }, colors: BCAT_COLORS }), 300); setTimeout(() => window.confetti({ particleCount: 90, spread: 160, decay: 0.92, scalar: 1.3, origin: { y: 0.5 }, colors: BCAT_COLORS }), 650); } // Action-needed cue: glow the deadline banner to pull the eye to the date/amount. // Localized and non-celebratory — the right signal when there's something to do. function pulseDeadlineBanner() { if (REDUCE_MOTION) return; const b = $("deadline-banner"); if (!b || b.hidden) return; b.classList.remove("pulse"); void b.offsetWidth; // force reflow so the animation restarts on repeat b.classList.add("pulse"); b.addEventListener("animationend", () => b.classList.remove("pulse"), { once: true }); } function screenFlash(kind) { if (REDUCE_MOTION) return; const el = document.createElement("div"); el.className = "screen-flash screen-flash--" + kind; document.body.appendChild(el); el.addEventListener("animationend", () => el.remove(), { once: true }); } // Verdict-reactive celebration: good news → fireworks; severe → red alert pulse; else → puff. function reactToResult(payload) { if (payload.kind !== "letter") return; const sev = payload.severity || 0; if (payload.mascot === "allclear") { // genuinely good news — nothing to pay, no deadline. Celebrate. fireworks(); screenFlash("good"); chimeGood(); } else if (sev >= 4) { // urgent — act now screenFlash("alert"); chimeAlert(); } else { // action needed but manageable — draw the eye to the deadline, do NOT celebrate pulseDeadlineBanner(); noteTone(); } } function setMuted(v) { muted = v; localStorage.setItem("bcat-muted", v ? "1" : "0"); const b = $("sound-toggle"); if (b) { b.textContent = v ? "🔇" : "🔊"; b.title = v ? "Sound off — click to enable" : "Sound on — click to mute"; } } /* ---------- shareable verdict card ---------- */ function _roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); if (ctx.roundRect) { ctx.roundRect(x, y, w, h, r); return; } ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function _wrapText(ctx, text, maxWidth) { const words = text.split(/\s+/), lines = []; let line = ""; for (const w of words) { const test = line ? `${line} ${w}` : w; if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = w; } else line = test; } if (line) lines.push(line); return lines; } function _loadImage(src) { return new Promise((res, rej) => { const img = new Image(); img.onload = () => res(img); img.onerror = rej; img.src = src; }); } // Draw a 1080×1080 social card of the verdict and share/download it. Pure canvas — // no external lib, stays "Off the Grid". Doubles as the hackathon social-post asset. async function generateShareCard() { const p = lastPayload; if (!p || p.kind !== "letter") return; if (!muted) chimePop(); const W = 1080, H = 1080; const c = document.createElement("canvas"); c.width = W; c.height = H; const ctx = c.getContext("2d"); const FONT = "Fredoka, system-ui, sans-serif"; ctx.textAlign = "center"; const bg = ctx.createLinearGradient(0, 0, W, H); bg.addColorStop(0, "#FFE3F1"); bg.addColorStop(1, "#FFF6D6"); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); ctx.fillStyle = "#ffffff"; _roundRect(ctx, 56, 56, W - 112, H - 112, 48); ctx.fill(); ctx.fillStyle = "#E91E63"; ctx.font = `700 62px ${FONT}`; ctx.fillText("🐱 Bureaucat", W / 2, 168); ctx.fillStyle = "#7a7a7a"; ctx.font = `500 28px ${FONT}`; ctx.fillText("read my scary Swedish letter so I didn't have to", W / 2, 214); try { const cat = await _loadImage(MASCOT(p.mascot || "idle")); const cw = 300, ch = cat.height * (cw / cat.width); ctx.drawImage(cat, W / 2 - cw / 2, 250, cw, ch); } catch { /* mascot optional */ } const sev = p.severity; const color = p.severity_color || "#9E9E9E"; const face = FACES[sev] || "🤔"; ctx.fillStyle = color; ctx.font = `700 150px ${FONT}`; ctx.fillText(`${face} ${sev != null ? sev : "?"}/5`, W / 2, 690); const label = p.severity_label || "Panic level unclear"; ctx.font = `600 46px ${FONT}`; ctx.fillText(label.includes("—") ? label.split("—")[1].trim() : label, W / 2, 752); ctx.fillStyle = "#2b2b2b"; ctx.font = `400 33px ${FONT}`; let tldr = (p.tldr || "").replace(/\*\*/g, "").replace(/\s+/g, " ").trim(); if (tldr.length > 230) tldr = tldr.slice(0, 227) + "…"; _wrapText(ctx, tldr, W - 220).slice(0, 4).forEach((ln, i) => ctx.fillText(ln, W / 2, 840 + i * 46)); ctx.fillStyle = "#9b9b9b"; ctx.font = `500 25px ${FONT}`; ctx.fillText("runs on one small model, inside the Space — no cloud", W / 2, H - 88); c.toBlob(async (blob) => { if (!blob) return; const file = new File([blob], "bureaucat-panic.png", { type: "image/png" }); if (navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ files: [file], title: "My Bureaucat panic level" }); return; } catch { /* fall through to download */ } } const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "bureaucat-panic.png"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000); }, "image/png"); } /* ---------- payload → page ---------- */ function showResult(payload, statusText) { stopReadingSteps(); lastPayload = payload; $("results").hidden = true; $("refusal").hidden = true; $("share-row").hidden = true; if (payload.kind === "letter") { setPanic(payload); setQuip(payload.quip); setMascot(payload.mascot, statusText); $("tldr").innerHTML = mdLite(payload.tldr); $("why").innerHTML = mdLite(payload.why); renderChecklist(payload.actions); renderDeadlines(payload); renderGrounding(payload); $("results").hidden = false; $("share-row").hidden = false; // letters have a panic level worth sharing setTimeout(() => reactToResult(payload), 220); } else if (payload.kind === "refusal") { setPanic(null); setQuip(payload.quip); setMascot(payload.mascot, "That didn't look right."); $("refusal-title").textContent = payload.doctype === "unreadable" ? "🙀 I can't read that…" : "🤨 That's not a Swedish letter…"; $("refusal-guidance").textContent = payload.guidance; $("refusal").hidden = false; $("deadline-banner").hidden = true; } else { setPanic(null); setQuip(""); setMascot("idle", "Something went wrong — try again."); $("refusal-title").textContent = "😿 Hmm, that didn't work"; $("refusal-guidance").textContent = payload.guidance; $("refusal").hidden = false; $("deadline-banner").hidden = true; } if (pendingSource) setReadSource(pendingSource.name, pendingSource.thumbUrl); armForNext(); // verdict stays; upload area re-arms (selection cleared, CTA off) } /* ---------- API ---------- */ async function getClient() { if (!client) client = await Client.connect(window.location.origin); return client; } async function analyze() { if (busy || !pickedFiles.length) return; audioCtx(); // prime audio within this click gesture so the result sound can play later busy = true; $("analyze-btn").disabled = true; $("results").hidden = true; $("refusal").hidden = true; $("deadline-banner").hidden = true; clearReadSource(); // hide any stale "verdict for" while this one reads $("share-row").hidden = true; setPanic(null); setQuip(""); startReadingSteps(); // rotating step messages + skeleton shimmer // Remember which letter this verdict is for (pickedFiles is cleared once it renders). // Thumbnail only for a single image; PDFs/multi just show the name. const files = pickedFiles.slice(); const first = files[0]; let thumbUrl = null; if (files.length === 1 && /\.(jpe?g|png)$/i.test(first.name)) { thumbUrl = URL.createObjectURL(first); lastThumbUrl = thumbUrl; } pendingSource = { name: files.length > 1 ? `${first.name} +${files.length - 1} more` : first.name, thumbUrl, }; try { const c = await getClient(); // Race the prediction against a timeout so a dropped/hung connection can never // leave the UI stuck `busy` (which would silently dead-click "Read it for me"). let timer; const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error("timeout")), 180000); }); let res; try { res = await Promise.race([ c.predict("/analyze", { files: pickedFiles.map((f) => handle_file(f)), beginner: $("beginner").checked, }), timeout, ]); } finally { clearTimeout(timer); } const payload = res.data[0]; // Brief "verifying" beat so the grounding check is a visible moment (D2-05). setMascot("verifying", "Double-checking every number against the letter…"); await new Promise((r) => setTimeout(r, 700)); showResult(payload, "Verdict delivered."); } catch (err) { console.error(err); client = null; // drop the cached client so the next attempt reconnects fresh showResult({ kind: "error", guidance: "The Space hiccuped (queue or quota). Wait a moment and try again — or tap an example below (zero GPU).", }); } finally { busy = false; $("analyze-btn").disabled = pickedFiles.length === 0; } } async function loadExample(index) { if (busy) return; audioCtx(); // prime audio within this click gesture busy = true; $("refusal").hidden = true; clearReadSource(); setMascot("reading", "Fetching a pre-read letter — zero GPU…"); const ex = EXAMPLES[index]; pendingSource = ex ? { name: ex.label, thumbUrl: `/letters/${ex.slug}.png` } : null; try { const c = await getClient(); const res = await c.predict("/example", { index }); showResult(res.data[0], "Pre-computed example — cost you nothing."); } catch (err) { console.error(err); setMascot("idle", "Couldn't load that example. Try again."); } finally { busy = false; } } /* ---------- wiring ---------- */ /* ---------- UI state model ---------- * The upload area and the CTA always agree on one fact: is a letter selected? * • CTA enabled ⟺ exactly when a file is selected and ready to read (SELECTED) * • CTA disabled ⟺ nothing selected (EMPTY at start, ARMED after a verdict, or READING) * Transitions: * EMPTY page load / nothing chosen → idle invite, CTA off * SELECTED a file is picked → filename shown, CTA on, prior verdict cleared * READING CTA clicked → CTA off (busy), mascot reading * ARMED a verdict is on screen → selection cleared, dropzone re-arms as the * highlighted next-letter target, CTA off */ const DZ_DEFAULT = { icon: "📬", title: "Drop a saved photo or PDF here", hint: "upload an existing image/PDF file · multi-page is fine · or click to browse", }; const DZ_NEXT = { icon: "📸", title: "Drop the next letter here to scan it", hint: "your verdict is below — drop a new file (or click) to read another", }; function setDropzoneText(t) { $("dz-icon").textContent = t.icon; $("dz-title").textContent = t.title; $("dz-hint").textContent = t.hint; } // "Verdict for " indicator — keeps the result attributable after the // dropzone re-arms (you can no longer see which file was read otherwise). function setReadSource(name, thumbUrl) { $("read-source-name").textContent = name; const thumb = $("read-source-thumb"); if (thumbUrl) { thumb.src = thumbUrl; thumb.hidden = false; } else { thumb.removeAttribute("src"); thumb.hidden = true; } $("read-source").hidden = false; } function clearReadSource() { $("read-source").hidden = true; if (lastThumbUrl) { URL.revokeObjectURL(lastThumbUrl); lastThumbUrl = null; } } // Forget any selected file and turn the CTA off — the single source of truth for // "no letter is loaded". Always leaves the dropzone showing its idle invite. function clearSelection() { pickedFiles = []; const fi = $("file-input"); if (fi) fi.value = ""; // so re-picking the same file still fires `change` const ul = $("file-list"); ul.innerHTML = ""; ul.hidden = true; $("dropzone-idle").hidden = false; $("analyze-btn").disabled = true; } // Hide any verdict currently on screen (does not touch the file selection). function hideVerdict() { $("results").hidden = true; $("refusal").hidden = true; $("deadline-banner").hidden = true; $("share-row").hidden = true; stopReadingSteps(); setPanic(null); setQuip(""); } // EMPTY — clean slate (page load, or after the user clears everything). function resetView() { hideVerdict(); clearSelection(); clearReadSource(); pendingSource = null; setDropzoneText(DZ_DEFAULT); $("dropzone").classList.remove("dropzone--ready"); setMascot("idle", "Awaiting your scary letter."); } // ARMED — a verdict stays on screen; the upload area becomes the obvious, highlighted // target for the NEXT letter, with the CTA off because nothing is selected yet. function armForNext() { clearSelection(); setDropzoneText(DZ_NEXT); $("dropzone").classList.add("dropzone--ready"); } // SELECTED — a new file was chosen: clear any prior verdict, list the file, enable the CTA. function setFiles(fileList) { hideVerdict(); $("dropzone").classList.remove("dropzone--ready"); setDropzoneText(DZ_DEFAULT); pickedFiles = Array.from(fileList).filter((f) => /\.(jpe?g|png|pdf)$/i.test(f.name)); if (!pickedFiles.length) { clearSelection(); // nothing valid picked → back to EMPTY return; } const ul = $("file-list"); ul.innerHTML = ""; for (const f of pickedFiles) { const li = document.createElement("li"); li.textContent = `${f.name} (${(f.size / 1024).toFixed(0)} kB)`; ul.appendChild(li); } ul.hidden = false; $("dropzone-idle").hidden = true; $("analyze-btn").disabled = false; // a letter is ready → CTA on setMascot("idle", "Ready when you are — tap “Read it for me!”"); } const dz = $("dropzone"); dz.addEventListener("click", () => $("file-input").click()); dz.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("file-input").click(); } }); $("file-input").addEventListener("change", (e) => setFiles(e.target.files)); ["dragenter", "dragover"].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("is-dragover"); })); ["dragleave", "drop"].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("is-dragover"); })); dz.addEventListener("drop", (e) => setFiles(e.dataTransfer.files)); $("analyze-btn").addEventListener("click", analyze); $("share-btn").addEventListener("click", generateShareCard); // Sound mute toggle (persists in localStorage; a click also primes the audio ctx) setMuted(muted); $("sound-toggle").addEventListener("click", () => { setMuted(!muted); if (!muted) chimePop(); }); // Gallery thumbnails (static files — zero GPU until clicked, and zero even then). const gal = $("gallery"); EXAMPLES.forEach((ex, i) => { const btn = document.createElement("button"); btn.className = "gallery__item"; btn.innerHTML = `${escapeHtml(ex.label)}` + `${escapeHtml(ex.label)}`; btn.addEventListener("click", () => loadExample(i)); gal.appendChild(btn); }); // Confetti lib (best-effort; no-op if CDN blocked). const s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"; document.head.appendChild(s); // Deep-link: /#example-2 auto-loads a pre-computed example (zero GPU) — // lets the demo video / judges land straight on a rendered verdict. const deepLink = location.hash.match(/^#example-([0-4])$/); if (deepLink) loadExample(Number(deepLink[1])); // Warm the client connection in the background. getClient().catch(() => { /* connect lazily on first use instead */ });