bureaucat / frontend /app.js
ravinsingh15's picture
Bureaucat — Build Small Hackathon submission (Qwen3-VL-8B, ZeroGPU, gr.Server)
6b5e47d
Raw
History Blame Contribute Delete
32.2 kB
/* 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return inline
.split(/\n{2,}/)
.map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`)
.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 += `<polyline points="${pts}" fill="none" stroke="${SEG_COLORS[k - 1]}" ` +
`stroke-width="13" stroke-linecap="round" opacity="${active ? 1 : known ? 0.5 : 0.4}"/>`;
const [nx, ny] = polar(CX, CY, R + 17, tc);
segs += `<text x="${nx.toFixed(1)}" y="${(ny + 3.5).toFixed(1)}" text-anchor="middle" ` +
`font-size="11" font-weight="700" ${active ? `fill="${SEG_COLORS[k - 1]}"` : 'class="gauge-tick"'} ` +
`opacity="${active ? 1 : 0.6}">${k}</text>`;
}
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 = `<circle cx="${mx.toFixed(1)}" cy="${my.toFixed(1)}" r="9" ` +
`fill="${color}" filter="url(#glow)"/><circle cx="${mx.toFixed(1)}" cy="${my.toFixed(1)}" r="3.5" fill="#fff"/>`;
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 = `<polygon class="gauge-needle" points="100,38 105.5,105 94.5,105" ` +
`transform="rotate(${phi} 100 105)">` +
`<animateTransform attributeName="transform" type="rotate" ` +
`values="0 100 105; ${(phi * 1.18).toFixed(1)} 100 105; ${(phi * 0.9).toFixed(1)} 100 105; ${phi} 100 105" ` +
`keyTimes="0;0.6;0.82;1" dur="0.8s" fill="freeze"/></polygon>`;
} else {
// unclear: a "?" where the needle would be, no hub-needle
needle = `<text x="100" y="84" text-anchor="middle" font-size="42" font-weight="700" class="gauge-tick">?</text>`;
}
const face = known ? FACES[s] : "🤔";
const num = known ? `${s}<span style="font-size:.6em">/5</span>` : `?<span style="font-size:.6em">/5</span>`;
return `<div class="panic-gauge" role="img" aria-label="Panic level ${known ? s + " of 5" : "unclear"}${escapeHtml(word)}">` +
`<div class="panic-gauge__title">🐾 Panic-o-meter</div>` +
`<svg class="panic-gauge__svg" viewBox="0 0 200 126">` +
`<defs><filter id="glow" x="-40%" y="-40%" width="180%" height="180%">` +
`<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${color}" flood-opacity="0.75"/></filter></defs>` +
segs + marker + needle +
(known ? `<circle cx="100" cy="105" r="9" class="gauge-needle"/>` +
`<circle cx="100" cy="105" r="3.5" fill="#fff" opacity="0.85"/>` : "") +
`</svg>` +
`<div class="panic-gauge__readout">` +
`<span class="panic-gauge__face">${face}</span>` +
`<span class="panic-gauge__num" style="color:${color}">${num}</span>` +
`<span class="panic-gauge__word" style="color:${color}">${escapeHtml(word)}</span>` +
`</div></div>`;
}
function setPanic(payload) {
const panic = $("panic");
const stage = $("stage");
if (!payload) {
panic.innerHTML =
'<div class="panic__placeholder">🐾 Feed me a letter and I\'ll tell you how worried to be</div>';
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 = '<span style="font-size:.6em">/5</span>';
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 = `<strong>Bureaucat says:</strong> ${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 = '<p class="prose">Nothing to do. Enjoy your fika. ☕</p>';
}
// ---- 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 = '<li class="none">None found.</li>';
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 =
`<mark>${escapeHtml(item.value)}</mark>` +
(item.note ? ` — ${escapeHtml(item.note)}` : "") +
(cd ? ` <span class="cd cd--${cd.cls}">${cd.text}</span>` : "");
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: <span class="value">${escapeHtml(lead.item.value)}</span>` +
(lead.item.note ? ` — ${escapeHtml(lead.item.note)}` : "") +
(lead.cd ? ` <span class="cd cd--${lead.cd.cls}">${lead.cd.text}</span>` : "");
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 <letter>" 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 =
`<img src="/letters/${ex.slug}.png" alt="${escapeHtml(ex.label)}" loading="lazy">` +
`<span>${escapeHtml(ex.label)}</span>`;
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 */ });