readyroom 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +190 -11
  2. package/dist/server.mjs +557 -752
  3. package/package.json +5 -1
package/dist/server.mjs CHANGED
@@ -7322,6 +7322,13 @@ var UNDO_WINDOW_MS = 5 * 60 * 1e3;
7322
7322
  var RECENT_WINDOW_MS = 5 * 60 * 1e3;
7323
7323
  var POLL_INTERVAL_MS = 250;
7324
7324
  var DEFAULT_STATE_DIR = "./.state";
7325
+ var MEDIA_DIR = "media";
7326
+ var MAX_MEDIA_FILES = 8;
7327
+ var MAX_MEDIA_BYTES_PER_FILE = 8 * 1024 * 1024;
7328
+ var MAX_MEDIA_BYTES_PER_TICKET = 24 * 1024 * 1024;
7329
+ var MAX_ANNOTATION_BYTES = 8 * 1024 * 1024;
7330
+ var MAX_ANNOTATIONS = 32;
7331
+ var MAX_BODY_BYTES = 48 * 1024 * 1024;
7325
7332
 
7326
7333
  // src/config.ts
7327
7334
  var ENV_ALLOWED_ORIGINS = "READYROOM_ALLOWED_ORIGINS";
@@ -7501,750 +7508,80 @@ async function acquireDaemonRole(stateDir, port) {
7501
7508
  }
7502
7509
 
7503
7510
  // public/index.html
7504
- var public_default = `<!DOCTYPE html>
7505
- <html lang="en">
7506
- <head>
7507
- <meta charset="utf-8">
7508
- <meta name="viewport" content="width=device-width, initial-scale=1">
7509
- <title>readyroom</title>
7510
- <style>
7511
- /* No external fonts, no external origins, no images fetched over the network.
7512
- Everything below is self-contained so the page renders with the network cut. */
7511
+ var public_default = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>readyroom</title>\n<!-- Suppress the /favicon.ico request. The page ships no icon and fetches nothing\n over the network; an inline empty data URI keeps the console free of a 404. -->\n<link rel="icon" href="data:,">\n<style>\n /* No external fonts, no external origins, no images fetched over the network.\n Everything below is self-contained so the page renders with the network cut. */\n\n :root {\n --ink: #111;\n --paper: #fff;\n --muted: #555;\n --line: #111;\n --line-soft: #999;\n --blocking: #000;\n --low: #666;\n }\n\n * { box-sizing: border-box; }\n\n html, body {\n margin: 0;\n padding: 0;\n background: var(--paper);\n color: var(--ink);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;\n font-size: 16px;\n line-height: 1.45;\n }\n\n body { padding: 16px; max-width: 1100px; margin: 0 auto; }\n\n h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }\n h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }\n h3 { font-size: 0.95rem; margin: 0 0 8px 0; }\n\n .sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }\n\n /* Status + error banners: text carries the meaning, not colour. */\n .banner {\n border: 2px solid var(--line);\n padding: 8px 10px;\n margin: 8px 0;\n font-size: 0.85rem;\n }\n .banner[hidden] { display: none; }\n .banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }\n\n #tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }\n #tokenbar[hidden] { display: none; }\n #tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }\n #tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n #tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }\n\n button {\n font: inherit;\n min-height: 44px;\n min-width: 44px;\n padding: 8px 12px;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n cursor: pointer;\n }\n button:focus-visible, input:focus-visible, a:focus-visible {\n outline: 3px solid var(--ink);\n outline-offset: 2px;\n }\n /* No hover-only affordances: hover is a bonus, never the only signal. */\n button:hover { background: #eee; }\n\n .stream-group { margin: 0 0 20px 0; }\n .stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }\n .stream-name { font-weight: bold; }\n .stream-count { color: var(--muted); font-size: 0.85rem; }\n\n .card {\n border: 2px solid var(--line);\n padding: 12px;\n margin: 10px 0;\n background: var(--paper);\n }\n /* Selection is carried by a thick left border AND a text marker, not colour alone. */\n .card.selected { border-left-width: 10px; }\n .card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }\n .card.selected .selmark { display: inline; }\n\n .card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }\n .age { color: var(--muted); font-size: 0.85rem; }\n\n .badge {\n display: inline-block;\n border: 2px solid var(--line);\n padding: 1px 6px;\n font-size: 0.75rem;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n font-weight: bold;\n }\n .badge.urgency-blocking { border-style: double; border-width: 4px; }\n .badge.urgency-low { border-style: dotted; }\n .badge.urgency-normal { border-style: solid; }\n .badge.status-answered { border-style: dashed; }\n\n .question { font-weight: bold; margin: 6px 0; }\n .context {\n white-space: pre-wrap;\n border-left: 3px solid var(--line-soft);\n padding: 6px 10px;\n margin: 8px 0;\n color: var(--ink);\n font-size: 0.9rem;\n }\n .context[hidden] { display: none; }\n\n .options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }\n .options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n\n .answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .answer-row label { font-size: 0.85rem; }\n .answer-row input[type="text"] {\n flex: 1 1 240px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n\n .card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }\n .card-error[hidden] { display: none; }\n .card-error .tag { font-weight: bold; text-transform: uppercase; }\n\n .recent .card { border-style: dashed; }\n .empty { color: var(--muted); font-style: italic; }\n\n /* Media is fetched with the Bearer token (fetch, not <img src>), then shown from an\n object URL. Expansion is a real <button> (keyboard-operable); drawing never relies\n on hover; nothing is colour-only. */\n .media-section { margin: 10px 0; }\n .media-toggle { width: 100%; text-align: left; }\n .media-list[hidden] { display: none; }\n .media-item { border: 1px solid var(--line-soft); padding: 8px; margin: 8px 0; }\n .media-item figcaption { font-size: 0.8rem; color: var(--muted); margin-bottom: 6px; }\n .media-stage { margin: 6px 0; }\n .media-stage img, .media-stage video, .media-stage audio { max-width: 100%; display: block; }\n .media-stage video { background: #000; }\n .media-stage canvas {\n max-width: 100%;\n height: auto;\n display: block;\n touch-action: none;\n cursor: crosshair;\n border: 1px solid var(--line-soft);\n }\n .media-tools { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }\n .media-tools button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }\n .media-status { font-size: 0.8rem; color: var(--muted); }\n .annotations { border-top: 1px dashed var(--line-soft); margin-top: 10px; padding-top: 8px; }\n .annotations h4 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 6px 0; }\n .note { border: 1px solid var(--line-soft); padding: 6px 8px; margin: 6px 0; font-size: 0.9rem; }\n .note-text { white-space: pre-wrap; }\n .note-meta { font-size: 0.75rem; color: var(--muted); margin-top: 4px; }\n .note-add { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }\n .note-add input[type="text"] {\n flex: 1 1 200px;\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n .note-add select {\n min-height: 44px;\n padding: 8px;\n font: inherit;\n border: 2px solid var(--line);\n background: var(--paper);\n color: var(--ink);\n }\n .annotation-image { margin: 8px 0; }\n .annotation-image img { max-width: 100%; border: 1px solid var(--line-soft); }\n\n .hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }\n kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }\n\n /* No animation on the critical path. Respect reduced motion. */\n @media (prefers-reduced-motion: reduce) {\n * { animation: none !important; transition: none !important; }\n }\n @media (prefers-reduced-motion: no-preference) {\n .card.selected { transition: border-left-width 80ms linear; }\n }\n\n @media print {\n body { max-width: none; }\n button { display: none; }\n .card { break-inside: avoid; }\n }\n</style>\n</head>\n<body>\n <header>\n <h1>readyroom</h1>\n <p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move &middot; <kbd>1</kbd>-<kbd>5</kbd> pick &middot; <kbd>Enter</kbd> answer &middot; <kbd>u</kbd> undo</p>\n <div id="status" class="banner" role="status" aria-live="polite">\n <span class="tag">Status:</span> <span id="status-text">starting</span>\n </div>\n <div id="error" class="banner" role="alert" hidden>\n <span class="tag">Error:</span> <span id="error-text"></span>\n </div>\n </header>\n\n <section id="tokenbar" hidden aria-labelledby="token-label">\n <label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>\n <div class="row">\n <input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">\n <button id="token-save" type="button">Save token</button>\n </div>\n <p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>\n </section>\n\n <main id="app">\n <section id="pending-section" aria-labelledby="pending-heading">\n <h2 id="pending-heading">Pending</h2>\n <div id="pending"></div>\n </section>\n\n <section id="recent-section" class="recent" aria-labelledby="recent-heading">\n <h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>\n <div id="recent"></div>\n </section>\n </main>\n\n <p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved. Attached media is fetched with your token only when you expand a ticket, and never from an external origin.</p>\n\n <noscript>\n <div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>\n </noscript>\n\n<script>\n(function () {\n "use strict";\n\n var TOKEN_KEY = "readyroom_token";\n var POLL_MS = 5000;\n var UNDO_WINDOW_MS = 5 * 60 * 1000;\n\n var state = {\n tickets: [],\n selectedId: null,\n token: "",\n abort: null,\n pollTimer: null,\n live: false,\n error: "",\n choices: {},\n drafts: {},\n /* Media and annotation state, all keyed so it survives the polling re-render:\n notes[ticketId] -> [{ text, media_id?, at_seconds? }]\n noteDrafts[key] -> in-progress note text (key = ticketId + ":" + (mediaId||""))\n shapes[mediaId] -> [{ tool: "box"|"draw", ... }] in the image\'s natural pixels\n expanded[ticketId] -> whether the attachment list is open\n mediaUrls[mediaId] -> object URL of the fetched blob (never revoked; ids are immutable) */\n notes: {},\n noteDrafts: {},\n noteTargets: {},\n shapes: {},\n canvasScale: {},\n tool: "box",\n expanded: {},\n mediaUrls: {},\n mediaPending: {},\n mediaErrors: {},\n /* True while a stroke is in progress, so a poll does not replace the canvas\n mid-drag; the pending render runs when the stroke ends. */\n drawing: false,\n renderQueued: false\n };\n\n var els = {\n status: document.getElementById("status"),\n statusText: document.getElementById("status-text"),\n error: document.getElementById("error"),\n errorText: document.getElementById("error-text"),\n tokenbar: document.getElementById("tokenbar"),\n tokenInput: document.getElementById("token-input"),\n tokenSave: document.getElementById("token-save"),\n pending: document.getElementById("pending"),\n recent: document.getElementById("recent")\n };\n\n /* ---------------- token handling (requirement 11) ---------------- */\n\n function readToken() {\n try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }\n }\n function writeToken(t) {\n try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }\n }\n function dropToken() {\n try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }\n }\n function showTokenBar(show) {\n els.tokenbar.hidden = !show;\n if (show) { try { els.tokenInput.focus(); } catch (e) {} }\n }\n\n /* ---------------- HTTP with Bearer on every request ---------------- */\n\n function api(path, opts) {\n opts = opts || {};\n var headers = { "Authorization": "Bearer " + state.token };\n if (opts.body) { headers["Content-Type"] = "application/json"; }\n return fetch(path, {\n method: opts.method || "GET",\n headers: headers,\n body: opts.body\n }).then(function (res) {\n if (res.status === 401) {\n dropToken();\n state.token = "";\n state.error = "Token rejected (401). Enter it again.";\n showTokenBar(true);\n render();\n var err = new Error("unauthorized");\n err.unauthorized = true;\n throw err;\n }\n return res;\n });\n }\n\n function fetchTickets() {\n if (!state.token) { return Promise.resolve(); }\n return api("/api/tickets").then(function (res) {\n if (!res.ok) { throw new Error("tickets " + res.status); }\n return res.json();\n }).then(function (data) {\n state.tickets = (data && data.tickets) || [];\n state.error = "";\n // Do not replace the DOM mid-stroke; queue the render for when the stroke ends.\n if (state.drawing) { state.renderQueued = true; } else { render(); }\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n /* ---------------- live updates (requirement 1) ---------------- */\n\n function startPolling() {\n if (state.pollTimer) { return; }\n state.live = false;\n fetchTickets();\n state.pollTimer = setInterval(fetchTickets, POLL_MS);\n render();\n }\n function stopPolling() {\n if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }\n }\n\n /* Fetch-based SSE reader.\n EventSource cannot attach an Authorization header, so a Bearer-protected\n /api/events is unreachable through it. Instead we fetch the endpoint with the\n token, read response.body through a ReadableStream reader, decode with a\n TextDecoder, and parse text/event-stream frames: frames are separated by a blank\n line, and the payload rides on `data:` lines. Any failure \u2014 a network error, a\n non-200 status, or a Worker\'s 501 \u2014 falls back to polling /api/tickets every 5 s,\n which is the Worker target\'s live-update path. */\n function handleFrame(frame) {\n var lines = frame.split("\\n");\n var sawData = false;\n for (var i = 0; i < lines.length; i++) {\n if (lines[i].indexOf("data:") === 0) { sawData = true; }\n }\n if (sawData) { fetchTickets(); }\n }\n\n function readSseStream(body) {\n var reader = body.getReader();\n var decoder = new TextDecoder();\n var buffer = "";\n function pump() {\n return reader.read().then(function (chunk) {\n if (chunk.done) { throw new Error("sse stream closed"); }\n buffer += decoder.decode(chunk.value, { stream: true });\n var idx;\n while ((idx = buffer.indexOf("\\n\\n")) !== -1) {\n var frame = buffer.slice(0, idx);\n buffer = buffer.slice(idx + 2);\n handleFrame(frame);\n }\n return pump();\n });\n }\n return pump();\n }\n\n function startLive() {\n stopLive();\n if (!state.token) { return; }\n if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }\n\n var controller = null;\n try { controller = new AbortController(); } catch (e) { controller = null; }\n if (controller) { state.abort = controller; }\n\n var options = { headers: { "Authorization": "Bearer " + state.token } };\n if (controller) { options.signal = controller.signal; }\n\n fetch("/api/events", options).then(function (res) {\n if (!res.ok || !res.body) {\n /* 401, 404, a Worker\'s 501, or a body we cannot stream: poll instead. */\n startPolling();\n return;\n }\n state.live = true;\n stopPolling();\n render();\n return readSseStream(res.body);\n }).catch(function () {\n if (controller && controller.signal.aborted) { return; }\n state.live = false;\n startPolling();\n });\n }\n function stopLive() {\n if (state.abort) {\n try { state.abort.abort(); } catch (e) { /* ignore */ }\n state.abort = null;\n }\n state.live = false;\n }\n\n /* ---------------- derived data ---------------- */\n\n function pendingTickets() {\n return state.tickets.filter(function (t) { return t.status === "pending"; });\n }\n function recentTickets() {\n var now = Date.now();\n return state.tickets.filter(function (t) {\n if (t.status !== "answered" || !t.answered_at) { return false; }\n return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;\n }).sort(function (a, b) {\n return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();\n });\n }\n\n function groupByStream(tickets) {\n var groups = {};\n var order = [];\n tickets.forEach(function (t) {\n if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }\n groups[t.stream].push(t);\n });\n order.forEach(function (s) {\n groups[s].sort(function (a, b) {\n return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();\n });\n });\n order.sort(function (a, b) {\n return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();\n });\n return order.map(function (s) { return { stream: s, tickets: groups[s] }; });\n }\n\n function orderedPending() {\n var out = [];\n groupByStream(pendingTickets()).forEach(function (g) {\n g.tickets.forEach(function (t) { out.push(t); });\n });\n return out;\n }\n\n function age(iso) {\n var ms = Date.now() - new Date(iso).getTime();\n if (!isFinite(ms) || ms < 0) { ms = 0; }\n var s = Math.floor(ms / 1000);\n if (s < 60) { return s + "s"; }\n var m = Math.floor(s / 60);\n if (m < 60) { return m + "m"; }\n var h = Math.floor(m / 60);\n if (h < 24) { return h + "h"; }\n return Math.floor(h / 24) + "d";\n }\n\n /* ---------------- media + annotations ---------------- */\n\n function formatBytes(n) {\n if (typeof n !== "number" || !isFinite(n)) { return "?"; }\n if (n < 1024) { return n + " B"; }\n if (n < 1024 * 1024) { return (n / 1024).toFixed(1) + " KiB"; }\n return (n / (1024 * 1024)).toFixed(1) + " MiB";\n }\n\n function formatTime(seconds) {\n var s = Math.max(0, Math.floor(seconds));\n var m = Math.floor(s / 60);\n var rest = s % 60;\n return m + ":" + (rest < 10 ? "0" : "") + rest;\n }\n\n function findMedia(ticket, id) {\n var media = ticket.media || [];\n for (var i = 0; i < media.length; i++) { if (media[i].id === id) { return media[i]; } }\n return null;\n }\n\n function mediaName(ticket, id) {\n var media = ticket.media || [];\n for (var i = 0; i < media.length; i++) {\n if (media[i].id === id) { return media[i].filename || ("attachment " + (i + 1) + " (" + media[i].kind + ")"); }\n }\n return "attachment";\n }\n\n /* Media is fetched with the Bearer token, then shown from an object URL: <img src>\n and <audio src> cannot attach an Authorization header, exactly like EventSource. */\n function loadMediaObjectUrl(ref) {\n if (state.mediaUrls[ref.id]) { return Promise.resolve(state.mediaUrls[ref.id]); }\n if (state.mediaPending[ref.id]) { return state.mediaPending[ref.id]; }\n var promise = api("/api/media/" + encodeURIComponent(ref.id)).then(function (res) {\n if (!res.ok) { throw new Error("media " + res.status); }\n return res.blob();\n }).then(function (blob) {\n var url = URL.createObjectURL(blob);\n state.mediaUrls[ref.id] = url;\n delete state.mediaPending[ref.id];\n return url;\n }).catch(function (err) {\n delete state.mediaPending[ref.id];\n state.mediaErrors[ref.id] = err && err.message ? err.message : "load failed";\n throw err;\n });\n state.mediaPending[ref.id] = promise;\n return promise;\n }\n\n function drawShape(ctx, shape) {\n ctx.save();\n ctx.lineWidth = 3;\n ctx.strokeStyle = "#d40000";\n ctx.lineJoin = "round";\n ctx.lineCap = "round";\n if (shape.tool === "box") {\n var x = Math.min(shape.x0, shape.x1);\n var y = Math.min(shape.y0, shape.y1);\n ctx.strokeRect(x, y, Math.abs(shape.x1 - shape.x0), Math.abs(shape.y1 - shape.y0));\n } else if (shape.tool === "draw" && shape.points && shape.points.length > 1) {\n ctx.beginPath();\n ctx.moveTo(shape.points[0].x, shape.points[0].y);\n for (var i = 1; i < shape.points.length; i++) { ctx.lineTo(shape.points[i].x, shape.points[i].y); }\n ctx.stroke();\n }\n ctx.restore();\n }\n\n function toolButton(tool, label) {\n var b = el("button", null, label);\n b.type = "button";\n b.setAttribute("aria-pressed", state.tool === tool ? "true" : "false");\n b.addEventListener("click", function () { state.tool = tool; render(); });\n return b;\n }\n\n /* The expanded image is a canvas you draw on. The base image lives offscreen so a\n repaint can restore it under the committed shapes plus the in-progress stroke. */\n function buildImageCanvas(ref, url) {\n var wrap = el("div", "media-canvas-wrap");\n var tools = el("div", "media-tools");\n tools.appendChild(toolButton("box", "Box"));\n tools.appendChild(toolButton("draw", "Draw"));\n var clear = el("button", null, "Clear markup");\n clear.type = "button";\n clear.addEventListener("click", function () {\n state.shapes[ref.id] = [];\n render();\n });\n tools.appendChild(clear);\n tools.appendChild(el("span", "media-status", "Draw to mark up; markup is sent with your answer."));\n wrap.appendChild(tools);\n\n var canvas = document.createElement("canvas");\n canvas.id = "canvas-" + ref.id;\n canvas.setAttribute("aria-label", (ref.filename || "attached image") + ". Draw on the image to annotate it.");\n wrap.appendChild(canvas);\n\n var base = document.createElement("canvas");\n var current = null;\n var drawing = false;\n\n function repaint() {\n var ctx = canvas.getContext("2d");\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n ctx.drawImage(base, 0, 0);\n var shapes = state.shapes[ref.id] || [];\n for (var i = 0; i < shapes.length; i++) { drawShape(ctx, shapes[i]); }\n if (current) { drawShape(ctx, current); }\n }\n\n function toCanvas(e) {\n var rect = canvas.getBoundingClientRect();\n return {\n x: (e.clientX - rect.left) * (canvas.width / rect.width),\n y: (e.clientY - rect.top) * (canvas.height / rect.height)\n };\n }\n\n function endStroke() {\n if (!drawing) { return; }\n drawing = false;\n state.drawing = false;\n if (current) {\n state.shapes[ref.id] = (state.shapes[ref.id] || []).concat([current]);\n current = null;\n }\n repaint();\n if (state.renderQueued) { state.renderQueued = false; render(); }\n }\n\n canvas.addEventListener("pointerdown", function (e) {\n if (e.button !== undefined && e.button !== 0) { return; }\n drawing = true;\n state.drawing = true;\n try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* not fatal */ }\n var p = toCanvas(e);\n current = state.tool === "box"\n ? { tool: "box", x0: p.x, y0: p.y, x1: p.x, y1: p.y }\n : { tool: "draw", points: [p] };\n repaint();\n e.preventDefault();\n });\n canvas.addEventListener("pointermove", function (e) {\n if (!drawing || !current) { return; }\n var p = toCanvas(e);\n if (current.tool === "box") { current.x1 = p.x; current.y1 = p.y; }\n else { current.points.push(p); }\n repaint();\n e.preventDefault();\n });\n canvas.addEventListener("pointerup", endStroke);\n canvas.addEventListener("pointercancel", endStroke);\n\n var img = new Image();\n img.onload = function () {\n var maxDim = 2400;\n var scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));\n canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));\n canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));\n // Shapes are captured in canvas pixels; record the ratio so an annotated copy\n // rendered at natural size can scale them back up.\n state.canvasScale[ref.id] = canvas.width / img.naturalWidth;\n base.width = canvas.width;\n base.height = canvas.height;\n base.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);\n repaint();\n };\n img.src = url;\n return wrap;\n }\n\n /* A player/stage for one attachment. Loads lazily on first mount; re-mounts are free\n because the object URL is cached in state. */\n function mountMedia(ref, ticket, isRecent) {\n var stage = el("div", "media-stage");\n stage.setAttribute("data-media-stage", "1");\n\n function build() {\n var url = state.mediaUrls[ref.id];\n if (ref.kind === "image" && !isRecent) {\n return buildImageCanvas(ref, url);\n }\n if (ref.kind === "image") {\n var img = document.createElement("img");\n img.alt = ref.filename || "attached image";\n img.src = url;\n return img;\n }\n var node = document.createElement(ref.kind === "audio" ? "audio" : "video");\n node.controls = true;\n node.id = "player-" + ref.id;\n node.preload = "metadata";\n node.src = url;\n return node;\n }\n\n function refresh() {\n stage.textContent = "";\n if (state.mediaUrls[ref.id]) {\n stage.appendChild(build());\n return;\n }\n if (state.mediaErrors[ref.id]) {\n stage.appendChild(el("div", "media-status", "Could not load attachment: " + state.mediaErrors[ref.id]));\n return;\n }\n stage.appendChild(el("div", "media-status", "Loading " + ref.kind + "\u2026"));\n }\n\n refresh();\n if (!state.mediaUrls[ref.id] && !state.mediaErrors[ref.id]) {\n loadMediaObjectUrl(ref).then(refresh, refresh);\n }\n return stage;\n }\n\n function buildMediaItem(ref, ticket, isRecent, index) {\n var fig = el("figure", "media-item");\n fig.setAttribute("data-media-item", ref.id);\n var name = ref.filename || ("attachment " + (index + 1) + " (" + ref.kind + ")");\n fig.appendChild(el("figcaption", null, name + " \xB7 " + ref.mime_type + " \xB7 " + formatBytes(ref.size_bytes)));\n if (ref.caption) { fig.appendChild(el("div", "media-status", ref.caption)); }\n fig.appendChild(mountMedia(ref, ticket, isRecent));\n return fig;\n }\n\n function addNote(ticketId, note) {\n if (!state.notes[ticketId]) { state.notes[ticketId] = []; }\n state.notes[ticketId].push(note);\n // Clear the field itself, not just the draft: render() re-captures the input\'s\n // value at the top of the pass, so clearing only the draft would be undone.\n var input = document.getElementById("note-" + ticketId);\n if (input) { input.value = ""; }\n state.noteDrafts[ticketId + ":note"] = "";\n render();\n }\n\n function removeNote(ticketId, index) {\n if (state.notes[ticketId]) { state.notes[ticketId].splice(index, 1); }\n render();\n }\n\n function buildNotesList(ticket, isRecent) {\n var list = el("div", "notes-list");\n var notes = state.notes[ticket.id] || [];\n if (notes.length === 0) {\n list.appendChild(el("p", "media-status", "No annotations yet. Markup you draw and notes you add are sent back with your answer."));\n return list;\n }\n notes.forEach(function (note, index) {\n var item = el("div", "note");\n item.appendChild(el("div", "note-text", note.text));\n var meta = [];\n if (note.media_id) { meta.push(mediaName(ticket, note.media_id)); }\n if (note.at_seconds !== undefined && note.at_seconds !== null) { meta.push(formatTime(note.at_seconds)); }\n if (meta.length) { item.appendChild(el("div", "note-meta", meta.join(" \xB7 "))); }\n if (!isRecent) {\n var row = el("div", "note-add");\n var rm = el("button", null, "Remove");\n rm.type = "button";\n rm.addEventListener("click", function () { removeNote(ticket.id, index); });\n row.appendChild(rm);\n item.appendChild(row);\n }\n list.appendChild(item);\n });\n return list;\n }\n\n function buildNoteAdder(ticket, media) {\n var form = el("form", "note-add");\n form.setAttribute("novalidate", "novalidate");\n var key = ticket.id + ":note";\n\n var input = document.createElement("input");\n input.type = "text";\n input.id = "note-" + ticket.id;\n input.setAttribute("data-notedraft", key);\n input.setAttribute("autocomplete", "off");\n input.setAttribute("placeholder", "Add a note");\n input.value = state.noteDrafts[key] || "";\n input.addEventListener("input", function () { state.noteDrafts[key] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n\n var select = document.createElement("select");\n select.id = "notetarget-" + ticket.id;\n select.setAttribute("aria-label", "Which attachment the note is about");\n var whole = document.createElement("option");\n whole.value = "";\n whole.textContent = "Whole ticket";\n select.appendChild(whole);\n media.forEach(function (ref, i) {\n var option = document.createElement("option");\n option.value = ref.id;\n option.textContent = ref.filename || ("attachment " + (i + 1) + " (" + ref.kind + ")");\n select.appendChild(option);\n });\n var targetKey = ticket.id + ":notetarget";\n if (state.noteTargets[targetKey]) { select.value = state.noteTargets[targetKey]; }\n select.addEventListener("change", function () { state.noteTargets[targetKey] = select.value; });\n\n var add = el("button", null, "Add note");\n add.type = "submit";\n form.appendChild(input);\n form.appendChild(select);\n form.appendChild(add);\n\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n var text = input.value.trim();\n if (!text) { return; }\n var note = { text: text };\n if (select.value) {\n note.media_id = select.value;\n var ref = findMedia(ticket, select.value);\n if (ref && (ref.kind === "audio" || ref.kind === "video")) {\n var player = document.getElementById("player-" + ref.id);\n if (player && isFinite(player.currentTime)) { note.at_seconds = Math.round(player.currentTime * 10) / 10; }\n }\n }\n addNote(ticket.id, note);\n });\n return form;\n }\n\n function buildAnnotationEditor(ticket) {\n var media = ticket.media || [];\n if (media.length === 0) { return null; }\n var box = el("div", "annotations");\n box.appendChild(el("h4", null, "Annotations (optional)"));\n box.appendChild(buildNotesList(ticket, false));\n box.appendChild(buildNoteAdder(ticket, media));\n return box;\n }\n\n function buildAnnotationsReadonly(ticket) {\n var annotations = ticket.annotations || [];\n if (annotations.length === 0) { return null; }\n var box = el("div", "annotations");\n box.appendChild(el("h4", null, "Annotations (" + annotations.length + ")"));\n annotations.forEach(function (a) {\n var item = el("div", "note");\n if (a.text) { item.appendChild(el("div", "note-text", a.text)); }\n var meta = [];\n if (a.media_id) { meta.push(mediaName(ticket, a.media_id)); }\n if (a.at_seconds !== undefined && a.at_seconds !== null) { meta.push(formatTime(a.at_seconds)); }\n if (a.annotated_media) { meta.push("annotated image"); }\n if (meta.length) { item.appendChild(el("div", "note-meta", meta.join(" \xB7 "))); }\n box.appendChild(item);\n if (a.annotated_media) {\n var fig = el("div", "annotation-image");\n fig.appendChild(mountMedia(a.annotated_media, ticket, true));\n box.appendChild(fig);\n }\n });\n return box;\n }\n\n function buildMediaSection(ticket, isRecent) {\n var media = ticket.media || [];\n var annotations = ticket.annotations || [];\n if (media.length === 0 && annotations.length === 0) { return null; }\n\n var section = el("div", "media-section");\n\n if (media.length > 0) {\n var expanded = !!state.expanded[ticket.id];\n var toggle = el("button", "media-toggle",\n (expanded ? "Hide " : "Show ") + media.length + " attachment" + (media.length === 1 ? "" : "s") +\n (isRecent ? "" : " for inspection"));\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", expanded ? "true" : "false");\n var list = el("div", "media-list");\n list.hidden = !expanded;\n if (expanded) {\n media.forEach(function (ref, i) { list.appendChild(buildMediaItem(ref, ticket, isRecent, i)); });\n }\n toggle.addEventListener("click", function () {\n state.expanded[ticket.id] = !state.expanded[ticket.id];\n render();\n });\n section.appendChild(toggle);\n section.appendChild(list);\n }\n\n if (isRecent) {\n var readOnly = buildAnnotationsReadonly(ticket);\n if (readOnly) { section.appendChild(readOnly); }\n } else {\n var editor = buildAnnotationEditor(ticket);\n if (editor) { section.appendChild(editor); }\n }\n\n return section;\n }\n\n /* Render an annotated copy offscreen, independent of whether the canvas is mounted.\n Shapes are in canvas pixels, so a fresh canvas at the image\'s natural size needs\n them scaled up to match. */\n function renderAnnotatedBlob(ref) {\n return loadMediaObjectUrl(ref).then(function (url) {\n return new Promise(function (resolve, reject) {\n var img = new Image();\n img.onload = function () {\n var canvas = document.createElement("canvas");\n canvas.width = img.naturalWidth;\n canvas.height = img.naturalHeight;\n var ctx = canvas.getContext("2d");\n ctx.drawImage(img, 0, 0);\n // Shapes are in on-screen canvas pixels; the copy is at natural size, so scale\n // by the inverse of the on-screen ratio (1 when the image was not downscaled).\n var baseScale = state.canvasScale[ref.id] || 1;\n var shapes = state.shapes[ref.id] || [];\n ctx.save();\n ctx.scale(1 / baseScale, 1 / baseScale);\n for (var i = 0; i < shapes.length; i++) { drawShape(ctx, shapes[i]); }\n ctx.restore();\n canvas.toBlob(function (blob) { blob ? resolve(blob) : reject(new Error("could not render image")); }, "image/png");\n };\n img.onerror = function () { reject(new Error("could not load image")); };\n img.src = url;\n });\n });\n }\n\n function blobToBase64(blob) {\n return new Promise(function (resolve, reject) {\n var reader = new FileReader();\n reader.onload = function () {\n var result = String(reader.result || "");\n var comma = result.indexOf(",");\n resolve(comma >= 0 ? result.slice(comma + 1) : result);\n };\n reader.onerror = function () { reject(new Error("could not read image")); };\n reader.readAsDataURL(blob);\n });\n }\n\n function uploadImageBlob(blob) {\n return blobToBase64(blob).then(function (data_base64) {\n return api("/api/media", {\n method: "POST",\n body: JSON.stringify({ mime_type: "image/png", data_base64: data_base64 })\n });\n }).then(function (res) {\n if (!res.ok) { throw new Error("media upload " + res.status); }\n return res.json();\n }).then(function (data) {\n if (!data || !data.media || !data.media.id) { throw new Error("media upload returned no id"); }\n return data.media.id;\n });\n }\n\n /* ---------------- actions ---------------- */\n\n function setCardError(id, msg) {\n var el = document.getElementById("err-" + id);\n if (el) {\n el.hidden = false;\n var span = el.querySelector(".err-text");\n if (span) { span.textContent = msg; }\n }\n }\n\n function submitAnswer(ticket) {\n var input = document.getElementById("free-" + ticket.id);\n var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();\n var choice = state.choices[ticket.id] || "";\n var answer = text || choice;\n\n var notes = (state.notes[ticket.id] || []).slice();\n var media = ticket.media || [];\n // A media item counts as markup only if at least one shape was committed on it.\n var marked = media.filter(function (ref) { return (state.shapes[ref.id] || []).length > 0; });\n\n if (!answer && notes.length === 0 && marked.length === 0) {\n setCardError(ticket.id, "Type an answer, pick an option, or add an annotation first.");\n return;\n }\n\n var annotations = notes.map(function (note) {\n var a = { text: note.text };\n if (note.media_id) { a.media_id = note.media_id; }\n if (note.at_seconds !== undefined && note.at_seconds !== null) { a.at_seconds = note.at_seconds; }\n return a;\n });\n\n // Upload each annotated image first (with POST /api/media), then reference it by id\n // in the answer. A missing or non-image reference is a 400, so this order matters.\n Promise.all(marked.map(function (ref) {\n return renderAnnotatedBlob(ref).then(uploadImageBlob).then(function (mediaId) {\n annotations.push({ media_id: ref.id, annotated_media_id: mediaId });\n });\n })).then(function () {\n return api("/api/answer", {\n method: "POST",\n body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human", annotations: annotations })\n });\n }).then(function (res) {\n if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }\n if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }\n if (res.status === 400) { setCardError(ticket.id, "Answer rejected (400)."); return; }\n if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }\n delete state.choices[ticket.id];\n delete state.drafts[ticket.id];\n delete state.notes[ticket.id];\n media.forEach(function (ref) { delete state.shapes[ref.id]; });\n return fetchTickets();\n }).catch(function (err) {\n if (err && err.unauthorized) { return; }\n setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));\n });\n }\n\n function undo(ticketId) {\n api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })\n .then(function (res) {\n if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }\n if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }\n return fetchTickets();\n })\n .catch(function (err) {\n if (err && err.unauthorized) { return; }\n state.error = "Undo failed: " + (err && err.message ? err.message : "network error");\n render();\n });\n }\n\n function undoLast() {\n var recent = recentTickets();\n if (recent.length === 0) { return; }\n undo(recent[0].id);\n }\n\n /* ---------------- keyboard (requirement 4) ---------------- */\n\n function isTypingTarget(el) {\n if (!el) { return false; }\n var tag = el.tagName;\n return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;\n }\n\n function moveSelection(delta) {\n var list = orderedPending();\n if (list.length === 0) { return; }\n var idx = -1;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }\n if (idx === -1) { idx = delta > 0 ? -1 : 0; }\n var next = idx + delta;\n if (next < 0) { next = 0; }\n if (next > list.length - 1) { next = list.length - 1; }\n state.selectedId = list[next].id;\n render();\n var card = document.getElementById("card-" + state.selectedId);\n if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }\n }\n\n function pickOption(n) {\n var list = orderedPending();\n var ticket = null;\n for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }\n if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }\n state.choices[ticket.id] = ticket.options[n - 1];\n render();\n }\n\n function submitSelected() {\n var list = orderedPending();\n for (var i = 0; i < list.length; i++) {\n if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }\n }\n }\n\n document.addEventListener("keydown", function (e) {\n if (isTypingTarget(e.target)) { return; }\n // While the pointer/keyboard focus is inside a media stage (the drawing canvas, a\n // player), the single-key shortcuts would fight the media interaction.\n if (e.target && e.target.closest && e.target.closest("[data-media-stage]")) { return; }\n if (e.metaKey || e.ctrlKey || e.altKey) { return; }\n if (e.key === "j") { e.preventDefault(); moveSelection(1); }\n else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }\n else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }\n else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }\n else if (e.key === "u") { e.preventDefault(); undoLast(); }\n });\n\n /* ---------------- rendering ---------------- */\n\n function captureDrafts() {\n var inputs = document.querySelectorAll("[data-draft]");\n for (var i = 0; i < inputs.length; i++) {\n state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;\n }\n }\n\n function captureNoteDrafts() {\n var inputs = document.querySelectorAll("[data-notedraft]");\n for (var i = 0; i < inputs.length; i++) {\n state.noteDrafts[inputs[i].getAttribute("data-notedraft")] = inputs[i].value;\n }\n }\n\n function el(tag, cls, text) {\n var node = document.createElement(tag);\n if (cls) { node.className = cls; }\n if (text !== undefined && text !== null) { node.textContent = text; }\n return node;\n }\n\n function urgencyBadge(urgency) {\n var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);\n return b;\n }\n\n function buildCard(ticket, isRecent) {\n // The `selected` marker is a keyboard-navigation affordance for the pending queue\n // only. An answered card in "recent" carries no selection target (`j`/`k`/`Enter`\n // never act on it), so marking it "selected" would be misleading.\n var card = el("article", "card" + (!isRecent && ticket.id === state.selectedId ? " selected" : ""));\n card.id = "card-" + ticket.id;\n card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);\n\n var head = el("div", "card-head");\n head.appendChild(el("span", "selmark", "selected"));\n head.appendChild(el("span", "stream-name", ticket.stream));\n head.appendChild(el("span", "age", age(ticket.created_at) + " old"));\n head.appendChild(urgencyBadge(ticket.urgency));\n if (isRecent) {\n head.appendChild(el("span", "badge status-answered", "status: answered"));\n if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }\n }\n card.appendChild(head);\n\n card.appendChild(el("p", "question", ticket.question));\n\n if (ticket.context) {\n var ctx = el("div", "context", ticket.context);\n card.appendChild(ctx);\n }\n\n var mediaSection = buildMediaSection(ticket, isRecent);\n if (mediaSection) { card.appendChild(mediaSection); }\n\n if (isRecent) {\n var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));\n card.appendChild(ans);\n var undoBtn = el("button", null, "Undo");\n undoBtn.type = "button";\n undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);\n undoBtn.addEventListener("click", function () { undo(ticket.id); });\n var row = el("div", "answer-row");\n row.appendChild(undoBtn);\n card.appendChild(row);\n return card;\n }\n\n if (ticket.options && ticket.options.length) {\n var opts = el("div", "options");\n opts.setAttribute("role", "group");\n opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);\n ticket.options.forEach(function (opt, i) {\n var b = el("button", null, (i + 1) + ". " + opt);\n b.type = "button";\n b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");\n b.addEventListener("click", function () {\n state.choices[ticket.id] = opt;\n state.selectedId = ticket.id;\n render();\n });\n opts.appendChild(b);\n });\n card.appendChild(opts);\n }\n\n var form = el("form", "answer-row");\n form.setAttribute("novalidate", "novalidate");\n var label = el("label", null, "Answer");\n label.setAttribute("for", "free-" + ticket.id);\n form.appendChild(label);\n var input = document.createElement("input");\n input.type = "text";\n input.id = "free-" + ticket.id;\n input.setAttribute("data-draft", ticket.id);\n input.setAttribute("autocomplete", "off");\n input.value = state.drafts[ticket.id] || "";\n input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });\n input.addEventListener("focus", function () { state.selectedId = ticket.id; });\n form.appendChild(input);\n var submit = el("button", null, "Answer");\n submit.type = "submit";\n form.appendChild(submit);\n form.addEventListener("submit", function (e) {\n e.preventDefault();\n submitAnswer(ticket);\n });\n card.appendChild(form);\n\n var err = el("div", "card-error");\n err.id = "err-" + ticket.id;\n err.hidden = true;\n err.setAttribute("role", "alert");\n err.appendChild(el("span", "tag", "Error: "));\n err.appendChild(el("span", "err-text", ""));\n card.appendChild(err);\n\n return card;\n }\n\n function render() {\n captureDrafts();\n captureNoteDrafts();\n\n var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;\n var selStart = null, selEnd = null;\n if (activeId && document.activeElement.setSelectionRange) {\n try {\n selStart = document.activeElement.selectionStart;\n selEnd = document.activeElement.selectionEnd;\n } catch (e) { /* not a text input */ }\n }\n\n var pending = pendingTickets();\n var recent = recentTickets();\n\n /* requirement 5: tab title carries the count */\n document.title = pending.length > 0 ? "(" + pending.length + ") readyroom" : "readyroom";\n\n /* status line */\n var statusText = "idle";\n if (state.live) { statusText = "live (server-sent events)"; }\n else if (state.pollTimer) { statusText = "polling every 5s"; }\n else if (!state.token) { statusText = "waiting for token"; }\n els.statusText.textContent = statusText;\n\n if (state.error) {\n els.error.hidden = false;\n els.errorText.textContent = state.error;\n } else {\n els.error.hidden = true;\n els.errorText.textContent = "";\n }\n\n /* pending, grouped by stream, newest stream first */\n els.pending.textContent = "";\n if (pending.length === 0) {\n els.pending.appendChild(el("p", "empty", "No pending tickets."));\n } else {\n groupByStream(pending).forEach(function (group) {\n var section = el("section", "stream-group");\n section.setAttribute("aria-label", "Stream " + group.stream);\n var head = el("div", "stream-head");\n head.appendChild(el("h3", "stream-name", group.stream));\n head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));\n section.appendChild(head);\n group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });\n els.pending.appendChild(section);\n });\n }\n\n /* recent, undoable for 5 minutes */\n els.recent.textContent = "";\n if (recent.length === 0) {\n els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));\n } else {\n recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });\n }\n\n /* restore focus + caret so polling re-renders do not interrupt typing */\n if (activeId) {\n var again = document.getElementById(activeId);\n if (again && again.focus) {\n try {\n again.focus();\n if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }\n } catch (e) { /* ignore */ }\n }\n }\n }\n\n /* ---------------- wiring ---------------- */\n\n els.tokenSave.addEventListener("click", function () {\n var t = els.tokenInput.value.trim();\n if (!t) { return; }\n state.token = t;\n writeToken(t);\n state.error = "";\n showTokenBar(false);\n startLive();\n fetchTickets();\n });\n els.tokenInput.addEventListener("keydown", function (e) {\n if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }\n });\n\n /* requirement 9: re-render on window load */\n window.addEventListener("load", render);\n\n /* boot */\n state.token = readToken();\n if (state.token) {\n showTokenBar(false);\n startLive();\n fetchTickets();\n } else {\n showTokenBar(true);\n }\n\n /* requirement 9: first paint is synchronous, at the end of the script, not in rAF */\n render();\n})();\n</script>\n</body>\n</html>\n';
7513
7512
 
7514
- :root {
7515
- --ink: #111;
7516
- --paper: #fff;
7517
- --muted: #555;
7518
- --line: #111;
7519
- --line-soft: #999;
7520
- --blocking: #000;
7521
- --low: #666;
7513
+ // src/core/media.ts
7514
+ function mediaKind(mimeType) {
7515
+ const base = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
7516
+ if (base.startsWith("image/")) {
7517
+ return "image";
7522
7518
  }
7523
-
7524
- * { box-sizing: border-box; }
7525
-
7526
- html, body {
7527
- margin: 0;
7528
- padding: 0;
7529
- background: var(--paper);
7530
- color: var(--ink);
7531
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
7532
- font-size: 16px;
7533
- line-height: 1.45;
7534
- }
7535
-
7536
- body { padding: 16px; max-width: 1100px; margin: 0 auto; }
7537
-
7538
- h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }
7539
- h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }
7540
- h3 { font-size: 0.95rem; margin: 0 0 8px 0; }
7541
-
7542
- .sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }
7543
-
7544
- /* Status + error banners: text carries the meaning, not colour. */
7545
- .banner {
7546
- border: 2px solid var(--line);
7547
- padding: 8px 10px;
7548
- margin: 8px 0;
7549
- font-size: 0.85rem;
7550
- }
7551
- .banner[hidden] { display: none; }
7552
- .banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }
7553
-
7554
- #tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }
7555
- #tokenbar[hidden] { display: none; }
7556
- #tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }
7557
- #tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
7558
- #tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }
7559
-
7560
- button {
7561
- font: inherit;
7562
- min-height: 44px;
7563
- min-width: 44px;
7564
- padding: 8px 12px;
7565
- border: 2px solid var(--line);
7566
- background: var(--paper);
7567
- color: var(--ink);
7568
- cursor: pointer;
7569
- }
7570
- button:focus-visible, input:focus-visible, a:focus-visible {
7571
- outline: 3px solid var(--ink);
7572
- outline-offset: 2px;
7573
- }
7574
- /* No hover-only affordances: hover is a bonus, never the only signal. */
7575
- button:hover { background: #eee; }
7576
-
7577
- .stream-group { margin: 0 0 20px 0; }
7578
- .stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
7579
- .stream-name { font-weight: bold; }
7580
- .stream-count { color: var(--muted); font-size: 0.85rem; }
7581
-
7582
- .card {
7583
- border: 2px solid var(--line);
7584
- padding: 12px;
7585
- margin: 10px 0;
7586
- background: var(--paper);
7587
- }
7588
- /* Selection is carried by a thick left border AND a text marker, not colour alone. */
7589
- .card.selected { border-left-width: 10px; }
7590
- .card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }
7591
- .card.selected .selmark { display: inline; }
7592
-
7593
- .card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }
7594
- .age { color: var(--muted); font-size: 0.85rem; }
7595
-
7596
- .badge {
7597
- display: inline-block;
7598
- border: 2px solid var(--line);
7599
- padding: 1px 6px;
7600
- font-size: 0.75rem;
7601
- text-transform: uppercase;
7602
- letter-spacing: 0.06em;
7603
- font-weight: bold;
7604
- }
7605
- .badge.urgency-blocking { border-style: double; border-width: 4px; }
7606
- .badge.urgency-low { border-style: dotted; }
7607
- .badge.urgency-normal { border-style: solid; }
7608
- .badge.status-answered { border-style: dashed; }
7609
-
7610
- .question { font-weight: bold; margin: 6px 0; }
7611
- .context {
7612
- white-space: pre-wrap;
7613
- border-left: 3px solid var(--line-soft);
7614
- padding: 6px 10px;
7615
- margin: 8px 0;
7616
- color: var(--ink);
7617
- font-size: 0.9rem;
7618
- }
7619
- .context[hidden] { display: none; }
7620
-
7621
- .options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
7622
- .options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }
7623
-
7624
- .answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
7625
- .answer-row label { font-size: 0.85rem; }
7626
- .answer-row input[type="text"] {
7627
- flex: 1 1 240px;
7628
- min-height: 44px;
7629
- padding: 8px;
7630
- font: inherit;
7631
- border: 2px solid var(--line);
7632
- background: var(--paper);
7633
- color: var(--ink);
7634
- }
7635
-
7636
- .card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }
7637
- .card-error[hidden] { display: none; }
7638
- .card-error .tag { font-weight: bold; text-transform: uppercase; }
7639
-
7640
- .recent .card { border-style: dashed; }
7641
- .empty { color: var(--muted); font-style: italic; }
7642
-
7643
- .hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }
7644
- kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }
7645
-
7646
- /* No animation on the critical path. Respect reduced motion. */
7647
- @media (prefers-reduced-motion: reduce) {
7648
- * { animation: none !important; transition: none !important; }
7649
- }
7650
- @media (prefers-reduced-motion: no-preference) {
7651
- .card.selected { transition: border-left-width 80ms linear; }
7652
- }
7653
-
7654
- @media print {
7655
- body { max-width: none; }
7656
- button { display: none; }
7657
- .card { break-inside: avoid; }
7658
- }
7659
- </style>
7660
- </head>
7661
- <body>
7662
- <header>
7663
- <h1>readyroom</h1>
7664
- <p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move &middot; <kbd>1</kbd>-<kbd>5</kbd> pick &middot; <kbd>Enter</kbd> answer &middot; <kbd>u</kbd> undo</p>
7665
- <div id="status" class="banner" role="status" aria-live="polite">
7666
- <span class="tag">Status:</span> <span id="status-text">starting</span>
7667
- </div>
7668
- <div id="error" class="banner" role="alert" hidden>
7669
- <span class="tag">Error:</span> <span id="error-text"></span>
7670
- </div>
7671
- </header>
7672
-
7673
- <section id="tokenbar" hidden aria-labelledby="token-label">
7674
- <label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>
7675
- <div class="row">
7676
- <input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">
7677
- <button id="token-save" type="button">Save token</button>
7678
- </div>
7679
- <p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>
7680
- </section>
7681
-
7682
- <main id="app">
7683
- <section id="pending-section" aria-labelledby="pending-heading">
7684
- <h2 id="pending-heading">Pending</h2>
7685
- <div id="pending"></div>
7686
- </section>
7687
-
7688
- <section id="recent-section" class="recent" aria-labelledby="recent-heading">
7689
- <h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>
7690
- <div id="recent"></div>
7691
- </section>
7692
- </main>
7693
-
7694
- <p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved.</p>
7695
-
7696
- <noscript>
7697
- <div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>
7698
- </noscript>
7699
-
7700
- <script>
7701
- (function () {
7702
- "use strict";
7703
-
7704
- var TOKEN_KEY = "readyroom_token";
7705
- var POLL_MS = 5000;
7706
- var UNDO_WINDOW_MS = 5 * 60 * 1000;
7707
-
7708
- var state = {
7709
- tickets: [],
7710
- selectedId: null,
7711
- token: "",
7712
- abort: null,
7713
- pollTimer: null,
7714
- live: false,
7715
- error: "",
7716
- choices: {},
7717
- drafts: {}
7718
- };
7719
-
7720
- var els = {
7721
- status: document.getElementById("status"),
7722
- statusText: document.getElementById("status-text"),
7723
- error: document.getElementById("error"),
7724
- errorText: document.getElementById("error-text"),
7725
- tokenbar: document.getElementById("tokenbar"),
7726
- tokenInput: document.getElementById("token-input"),
7727
- tokenSave: document.getElementById("token-save"),
7728
- pending: document.getElementById("pending"),
7729
- recent: document.getElementById("recent")
7730
- };
7731
-
7732
- /* ---------------- token handling (requirement 11) ---------------- */
7733
-
7734
- function readToken() {
7735
- try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }
7736
- }
7737
- function writeToken(t) {
7738
- try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }
7739
- }
7740
- function dropToken() {
7741
- try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }
7742
- }
7743
- function showTokenBar(show) {
7744
- els.tokenbar.hidden = !show;
7745
- if (show) { try { els.tokenInput.focus(); } catch (e) {} }
7746
- }
7747
-
7748
- /* ---------------- HTTP with Bearer on every request ---------------- */
7749
-
7750
- function api(path, opts) {
7751
- opts = opts || {};
7752
- var headers = { "Authorization": "Bearer " + state.token };
7753
- if (opts.body) { headers["Content-Type"] = "application/json"; }
7754
- return fetch(path, {
7755
- method: opts.method || "GET",
7756
- headers: headers,
7757
- body: opts.body
7758
- }).then(function (res) {
7759
- if (res.status === 401) {
7760
- dropToken();
7761
- state.token = "";
7762
- state.error = "Token rejected (401). Enter it again.";
7763
- showTokenBar(true);
7764
- render();
7765
- var err = new Error("unauthorized");
7766
- err.unauthorized = true;
7767
- throw err;
7768
- }
7769
- return res;
7770
- });
7771
- }
7772
-
7773
- function fetchTickets() {
7774
- if (!state.token) { return Promise.resolve(); }
7775
- return api("/api/tickets").then(function (res) {
7776
- if (!res.ok) { throw new Error("tickets " + res.status); }
7777
- return res.json();
7778
- }).then(function (data) {
7779
- state.tickets = (data && data.tickets) || [];
7780
- state.error = "";
7781
- render();
7782
- }).catch(function (err) {
7783
- if (err && err.unauthorized) { return; }
7784
- state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");
7785
- render();
7786
- });
7787
- }
7788
-
7789
- /* ---------------- live updates (requirement 1) ---------------- */
7790
-
7791
- function startPolling() {
7792
- if (state.pollTimer) { return; }
7793
- state.live = false;
7794
- fetchTickets();
7795
- state.pollTimer = setInterval(fetchTickets, POLL_MS);
7796
- render();
7797
- }
7798
- function stopPolling() {
7799
- if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
7800
- }
7801
-
7802
- /* Fetch-based SSE reader.
7803
- EventSource cannot attach an Authorization header, so a Bearer-protected
7804
- /api/events is unreachable through it. Instead we fetch the endpoint with the
7805
- token, read response.body through a ReadableStream reader, decode with a
7806
- TextDecoder, and parse text/event-stream frames: frames are separated by a blank
7807
- line, and the payload rides on \`data:\` lines. Any failure \u2014 a network error, a
7808
- non-200 status, or a Worker's 501 \u2014 falls back to polling /api/tickets every 5 s,
7809
- which is the Worker target's live-update path. */
7810
- function handleFrame(frame) {
7811
- var lines = frame.split("\\n");
7812
- var sawData = false;
7813
- for (var i = 0; i < lines.length; i++) {
7814
- if (lines[i].indexOf("data:") === 0) { sawData = true; }
7815
- }
7816
- if (sawData) { fetchTickets(); }
7817
- }
7818
-
7819
- function readSseStream(body) {
7820
- var reader = body.getReader();
7821
- var decoder = new TextDecoder();
7822
- var buffer = "";
7823
- function pump() {
7824
- return reader.read().then(function (chunk) {
7825
- if (chunk.done) { throw new Error("sse stream closed"); }
7826
- buffer += decoder.decode(chunk.value, { stream: true });
7827
- var idx;
7828
- while ((idx = buffer.indexOf("\\n\\n")) !== -1) {
7829
- var frame = buffer.slice(0, idx);
7830
- buffer = buffer.slice(idx + 2);
7831
- handleFrame(frame);
7832
- }
7833
- return pump();
7834
- });
7835
- }
7836
- return pump();
7837
- }
7838
-
7839
- function startLive() {
7840
- stopLive();
7841
- if (!state.token) { return; }
7842
- if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }
7843
-
7844
- var controller = null;
7845
- try { controller = new AbortController(); } catch (e) { controller = null; }
7846
- if (controller) { state.abort = controller; }
7847
-
7848
- var options = { headers: { "Authorization": "Bearer " + state.token } };
7849
- if (controller) { options.signal = controller.signal; }
7850
-
7851
- fetch("/api/events", options).then(function (res) {
7852
- if (!res.ok || !res.body) {
7853
- /* 401, 404, a Worker's 501, or a body we cannot stream: poll instead. */
7854
- startPolling();
7855
- return;
7856
- }
7857
- state.live = true;
7858
- stopPolling();
7859
- render();
7860
- return readSseStream(res.body);
7861
- }).catch(function () {
7862
- if (controller && controller.signal.aborted) { return; }
7863
- state.live = false;
7864
- startPolling();
7865
- });
7866
- }
7867
- function stopLive() {
7868
- if (state.abort) {
7869
- try { state.abort.abort(); } catch (e) { /* ignore */ }
7870
- state.abort = null;
7871
- }
7872
- state.live = false;
7873
- }
7874
-
7875
- /* ---------------- derived data ---------------- */
7876
-
7877
- function pendingTickets() {
7878
- return state.tickets.filter(function (t) { return t.status === "pending"; });
7879
- }
7880
- function recentTickets() {
7881
- var now = Date.now();
7882
- return state.tickets.filter(function (t) {
7883
- if (t.status !== "answered" || !t.answered_at) { return false; }
7884
- return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;
7885
- }).sort(function (a, b) {
7886
- return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();
7887
- });
7888
- }
7889
-
7890
- function groupByStream(tickets) {
7891
- var groups = {};
7892
- var order = [];
7893
- tickets.forEach(function (t) {
7894
- if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }
7895
- groups[t.stream].push(t);
7896
- });
7897
- order.forEach(function (s) {
7898
- groups[s].sort(function (a, b) {
7899
- return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
7900
- });
7901
- });
7902
- order.sort(function (a, b) {
7903
- return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();
7904
- });
7905
- return order.map(function (s) { return { stream: s, tickets: groups[s] }; });
7906
- }
7907
-
7908
- function orderedPending() {
7909
- var out = [];
7910
- groupByStream(pendingTickets()).forEach(function (g) {
7911
- g.tickets.forEach(function (t) { out.push(t); });
7912
- });
7913
- return out;
7914
- }
7915
-
7916
- function age(iso) {
7917
- var ms = Date.now() - new Date(iso).getTime();
7918
- if (!isFinite(ms) || ms < 0) { ms = 0; }
7919
- var s = Math.floor(ms / 1000);
7920
- if (s < 60) { return s + "s"; }
7921
- var m = Math.floor(s / 60);
7922
- if (m < 60) { return m + "m"; }
7923
- var h = Math.floor(m / 60);
7924
- if (h < 24) { return h + "h"; }
7925
- return Math.floor(h / 24) + "d";
7519
+ if (base.startsWith("audio/")) {
7520
+ return "audio";
7926
7521
  }
7927
-
7928
- /* ---------------- actions ---------------- */
7929
-
7930
- function setCardError(id, msg) {
7931
- var el = document.getElementById("err-" + id);
7932
- if (el) {
7933
- el.hidden = false;
7934
- var span = el.querySelector(".err-text");
7935
- if (span) { span.textContent = msg; }
7936
- }
7937
- }
7938
-
7939
- function submitAnswer(ticket) {
7940
- var input = document.getElementById("free-" + ticket.id);
7941
- var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();
7942
- var choice = state.choices[ticket.id] || "";
7943
- var answer = text || choice;
7944
- if (!answer) {
7945
- setCardError(ticket.id, "Type an answer or pick an option first.");
7946
- return;
7947
- }
7948
- api("/api/answer", {
7949
- method: "POST",
7950
- body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human" })
7951
- }).then(function (res) {
7952
- if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }
7953
- if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }
7954
- if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }
7955
- delete state.choices[ticket.id];
7956
- delete state.drafts[ticket.id];
7957
- return fetchTickets();
7958
- }).catch(function (err) {
7959
- if (err && err.unauthorized) { return; }
7960
- setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));
7961
- });
7522
+ if (base.startsWith("video/")) {
7523
+ return "video";
7962
7524
  }
7963
-
7964
- function undo(ticketId) {
7965
- api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })
7966
- .then(function (res) {
7967
- if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }
7968
- if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }
7969
- return fetchTickets();
7970
- })
7971
- .catch(function (err) {
7972
- if (err && err.unauthorized) { return; }
7973
- state.error = "Undo failed: " + (err && err.message ? err.message : "network error");
7974
- render();
7975
- });
7976
- }
7977
-
7978
- function undoLast() {
7979
- var recent = recentTickets();
7980
- if (recent.length === 0) { return; }
7981
- undo(recent[0].id);
7982
- }
7983
-
7984
- /* ---------------- keyboard (requirement 4) ---------------- */
7985
-
7986
- function isTypingTarget(el) {
7987
- if (!el) { return false; }
7988
- var tag = el.tagName;
7989
- return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;
7990
- }
7991
-
7992
- function moveSelection(delta) {
7993
- var list = orderedPending();
7994
- if (list.length === 0) { return; }
7995
- var idx = -1;
7996
- for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }
7997
- if (idx === -1) { idx = delta > 0 ? -1 : 0; }
7998
- var next = idx + delta;
7999
- if (next < 0) { next = 0; }
8000
- if (next > list.length - 1) { next = list.length - 1; }
8001
- state.selectedId = list[next].id;
8002
- render();
8003
- var card = document.getElementById("card-" + state.selectedId);
8004
- if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }
8005
- }
8006
-
8007
- function pickOption(n) {
8008
- var list = orderedPending();
8009
- var ticket = null;
8010
- for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }
8011
- if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }
8012
- state.choices[ticket.id] = ticket.options[n - 1];
8013
- render();
8014
- }
8015
-
8016
- function submitSelected() {
8017
- var list = orderedPending();
8018
- for (var i = 0; i < list.length; i++) {
8019
- if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }
8020
- }
8021
- }
8022
-
8023
- document.addEventListener("keydown", function (e) {
8024
- if (isTypingTarget(e.target)) { return; }
8025
- if (e.metaKey || e.ctrlKey || e.altKey) { return; }
8026
- if (e.key === "j") { e.preventDefault(); moveSelection(1); }
8027
- else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }
8028
- else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }
8029
- else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }
8030
- else if (e.key === "u") { e.preventDefault(); undoLast(); }
8031
- });
8032
-
8033
- /* ---------------- rendering ---------------- */
8034
-
8035
- function captureDrafts() {
8036
- var inputs = document.querySelectorAll("[data-draft]");
8037
- for (var i = 0; i < inputs.length; i++) {
8038
- state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;
8039
- }
7525
+ return void 0;
7526
+ }
7527
+ function dataUriMime(input) {
7528
+ const match = /^data:([^;,]+);base64,/i.exec(input.trim());
7529
+ return match === null ? void 0 : match[1].toLowerCase();
7530
+ }
7531
+ function stripDataUri(input) {
7532
+ return input.trim().replace(/^data:[^;,]+;base64,/i, "");
7533
+ }
7534
+ function normalizeBase64(input) {
7535
+ return input.replace(/\s+/g, "");
7536
+ }
7537
+ function base64ToBytes(base642) {
7538
+ const binary = atob(base642);
7539
+ const bytes = new Uint8Array(binary.length);
7540
+ for (let index = 0; index < binary.length; index += 1) {
7541
+ bytes[index] = binary.charCodeAt(index);
8040
7542
  }
8041
-
8042
- function el(tag, cls, text) {
8043
- var node = document.createElement(tag);
8044
- if (cls) { node.className = cls; }
8045
- if (text !== undefined && text !== null) { node.textContent = text; }
8046
- return node;
7543
+ return bytes;
7544
+ }
7545
+ function bytesToBase64(bytes) {
7546
+ let binary = "";
7547
+ const chunk = 32768;
7548
+ for (let offset = 0; offset < bytes.length; offset += chunk) {
7549
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk));
8047
7550
  }
8048
-
8049
- function urgencyBadge(urgency) {
8050
- var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);
8051
- return b;
7551
+ return btoa(binary);
7552
+ }
7553
+ function base64ByteLength(base642) {
7554
+ const length = base642.length;
7555
+ if (length === 0) {
7556
+ return 0;
8052
7557
  }
8053
-
8054
- function buildCard(ticket, isRecent) {
8055
- var card = el("article", "card" + (ticket.id === state.selectedId ? " selected" : ""));
8056
- card.id = "card-" + ticket.id;
8057
- card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);
8058
-
8059
- var head = el("div", "card-head");
8060
- head.appendChild(el("span", "selmark", "selected"));
8061
- head.appendChild(el("span", "stream-name", ticket.stream));
8062
- head.appendChild(el("span", "age", age(ticket.created_at) + " old"));
8063
- head.appendChild(urgencyBadge(ticket.urgency));
8064
- if (isRecent) {
8065
- head.appendChild(el("span", "badge status-answered", "status: answered"));
8066
- if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }
8067
- }
8068
- card.appendChild(head);
8069
-
8070
- card.appendChild(el("p", "question", ticket.question));
8071
-
8072
- if (ticket.context) {
8073
- var ctx = el("div", "context", ticket.context);
8074
- card.appendChild(ctx);
8075
- }
8076
-
8077
- if (isRecent) {
8078
- var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));
8079
- card.appendChild(ans);
8080
- var undoBtn = el("button", null, "Undo");
8081
- undoBtn.type = "button";
8082
- undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);
8083
- undoBtn.addEventListener("click", function () { undo(ticket.id); });
8084
- var row = el("div", "answer-row");
8085
- row.appendChild(undoBtn);
8086
- card.appendChild(row);
8087
- return card;
8088
- }
8089
-
8090
- if (ticket.options && ticket.options.length) {
8091
- var opts = el("div", "options");
8092
- opts.setAttribute("role", "group");
8093
- opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);
8094
- ticket.options.forEach(function (opt, i) {
8095
- var b = el("button", null, (i + 1) + ". " + opt);
8096
- b.type = "button";
8097
- b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");
8098
- b.addEventListener("click", function () {
8099
- state.choices[ticket.id] = opt;
8100
- state.selectedId = ticket.id;
8101
- render();
8102
- });
8103
- opts.appendChild(b);
8104
- });
8105
- card.appendChild(opts);
8106
- }
8107
-
8108
- var form = el("form", "answer-row");
8109
- form.setAttribute("novalidate", "novalidate");
8110
- var label = el("label", null, "Answer");
8111
- label.setAttribute("for", "free-" + ticket.id);
8112
- form.appendChild(label);
8113
- var input = document.createElement("input");
8114
- input.type = "text";
8115
- input.id = "free-" + ticket.id;
8116
- input.setAttribute("data-draft", ticket.id);
8117
- input.setAttribute("autocomplete", "off");
8118
- input.value = state.drafts[ticket.id] || "";
8119
- input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });
8120
- input.addEventListener("focus", function () { state.selectedId = ticket.id; });
8121
- form.appendChild(input);
8122
- var submit = el("button", null, "Answer");
8123
- submit.type = "submit";
8124
- form.appendChild(submit);
8125
- form.addEventListener("submit", function (e) {
8126
- e.preventDefault();
8127
- submitAnswer(ticket);
8128
- });
8129
- card.appendChild(form);
8130
-
8131
- var err = el("div", "card-error");
8132
- err.id = "err-" + ticket.id;
8133
- err.hidden = true;
8134
- err.setAttribute("role", "alert");
8135
- err.appendChild(el("span", "tag", "Error: "));
8136
- err.appendChild(el("span", "err-text", ""));
8137
- card.appendChild(err);
8138
-
8139
- return card;
7558
+ let padding = 0;
7559
+ if (base642.endsWith("==")) {
7560
+ padding = 2;
7561
+ } else if (base642.endsWith("=")) {
7562
+ padding = 1;
7563
+ }
7564
+ return Math.floor(length * 3 / 4) - padding;
7565
+ }
7566
+ function buildMediaRef(id, input, now2) {
7567
+ const ref = {
7568
+ id,
7569
+ kind: mediaKind(input.mime_type) ?? "image",
7570
+ mime_type: input.mime_type,
7571
+ size_bytes: base64ByteLength(input.data_base64),
7572
+ created_at: now2
7573
+ };
7574
+ if (input.filename !== void 0) {
7575
+ ref.filename = input.filename;
8140
7576
  }
8141
-
8142
- function render() {
8143
- captureDrafts();
8144
-
8145
- var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;
8146
- var selStart = null, selEnd = null;
8147
- if (activeId && document.activeElement.setSelectionRange) {
8148
- try {
8149
- selStart = document.activeElement.selectionStart;
8150
- selEnd = document.activeElement.selectionEnd;
8151
- } catch (e) { /* not a text input */ }
8152
- }
8153
-
8154
- var pending = pendingTickets();
8155
- var recent = recentTickets();
8156
-
8157
- /* requirement 5: tab title carries the count */
8158
- document.title = pending.length > 0 ? "(" + pending.length + ") readyroom" : "readyroom";
8159
-
8160
- /* status line */
8161
- var statusText = "idle";
8162
- if (state.live) { statusText = "live (server-sent events)"; }
8163
- else if (state.pollTimer) { statusText = "polling every 5s"; }
8164
- else if (!state.token) { statusText = "waiting for token"; }
8165
- els.statusText.textContent = statusText;
8166
-
8167
- if (state.error) {
8168
- els.error.hidden = false;
8169
- els.errorText.textContent = state.error;
8170
- } else {
8171
- els.error.hidden = true;
8172
- els.errorText.textContent = "";
8173
- }
8174
-
8175
- /* pending, grouped by stream, newest stream first */
8176
- els.pending.textContent = "";
8177
- if (pending.length === 0) {
8178
- els.pending.appendChild(el("p", "empty", "No pending tickets."));
8179
- } else {
8180
- groupByStream(pending).forEach(function (group) {
8181
- var section = el("section", "stream-group");
8182
- section.setAttribute("aria-label", "Stream " + group.stream);
8183
- var head = el("div", "stream-head");
8184
- head.appendChild(el("h3", "stream-name", group.stream));
8185
- head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));
8186
- section.appendChild(head);
8187
- group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });
8188
- els.pending.appendChild(section);
8189
- });
8190
- }
8191
-
8192
- /* recent, undoable for 5 minutes */
8193
- els.recent.textContent = "";
8194
- if (recent.length === 0) {
8195
- els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));
8196
- } else {
8197
- recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });
8198
- }
8199
-
8200
- /* restore focus + caret so polling re-renders do not interrupt typing */
8201
- if (activeId) {
8202
- var again = document.getElementById(activeId);
8203
- if (again && again.focus) {
8204
- try {
8205
- again.focus();
8206
- if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }
8207
- } catch (e) { /* ignore */ }
8208
- }
8209
- }
7577
+ if (input.caption !== void 0) {
7578
+ ref.caption = input.caption;
8210
7579
  }
8211
-
8212
- /* ---------------- wiring ---------------- */
8213
-
8214
- els.tokenSave.addEventListener("click", function () {
8215
- var t = els.tokenInput.value.trim();
8216
- if (!t) { return; }
8217
- state.token = t;
8218
- writeToken(t);
8219
- state.error = "";
8220
- showTokenBar(false);
8221
- startLive();
8222
- fetchTickets();
8223
- });
8224
- els.tokenInput.addEventListener("keydown", function (e) {
8225
- if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }
8226
- });
8227
-
8228
- /* requirement 9: re-render on window load */
8229
- window.addEventListener("load", render);
8230
-
8231
- /* boot */
8232
- state.token = readToken();
8233
- if (state.token) {
8234
- showTokenBar(false);
8235
- startLive();
8236
- fetchTickets();
8237
- } else {
8238
- showTokenBar(true);
8239
- }
8240
-
8241
- /* requirement 9: first paint is synchronous, at the end of the script, not in rAF */
8242
- render();
8243
- })();
8244
- </script>
8245
- </body>
8246
- </html>
8247
- `;
7580
+ return ref;
7581
+ }
7582
+ function isSafeMediaId(id) {
7583
+ return /^[A-Za-z0-9_-]{1,128}$/.test(id);
7584
+ }
8248
7585
 
8249
7586
  // src/core/stream.ts
8250
7587
  function resolveStream(explicit, defaultStream) {
@@ -8292,6 +7629,11 @@ function makeAudit(ticketId, action, at, by, answer2) {
8292
7629
  }
8293
7630
  return entry;
8294
7631
  }
7632
+ async function uploadMedia(store, input, now2) {
7633
+ const id = newId();
7634
+ await store.putMedia({ id, mime_type: input.mime_type, data_base64: input.data_base64 });
7635
+ return buildMediaRef(id, input, now2);
7636
+ }
8295
7637
  async function enqueue(store, input, options) {
8296
7638
  const stream = resolveStream(input.stream, options.defaultStream);
8297
7639
  const timeoutSeconds = input.timeout_seconds ?? DEFAULT_TIMEOUT_SECONDS;
@@ -8315,8 +7657,19 @@ async function enqueue(store, input, options) {
8315
7657
  if (input.default_answer !== void 0) {
8316
7658
  ticket.default_answer = input.default_answer;
8317
7659
  }
7660
+ const media = [];
7661
+ if (input.media !== void 0 && input.media.length > 0) {
7662
+ for (const item of input.media) {
7663
+ media.push(await uploadMedia(store, item, created_at));
7664
+ }
7665
+ ticket.media = media;
7666
+ }
8318
7667
  await store.put(ticket);
8319
- await store.appendAudit(makeAudit(ticket.id, "created", created_at, stream));
7668
+ const createdAudit = makeAudit(ticket.id, "created", created_at, stream);
7669
+ if (media.length > 0) {
7670
+ createdAudit.media_count = media.length;
7671
+ }
7672
+ await store.appendAudit(createdAudit);
8320
7673
  return ticket;
8321
7674
  }
8322
7675
  function resolveExpiry(ticket, now2) {
@@ -8362,7 +7715,7 @@ async function listResolved(store, filter, now2) {
8362
7715
  }
8363
7716
  return out;
8364
7717
  }
8365
- async function answer(store, id, value, answeredBy, now2) {
7718
+ async function answer(store, id, value, answeredBy, now2, annotations = []) {
8366
7719
  const existing = await getResolved(store, id, now2);
8367
7720
  if (existing === void 0) {
8368
7721
  throw notFound(id);
@@ -8370,15 +7723,62 @@ async function answer(store, id, value, answeredBy, now2) {
8370
7723
  if (existing.status !== "pending") {
8371
7724
  throw conflict(`ticket ${id} is ${existing.status}; only a pending ticket can be answered`);
8372
7725
  }
7726
+ const resolved = [];
7727
+ for (const input of annotations) {
7728
+ const annotation = { id: newId() };
7729
+ if (input.text !== void 0) {
7730
+ annotation.text = input.text;
7731
+ }
7732
+ if (input.media_id !== void 0) {
7733
+ annotation.media_id = input.media_id;
7734
+ }
7735
+ if (input.at_seconds !== void 0) {
7736
+ annotation.at_seconds = input.at_seconds;
7737
+ }
7738
+ if (input.annotated_media_id !== void 0) {
7739
+ const blob = await store.getMedia(input.annotated_media_id);
7740
+ if (blob === void 0) {
7741
+ throw new QueueError(
7742
+ 400,
7743
+ `annotation media ${input.annotated_media_id} not found; upload it with POST /api/media first`
7744
+ );
7745
+ }
7746
+ const kind = mediaKind(blob.mime_type);
7747
+ if (kind !== "image") {
7748
+ throw new QueueError(400, `annotation media ${input.annotated_media_id} must be an image`);
7749
+ }
7750
+ annotation.annotated_media = {
7751
+ id: blob.id,
7752
+ kind,
7753
+ mime_type: blob.mime_type,
7754
+ size_bytes: base64ByteLength(blob.data_base64),
7755
+ created_at: now2
7756
+ };
7757
+ }
7758
+ resolved.push(annotation);
7759
+ }
7760
+ const trimmed = value.trim();
7761
+ if (trimmed === "" && resolved.length === 0) {
7762
+ throw new QueueError(400, "an answer must include text, a chosen option, or at least one annotation");
7763
+ }
8373
7764
  const ticket = {
8374
7765
  ...existing,
8375
7766
  status: "answered",
8376
- answer: value,
8377
7767
  answered_by: answeredBy,
8378
7768
  answered_at: now2
8379
7769
  };
7770
+ if (trimmed !== "") {
7771
+ ticket.answer = value;
7772
+ }
7773
+ if (resolved.length > 0) {
7774
+ ticket.annotations = resolved;
7775
+ }
8380
7776
  await store.put(ticket);
8381
- await store.appendAudit(makeAudit(id, "answered", now2, answeredBy, value));
7777
+ const answeredAudit = makeAudit(id, "answered", now2, answeredBy, trimmed !== "" ? value : void 0);
7778
+ if (resolved.length > 0) {
7779
+ answeredAudit.annotation_count = resolved.length;
7780
+ }
7781
+ await store.appendAudit(answeredAudit);
8382
7782
  return ticket;
8383
7783
  }
8384
7784
  async function undo(store, id, now2) {
@@ -8400,6 +7800,7 @@ async function undo(store, id, now2) {
8400
7800
  }
8401
7801
  const reverted = { ...ticket, status: "pending" };
8402
7802
  delete reverted.answer;
7803
+ delete reverted.annotations;
8403
7804
  delete reverted.answered_by;
8404
7805
  delete reverted.answered_at;
8405
7806
  await store.put(reverted);
@@ -8445,8 +7846,20 @@ var ASK_HUMAN_KEYS = [
8445
7846
  "default_answer",
8446
7847
  "timeout_seconds",
8447
7848
  "urgency",
8448
- "stream"
7849
+ "stream",
7850
+ "media"
8449
7851
  ];
7852
+ var MEDIA_KEYS = ["mime_type", "data_base64", "filename", "caption"];
7853
+ var ANNOTATION_KEYS = ["text", "media_id", "at_seconds", "annotated_media_id"];
7854
+ function formatBytes(bytes) {
7855
+ if (bytes < 1024) {
7856
+ return `${bytes} B`;
7857
+ }
7858
+ if (bytes < 1024 * 1024) {
7859
+ return `${(bytes / 1024).toFixed(1)} KiB`;
7860
+ }
7861
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
7862
+ }
8450
7863
  function fail(tool, message) {
8451
7864
  throw new ValidationError(tool, message);
8452
7865
  }
@@ -8499,6 +7912,174 @@ function optionalInteger(tool, args, key, min, max) {
8499
7912
  }
8500
7913
  return value;
8501
7914
  }
7915
+ function normalizeMediaData(tool, label, rawData, cap) {
7916
+ if (rawData.trim() === "") {
7917
+ fail(tool, `\`${label}\` must be a non-empty base64 string`);
7918
+ }
7919
+ const base642 = normalizeBase64(stripDataUri(rawData));
7920
+ const size = base64ByteLength(base642);
7921
+ if (size > cap) {
7922
+ fail(tool, `\`${label}\` is ${formatBytes(size)}; the limit is ${formatBytes(cap)}`);
7923
+ }
7924
+ try {
7925
+ base64ToBytes(base642);
7926
+ } catch {
7927
+ fail(tool, `\`${label}\` is not valid base64`);
7928
+ }
7929
+ return { base64: base642, size };
7930
+ }
7931
+ function validateAskHumanMedia(tool, raw) {
7932
+ const value = raw["media"];
7933
+ if (value === void 0 || value === null) {
7934
+ return void 0;
7935
+ }
7936
+ if (!Array.isArray(value)) {
7937
+ fail(tool, "`media` must be an array of attachments when present");
7938
+ }
7939
+ if (value.length === 0) {
7940
+ return void 0;
7941
+ }
7942
+ if (value.length > MAX_MEDIA_FILES) {
7943
+ fail(tool, `\`media\` may contain at most ${MAX_MEDIA_FILES} attachments (got ${value.length})`);
7944
+ }
7945
+ const out = [];
7946
+ let totalBytes = 0;
7947
+ for (let index = 0; index < value.length; index += 1) {
7948
+ const item = value[index];
7949
+ const label = `media[${index}]`;
7950
+ if (!isRecord(item)) {
7951
+ fail(tool, `\`${label}\` must be an object`);
7952
+ }
7953
+ for (const key of Object.keys(item)) {
7954
+ if (!MEDIA_KEYS.includes(key)) {
7955
+ fail(
7956
+ tool,
7957
+ `unknown argument \`${label}.${key}\`. Allowed arguments: ${MEDIA_KEYS.join(", ")}`
7958
+ );
7959
+ }
7960
+ }
7961
+ const rawMime = item["mime_type"];
7962
+ const declaredMime = typeof rawMime === "string" && rawMime.trim() !== "" ? rawMime.trim().toLowerCase() : void 0;
7963
+ const rawData = item["data_base64"];
7964
+ if (typeof rawData !== "string") {
7965
+ fail(tool, `\`${label}.data_base64\` is required and must be a base64 string`);
7966
+ }
7967
+ const effectiveMime = declaredMime ?? dataUriMime(rawData);
7968
+ if (effectiveMime === void 0) {
7969
+ fail(tool, `\`${label}.mime_type\` is required (e.g. "image/png", "audio/mpeg", "video/mp4")`);
7970
+ }
7971
+ if (mediaKind(effectiveMime) === void 0) {
7972
+ fail(tool, `\`${label}.mime_type\` must be an image/*, audio/*, or video/* type (got "${effectiveMime}")`);
7973
+ }
7974
+ const { base64: base642, size } = normalizeMediaData(tool, `${label}.data_base64`, rawData, MAX_MEDIA_BYTES_PER_FILE);
7975
+ totalBytes += size;
7976
+ const entry = { mime_type: effectiveMime, data_base64: base642 };
7977
+ const filename = optionalString(tool, item, "filename");
7978
+ if (filename !== void 0 && filename.trim() !== "") {
7979
+ entry.filename = filename.trim();
7980
+ }
7981
+ const caption = optionalString(tool, item, "caption");
7982
+ if (caption !== void 0 && caption.trim() !== "") {
7983
+ entry.caption = caption.trim();
7984
+ }
7985
+ out.push(entry);
7986
+ }
7987
+ if (totalBytes > MAX_MEDIA_BYTES_PER_TICKET) {
7988
+ fail(
7989
+ tool,
7990
+ `\`media\` totals ${formatBytes(totalBytes)}; the per-ticket limit is ${formatBytes(MAX_MEDIA_BYTES_PER_TICKET)}`
7991
+ );
7992
+ }
7993
+ return out;
7994
+ }
7995
+ function validateMediaUpload(args) {
7996
+ const tool = "media_upload";
7997
+ const raw = requireRecord(tool, args);
7998
+ rejectUnknownKeys(tool, raw, MEDIA_KEYS);
7999
+ const rawMime = raw["mime_type"];
8000
+ const declaredMime = typeof rawMime === "string" && rawMime.trim() !== "" ? rawMime.trim().toLowerCase() : void 0;
8001
+ const rawData = raw["data_base64"];
8002
+ if (typeof rawData !== "string") {
8003
+ fail(tool, "`data_base64` is required and must be a base64 string");
8004
+ }
8005
+ const effectiveMime = declaredMime ?? dataUriMime(rawData);
8006
+ if (effectiveMime === void 0) {
8007
+ fail(tool, '`mime_type` is required (e.g. "image/png")');
8008
+ }
8009
+ if (mediaKind(effectiveMime) === void 0) {
8010
+ fail(tool, `\`mime_type\` must be an image/*, audio/*, or video/* type (got "${effectiveMime}")`);
8011
+ }
8012
+ const { base64: base642 } = normalizeMediaData(tool, "data_base64", rawData, MAX_ANNOTATION_BYTES);
8013
+ const entry = { mime_type: effectiveMime, data_base64: base642 };
8014
+ const filename = optionalString(tool, raw, "filename");
8015
+ if (filename !== void 0 && filename.trim() !== "") {
8016
+ entry.filename = filename.trim();
8017
+ }
8018
+ const caption = optionalString(tool, raw, "caption");
8019
+ if (caption !== void 0 && caption.trim() !== "") {
8020
+ entry.caption = caption.trim();
8021
+ }
8022
+ return entry;
8023
+ }
8024
+ function validateAnnotationInputs(args) {
8025
+ const tool = "answer";
8026
+ if (args === void 0 || args === null) {
8027
+ return [];
8028
+ }
8029
+ if (!Array.isArray(args)) {
8030
+ fail(tool, "`annotations` must be an array when present");
8031
+ }
8032
+ if (args.length > MAX_ANNOTATIONS) {
8033
+ fail(tool, `\`annotations\` may contain at most ${MAX_ANNOTATIONS} items (got ${args.length})`);
8034
+ }
8035
+ const out = [];
8036
+ for (let index = 0; index < args.length; index += 1) {
8037
+ const item = args[index];
8038
+ const label = `annotations[${index}]`;
8039
+ if (!isRecord(item)) {
8040
+ fail(tool, `\`${label}\` must be an object`);
8041
+ }
8042
+ for (const key of Object.keys(item)) {
8043
+ if (!ANNOTATION_KEYS.includes(key)) {
8044
+ fail(
8045
+ tool,
8046
+ `unknown argument \`${label}.${key}\`. Allowed arguments: ${ANNOTATION_KEYS.join(", ")}`
8047
+ );
8048
+ }
8049
+ }
8050
+ const text = optionalString(tool, item, "text");
8051
+ const annotatedMediaId = optionalString(tool, item, "annotated_media_id");
8052
+ if ((text === void 0 || text.trim() === "") && (annotatedMediaId === void 0 || annotatedMediaId.trim() === "")) {
8053
+ fail(tool, `\`${label}\` must carry \`text\` or \`annotated_media_id\``);
8054
+ }
8055
+ const entry = {};
8056
+ if (text !== void 0 && text.trim() !== "") {
8057
+ entry.text = text.trim();
8058
+ }
8059
+ if (annotatedMediaId !== void 0 && annotatedMediaId.trim() !== "") {
8060
+ if (!isSafeMediaId(annotatedMediaId)) {
8061
+ fail(tool, `\`${label}.annotated_media_id\` is not a valid media id`);
8062
+ }
8063
+ entry.annotated_media_id = annotatedMediaId;
8064
+ }
8065
+ const mediaId = optionalString(tool, item, "media_id");
8066
+ if (mediaId !== void 0 && mediaId.trim() !== "") {
8067
+ if (!isSafeMediaId(mediaId)) {
8068
+ fail(tool, `\`${label}.media_id\` is not a valid media id`);
8069
+ }
8070
+ entry.media_id = mediaId;
8071
+ }
8072
+ const atSeconds = item["at_seconds"];
8073
+ if (atSeconds !== void 0 && atSeconds !== null) {
8074
+ if (typeof atSeconds !== "number" || !Number.isFinite(atSeconds) || atSeconds < 0) {
8075
+ fail(tool, `\`${label}.at_seconds\` must be a non-negative number of seconds`);
8076
+ }
8077
+ entry.at_seconds = atSeconds;
8078
+ }
8079
+ out.push(entry);
8080
+ }
8081
+ return out;
8082
+ }
8502
8083
  function extraSentenceTerminators(question) {
8503
8084
  const scrubbed = question.replace(/\b(e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St)\./gi, (match) => match.replace(/\./g, "")).replace(/\d+\.\d+/g, "");
8504
8085
  const withoutTrailing = scrubbed.replace(/[.!?]+\s*$/, "");
@@ -8543,6 +8124,7 @@ function validateAskHuman(args) {
8543
8124
  const context = optionalString(tool, raw, "context");
8544
8125
  const default_answer = optionalString(tool, raw, "default_answer");
8545
8126
  const stream = optionalString(tool, raw, "stream");
8127
+ const media = validateAskHumanMedia(tool, raw);
8546
8128
  const result = { question };
8547
8129
  if (options !== void 0) {
8548
8130
  result.options = options;
@@ -8562,6 +8144,9 @@ function validateAskHuman(args) {
8562
8144
  if (stream !== void 0) {
8563
8145
  result.stream = stream;
8564
8146
  }
8147
+ if (media !== void 0) {
8148
+ result.media = media;
8149
+ }
8565
8150
  return result;
8566
8151
  }
8567
8152
  function validateWaitForAnswer(args) {
@@ -8596,7 +8181,9 @@ var ASK_HUMAN_DESCRIPTION = [
8596
8181
  "",
8597
8182
  "Protocol you must follow: on `pending`, continue with independent work and call `wait_for_answer` again later \u2014 do not poll in a tight loop.",
8598
8183
  "",
8599
- 'Set `default_answer` only when it is safe for you to proceed without a human; it is used only if the ticket expires. Set `options` for 2-5 mutually exclusive choices. `timeout_seconds` defaults to 1800 and maxes at 86400. `urgency` defaults to "normal".'
8184
+ 'Set `default_answer` only when it is safe for you to proceed without a human; it is used only if the ticket expires. Set `options` for 2-5 mutually exclusive choices. `timeout_seconds` defaults to 1800 and maxes at 86400. `urgency` defaults to "normal".',
8185
+ "",
8186
+ "Optionally attach `media` (screenshots, audio, or video) for the human to inspect \u2014 media is never required. Each item is { mime_type, data_base64 } with optional `filename` and `caption`; `data_base64` is standard base64 (a `data:<mime>;base64,` prefix is accepted). The human may answer with a decision, with annotations on your media, or both; `wait_for_answer` returns any annotations, and rendered markup comes back as image content."
8600
8187
  ].join("\n");
8601
8188
  var WAIT_FOR_ANSWER_DESCRIPTION = [
8602
8189
  "Wait for the human to answer a ticket created by ask_human, then return the result.",
@@ -8647,6 +8234,28 @@ var TOOL_DEFINITIONS = [
8647
8234
  stream: {
8648
8235
  type: "string",
8649
8236
  description: "Optional. Overrides the configured default stream for this ticket."
8237
+ },
8238
+ media: {
8239
+ type: "array",
8240
+ maxItems: 8,
8241
+ description: "Optional. Media (screenshots, audio, video) for the human to inspect. Never required.",
8242
+ items: {
8243
+ type: "object",
8244
+ properties: {
8245
+ mime_type: {
8246
+ type: "string",
8247
+ description: 'An image/*, audio/*, or video/* type, e.g. "image/png".'
8248
+ },
8249
+ data_base64: {
8250
+ type: "string",
8251
+ description: "Standard base64 bytes. A `data:<mime>;base64,` prefix is accepted."
8252
+ },
8253
+ filename: { type: "string", description: 'Optional. A display name, e.g. "before.png".' },
8254
+ caption: { type: "string", description: "Optional. A caption shown with the attachment." }
8255
+ },
8256
+ required: ["data_base64"],
8257
+ additionalProperties: false
8258
+ }
8650
8259
  }
8651
8260
  },
8652
8261
  required: ["question"],
@@ -8719,12 +8328,63 @@ function waitOutput(ticket) {
8719
8328
  if (ticket.answered_at !== void 0) {
8720
8329
  out.answered_at = ticket.answered_at;
8721
8330
  }
8331
+ if (ticket.media !== void 0) {
8332
+ out.media = ticket.media;
8333
+ }
8334
+ if (ticket.annotations !== void 0) {
8335
+ out.annotations = ticket.annotations;
8336
+ }
8722
8337
  return { ...out };
8723
8338
  }
8339
+ function annotationSummary(ticket) {
8340
+ const annotations = ticket.annotations;
8341
+ if (annotations === void 0 || annotations.length === 0) {
8342
+ return void 0;
8343
+ }
8344
+ const notes = annotations.filter((annotation) => annotation.text !== void 0).length;
8345
+ const markup = annotations.filter((annotation) => annotation.annotated_media !== void 0).length;
8346
+ const parts = [];
8347
+ if (notes > 0) {
8348
+ parts.push(`${notes} note${notes === 1 ? "" : "s"}`);
8349
+ }
8350
+ if (markup > 0) {
8351
+ parts.push(`${markup} annotated image${markup === 1 ? "" : "s"}`);
8352
+ }
8353
+ return `The human also left ${annotations.length} annotation(s) (${parts.join(", ")}); see the attached content and the structured "annotations" field.`;
8354
+ }
8355
+ async function waitResult(ticket, waitedSeconds, store) {
8356
+ const content = [{ type: "text", text: waitText(ticket, waitedSeconds) }];
8357
+ if (ticket.status === "answered" && ticket.annotations !== void 0) {
8358
+ for (const annotation of ticket.annotations) {
8359
+ const ref = annotation.annotated_media;
8360
+ if (ref === void 0) {
8361
+ continue;
8362
+ }
8363
+ const blob = await store.getMedia(ref.id);
8364
+ if (blob === void 0) {
8365
+ continue;
8366
+ }
8367
+ if (ref.kind === "image") {
8368
+ content.push({ type: "image", data: blob.data_base64, mimeType: blob.mime_type });
8369
+ } else if (ref.kind === "audio") {
8370
+ content.push({ type: "audio", data: blob.data_base64, mimeType: blob.mime_type });
8371
+ }
8372
+ }
8373
+ }
8374
+ return { content, structuredContent: waitOutput(ticket) };
8375
+ }
8724
8376
  function waitText(ticket, waitedSeconds) {
8725
8377
  switch (ticket.status) {
8726
- case "answered":
8727
- return answerText(ticket.answer, ticket.answered_by);
8378
+ case "answered": {
8379
+ const lines = [
8380
+ ticket.answer !== void 0 && ticket.answer !== "" ? answerText(ticket.answer, ticket.answered_by) : "HUMAN ANSWER: (no text; the human responded with annotations only \u2014 see the attached content)"
8381
+ ];
8382
+ const summary = annotationSummary(ticket);
8383
+ if (summary !== void 0) {
8384
+ lines.push(summary);
8385
+ }
8386
+ return lines.join("\n\n");
8387
+ }
8728
8388
  case "expired":
8729
8389
  return ticket.answer !== void 0 ? `DEFAULT ANSWER (applied on expiry; no human responded): ${ticket.answer}` : `Ticket ${ticket.id} expired with no answer and no default_answer was set. Do not assume an answer: decide for yourself or ask again with ask_human.`;
8730
8390
  case "cancelled":
@@ -8740,14 +8400,20 @@ async function runAskHuman(args, deps) {
8740
8400
  defaultStream: deps.defaultStream,
8741
8401
  now: deps.now()
8742
8402
  });
8743
- const text = `Ticket ${ticket.id} enqueued (stream "${ticket.stream}", urgency ${ticket.urgency}, status pending, expires_at ${ticket.expires_at}). Continue with independent work and call wait_for_answer with id "${ticket.id}" later. Do not poll in a tight loop.`;
8744
- return textResult(text, {
8403
+ const mediaCount = ticket.media?.length ?? 0;
8404
+ const mediaText = mediaCount > 0 ? `, ${mediaCount} media attachment(s)` : "";
8405
+ const text = `Ticket ${ticket.id} enqueued (stream "${ticket.stream}", urgency ${ticket.urgency}, status pending${mediaText}, expires_at ${ticket.expires_at}). Continue with independent work and call wait_for_answer with id "${ticket.id}" later. Do not poll in a tight loop.`;
8406
+ const structured = {
8745
8407
  id: ticket.id,
8746
8408
  status: "pending",
8747
8409
  stream: ticket.stream,
8748
8410
  urgency: ticket.urgency,
8749
8411
  expires_at: ticket.expires_at
8750
- });
8412
+ };
8413
+ if (ticket.media !== void 0) {
8414
+ structured.media = ticket.media;
8415
+ }
8416
+ return textResult(text, structured);
8751
8417
  }
8752
8418
  async function runWaitForAnswer(args, deps) {
8753
8419
  const input = validateWaitForAnswer(args);
@@ -8761,10 +8427,10 @@ async function runWaitForAnswer(args, deps) {
8761
8427
  throw notFound(input.id);
8762
8428
  }
8763
8429
  if (ticket.status !== "pending") {
8764
- return textResult(waitText(ticket, effective), waitOutput(ticket));
8430
+ return waitResult(ticket, effective, deps.store);
8765
8431
  }
8766
8432
  if (Date.now() >= deadline) {
8767
- return textResult(waitText(ticket, effective), waitOutput(ticket));
8433
+ return waitResult(ticket, effective, deps.store);
8768
8434
  }
8769
8435
  await sleep(POLL_INTERVAL_MS);
8770
8436
  }
@@ -9015,7 +8681,6 @@ function buildAllowedOrigins(port, extra) {
9015
8681
  // src/http.ts
9016
8682
  var SWEEP_MS = 5e3;
9017
8683
  var HEARTBEAT_MS = 25e3;
9018
- var MAX_BODY_BYTES = 1e6;
9019
8684
  var HttpError = class extends Error {
9020
8685
  status;
9021
8686
  constructor(status, message) {
@@ -9153,14 +8818,69 @@ function createHttpApp(options) {
9153
8818
  void sweep();
9154
8819
  async function handleAnswer(req, res) {
9155
8820
  const body = await readJsonBody(req);
9156
- if (!isRecord2(body) || typeof body["id"] !== "string" || typeof body["answer"] !== "string") {
9157
- throw new HttpError(400, "body must be { id: string, answer: string, answered_by?: string }");
8821
+ if (!isRecord2(body) || typeof body["id"] !== "string") {
8822
+ throw new HttpError(
8823
+ 400,
8824
+ "body must be { id: string, answer?: string, answered_by?: string, annotations?: AnnotationInput[] }"
8825
+ );
8826
+ }
8827
+ const rawAnswer = body["answer"];
8828
+ if (rawAnswer !== void 0 && rawAnswer !== null && typeof rawAnswer !== "string") {
8829
+ throw new HttpError(400, "`answer` must be a string when present");
9158
8830
  }
9159
8831
  const answeredBy = body["answered_by"] === "default" ? "default" : "human";
9160
- const ticket = await answer(store, body["id"], body["answer"], answeredBy, now2());
8832
+ let annotations;
8833
+ try {
8834
+ annotations = validateAnnotationInputs(body["annotations"]);
8835
+ } catch (error2) {
8836
+ if (error2 instanceof ValidationError) {
8837
+ throw new HttpError(400, error2.message);
8838
+ }
8839
+ throw error2;
8840
+ }
8841
+ const ticket = await answer(
8842
+ store,
8843
+ body["id"],
8844
+ typeof rawAnswer === "string" ? rawAnswer : "",
8845
+ answeredBy,
8846
+ now2(),
8847
+ annotations
8848
+ );
9161
8849
  broadcast();
9162
8850
  sendJson(res, 200, { ticket });
9163
8851
  }
8852
+ async function handleMediaUpload(req, res) {
8853
+ const body = await readJsonBody(req);
8854
+ let input;
8855
+ try {
8856
+ input = validateMediaUpload(body);
8857
+ } catch (error2) {
8858
+ if (error2 instanceof ValidationError) {
8859
+ throw new HttpError(400, error2.message);
8860
+ }
8861
+ throw error2;
8862
+ }
8863
+ const media = await uploadMedia(store, input, now2());
8864
+ sendJson(res, 201, { media });
8865
+ }
8866
+ async function handleMediaGet(res, id) {
8867
+ if (!isSafeMediaId(id)) {
8868
+ sendText(res, 404, "Not Found");
8869
+ return;
8870
+ }
8871
+ const blob = await store.getMedia(id);
8872
+ if (blob === void 0) {
8873
+ sendText(res, 404, "Not Found");
8874
+ return;
8875
+ }
8876
+ const bytes = Buffer.from(base64ToBytes(blob.data_base64));
8877
+ res.writeHead(200, {
8878
+ "content-type": blob.mime_type,
8879
+ "content-length": bytes.length,
8880
+ "cache-control": "no-store"
8881
+ });
8882
+ res.end(bytes);
8883
+ }
9164
8884
  async function handleUndo(req, res) {
9165
8885
  const body = await readJsonBody(req);
9166
8886
  if (!isRecord2(body) || typeof body["id"] !== "string") {
@@ -9199,6 +8919,38 @@ function createHttpApp(options) {
9199
8919
  await mcpHandler(req, res);
9200
8920
  return;
9201
8921
  }
8922
+ if (path === "/api/media") {
8923
+ if (method !== "POST") {
8924
+ res.writeHead(405, { allow: "POST" });
8925
+ res.end("Method Not Allowed");
8926
+ return;
8927
+ }
8928
+ if (!requireBearer(req, token)) {
8929
+ sendText(res, 401, "Unauthorized");
8930
+ return;
8931
+ }
8932
+ await handleMediaUpload(req, res);
8933
+ return;
8934
+ }
8935
+ if (path.startsWith("/api/media/")) {
8936
+ if (method !== "GET") {
8937
+ res.writeHead(405, { allow: "GET" });
8938
+ res.end("Method Not Allowed");
8939
+ return;
8940
+ }
8941
+ if (!requireBearer(req, token)) {
8942
+ sendText(res, 401, "Unauthorized");
8943
+ return;
8944
+ }
8945
+ let id;
8946
+ try {
8947
+ id = decodeURIComponent(path.slice("/api/media/".length));
8948
+ } catch {
8949
+ id = path.slice("/api/media/".length);
8950
+ }
8951
+ await handleMediaGet(res, id);
8952
+ return;
8953
+ }
9202
8954
  const allowed = ROUTE_METHODS[path];
9203
8955
  if (allowed === void 0) {
9204
8956
  sendText(res, 404, "Not Found");
@@ -21306,11 +21058,23 @@ function log2(message) {
21306
21058
  process.stderr.write(`readyroom: ${message}
21307
21059
  `);
21308
21060
  }
21061
+ async function writeAtomic(path, bytes) {
21062
+ const tmp = `${path}.tmp`;
21063
+ const handle = await open2(tmp, "w");
21064
+ try {
21065
+ await handle.writeFile(bytes);
21066
+ await handle.sync();
21067
+ } finally {
21068
+ await handle.close();
21069
+ }
21070
+ await rename2(tmp, path);
21071
+ }
21309
21072
  var FileTicketStore = class {
21310
21073
  stateDir;
21311
21074
  ticketsPath;
21312
21075
  ticketsTmpPath;
21313
21076
  auditPath;
21077
+ mediaDir;
21314
21078
  /** In-memory mirror of `tickets.json`, keyed by ticket id. */
21315
21079
  tickets = /* @__PURE__ */ new Map();
21316
21080
  /** Tail of the write chain. Every mutation appends to it; nothing runs concurrently. */
@@ -21324,6 +21088,7 @@ var FileTicketStore = class {
21324
21088
  this.ticketsPath = join2(stateDir, TICKETS_FILE);
21325
21089
  this.ticketsTmpPath = join2(stateDir, TICKETS_TMP_FILE);
21326
21090
  this.auditPath = join2(stateDir, AUDIT_FILE);
21091
+ this.mediaDir = join2(stateDir, MEDIA_DIR);
21327
21092
  }
21328
21093
  /**
21329
21094
  * Serialize a task behind every previously queued write. The returned promise
@@ -21417,6 +21182,46 @@ var FileTicketStore = class {
21417
21182
  }
21418
21183
  });
21419
21184
  }
21185
+ /**
21186
+ * Store a media blob as `<mediadir>/<id>` (raw bytes) with a sidecar
21187
+ * `<mediadir>/<id>.json` carrying the MIME type. The sidecar exists because a raw
21188
+ * file cannot say what it is, and `GET /api/media/{id}` needs the content type
21189
+ * without consulting a ticket.
21190
+ *
21191
+ * Writes go through the same tmp -> fsync -> rename path as `tickets.json`, so a
21192
+ * crash mid-write can never leave a truncated blob where a reader would find it.
21193
+ */
21194
+ async putMedia(blob) {
21195
+ if (!isSafeMediaId(blob.id)) {
21196
+ throw new Error(`refusing to store media under an unsafe id: ${blob.id}`);
21197
+ }
21198
+ return this.serialize(async () => {
21199
+ await mkdir2(this.mediaDir, { recursive: true });
21200
+ const bytes = base64ToBytes(blob.data_base64);
21201
+ await writeAtomic(join2(this.mediaDir, blob.id), bytes);
21202
+ const meta2 = JSON.stringify({ mime_type: blob.mime_type, size_bytes: bytes.length });
21203
+ await writeAtomic(join2(this.mediaDir, `${blob.id}.json`), new TextEncoder().encode(meta2));
21204
+ });
21205
+ }
21206
+ /** The blob, or `undefined` for an unknown or unsafe id. Never throws on a bad id. */
21207
+ async getMedia(id) {
21208
+ if (!isSafeMediaId(id)) {
21209
+ return void 0;
21210
+ }
21211
+ let bytes;
21212
+ let metaRaw;
21213
+ try {
21214
+ bytes = await readFile2(join2(this.mediaDir, id));
21215
+ metaRaw = await readFile2(join2(this.mediaDir, `${id}.json`), "utf8");
21216
+ } catch (error2) {
21217
+ if (error2.code === "ENOENT") {
21218
+ return void 0;
21219
+ }
21220
+ throw error2;
21221
+ }
21222
+ const meta2 = JSON.parse(metaRaw);
21223
+ return { id, mime_type: meta2.mime_type, data_base64: bytesToBase64(bytes) };
21224
+ }
21420
21225
  /**
21421
21226
  * Atomic write: tmp file -> fsync -> rename over the real file. The rename is the
21422
21227
  * atomic step; the fsync guarantees the bytes are on disk before the rename lands.