| |
| |
| |
| |
| |
|
|
| 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`; |
|
|
| |
| |
| |
| 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; |
| let lastThumbUrl = null; |
| let lastPayload = null; |
| let _stepTimer = null; |
|
|
| |
|
|
| const escapeHtml = (s) => |
| String(s).replace(/[&<>"']/g, (c) => |
| ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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)]; |
| } |
| |
| 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) { |
| |
| |
| const known = severity != null && severity >= 1 && severity <= 5; |
| const s = known ? severity : 0; |
| const CX = 100, CY = 105, R = 78; |
| |
| |
| |
| let segs = ""; |
| for (let k = 1; k <= 5; k++) { |
| const tc = 180 - (k - 0.5) * 36; |
| 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) { |
| |
| 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 = `<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 { |
| |
| 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); |
| |
| stage.style.setProperty("--sev", color); |
| stage.classList.toggle("stage--alarm", (payload.severity || 0) >= 4); |
| rollPanicNumber(payload.severity); |
| } |
|
|
| |
| |
| 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>'; |
| } |
|
|
| |
| 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})/); |
| if (m) return new Date(+m[1], +m[2] - 1, +m[3]); |
| m = s.toLowerCase().match( |
| /(\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); |
| } |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
|
|
| 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(); |
| return _actx; |
| } |
|
|
| |
| 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); } }; |
| |
| 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; |
| |
| 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); |
| })(); |
| |
| 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); |
| } |
|
|
| |
| |
| function pulseDeadlineBanner() { |
| if (REDUCE_MOTION) return; |
| const b = $("deadline-banner"); |
| if (!b || b.hidden) return; |
| b.classList.remove("pulse"); |
| void b.offsetWidth; |
| 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 }); |
| } |
|
|
| |
| function reactToResult(payload) { |
| if (payload.kind !== "letter") return; |
| const sev = payload.severity || 0; |
| if (payload.mascot === "allclear") { |
| |
| fireworks(); screenFlash("good"); chimeGood(); |
| } else if (sev >= 4) { |
| |
| screenFlash("alert"); chimeAlert(); |
| } else { |
| |
| 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"; } |
| } |
|
|
| |
|
|
| 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; |
| }); |
| } |
|
|
| |
| |
| 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 { } |
|
|
| 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 { } |
| } |
| 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"); |
| } |
|
|
| |
|
|
| 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; |
| 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(); |
| } |
|
|
| |
|
|
| async function getClient() { |
| if (!client) client = await Client.connect(window.location.origin); |
| return client; |
| } |
|
|
| async function analyze() { |
| if (busy || !pickedFiles.length) return; |
| audioCtx(); |
| busy = true; |
| $("analyze-btn").disabled = true; |
| $("results").hidden = true; |
| $("refusal").hidden = true; |
| $("deadline-banner").hidden = true; |
| clearReadSource(); |
| $("share-row").hidden = true; |
| setPanic(null); |
| setQuip(""); |
| startReadingSteps(); |
|
|
| |
| |
| 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(); |
| |
| |
| 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]; |
| |
| 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; |
| 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(); |
| 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; |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| 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; } |
| } |
|
|
| |
| |
| function clearSelection() { |
| pickedFiles = []; |
| const fi = $("file-input"); |
| if (fi) fi.value = ""; |
| const ul = $("file-list"); |
| ul.innerHTML = ""; |
| ul.hidden = true; |
| $("dropzone-idle").hidden = false; |
| $("analyze-btn").disabled = true; |
| } |
|
|
| |
| function hideVerdict() { |
| $("results").hidden = true; |
| $("refusal").hidden = true; |
| $("deadline-banner").hidden = true; |
| $("share-row").hidden = true; |
| stopReadingSteps(); |
| setPanic(null); |
| setQuip(""); |
| } |
|
|
| |
| function resetView() { |
| hideVerdict(); |
| clearSelection(); |
| clearReadSource(); |
| pendingSource = null; |
| setDropzoneText(DZ_DEFAULT); |
| $("dropzone").classList.remove("dropzone--ready"); |
| setMascot("idle", "Awaiting your scary letter."); |
| } |
|
|
| |
| |
| function armForNext() { |
| clearSelection(); |
| setDropzoneText(DZ_NEXT); |
| $("dropzone").classList.add("dropzone--ready"); |
| } |
|
|
| |
| 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(); |
| 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; |
| 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); |
|
|
| |
| setMuted(muted); |
| $("sound-toggle").addEventListener("click", () => { setMuted(!muted); if (!muted) chimePop(); }); |
|
|
| |
| 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); |
| }); |
|
|
| |
| 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); |
|
|
| |
| |
| const deepLink = location.hash.match(/^#example-([0-4])$/); |
| if (deepLink) loadExample(Number(deepLink[1])); |
|
|
| |
| getClient().catch(() => { }); |
|
|