Spaces:
Running
Running
| /* LocalGate audit engine — shared by both audit Spaces. | |
| * | |
| * Design contract (deliverables/AUDIT_SPACES_BUILD.md, FINAL SPEC): | |
| * - Same-tab OAuth on the direct *.static.hf.space URL; embedded views link out. | |
| * - Token lives in sessionStorage only; the hub library persists nothing itself. | |
| * - Scopes are read-repos + contribute-repos: results go to a private dataset in | |
| * the ANNOTATOR'S OWN namespace ({user}/localgate-audit-results), created on | |
| * first login. Nobody can write anyone else's results at the permission layer. | |
| * - Append-only event log: every judgment/revision is a new row; analysis takes | |
| * the LAST row per key in file order (position is the authority — client | |
| * clocks are metadata, immune to skew). Every push is read-merge-write: the | |
| * remote file is pulled and unioned first, so a push can only grow the file. | |
| * - One tab at a time: a localStorage heartbeat blocks a second tab of the | |
| * same task in the same browser (two browsers merge safely, last-write races | |
| * are bounded by the merge-before-push). | |
| * - All content rendered via textContent. No innerHTML anywhere in this file. | |
| * - localStorage holds the event log per (task, user) — loss bound is zero; | |
| * uploads every SAVE_EVERY judgments / on 's' / on tab-hide / at the end. | |
| */ | |
| import { oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles, downloadFile, | |
| createRepo } from "./hub-2.15.0.bundle.mjs"; | |
| // The version query keeps the nested import cache-coherent with app.js itself: | |
| // static Spaces send no Cache-Control, and only the top-level script tags get | |
| // the ?v= stamp — an unversioned nested import could load a stale module. | |
| import { keyOf, latestByKey, mergeEvents, orderItems, firstUngraded as | |
| firstUngradedPure, countComplete, stampClass, nextEpoch, | |
| standsDownTo, statusOf, classifyProbe, hasScopes, displayId } | |
| from "./logic.mjs?v=d2b2018-space"; | |
| // Test seam: Playwright installs window.__testHub (an in-memory hub) before | |
| // any page script runs; production never defines it, so this is one inert | |
| // window read per call. The fake mirrors the real contracts exactly — | |
| // downloadFile: null on missing file, THROW on any other failure. | |
| const hub = () => window.__testHub ?? { | |
| oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles, downloadFile, | |
| createRepo, fetch: (...args) => fetch(...args), | |
| }; | |
| /* global CONFIG */ | |
| const ORG_ID = "6a7af86a89612db0d39b0b14"; // localgate — forces the org grant | |
| // write-repos: commit to the shared repo whichever app created it; | |
| // contribute-repos: create it on a brand-new annotator's first login. | |
| const REQUIRED_SCOPES = ["read-repos", "write-repos", "contribute-repos"]; | |
| const ITEMS_REPO = { type: "dataset", name: "localgate/audit-items" }; | |
| const SAVE_EVERY = 3; // phones kill background fetches; keep the window small | |
| const $ = (sel) => document.querySelector(sel); | |
| const el = (tag, cls, text) => { | |
| const node = document.createElement(tag); | |
| if (cls) node.className = cls; | |
| if (text !== undefined) node.textContent = text; | |
| return node; | |
| }; | |
| // ── rubric fingerprint (same discipline as convert.py's PROMPT_VERSION) ────── | |
| async function rubricVersion() { | |
| const data = new TextEncoder().encode(CONFIG.rubric + "\0" + JSON.stringify(CONFIG.fields)); | |
| const hash = await crypto.subtle.digest("SHA-256", data); | |
| return [...new Uint8Array(hash)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| } | |
| // ── auth ───────────────────────────────────────────────────────────────────── | |
| function storedAuth() { | |
| try { | |
| const raw = sessionStorage.getItem("oauth"); | |
| if (!raw) return null; | |
| const auth = JSON.parse(raw); | |
| if (new Date(auth.accessTokenExpiresAt) <= new Date()) return null; | |
| // A token minted before a scope change still "works" for reads but fails | |
| // at publish — treat it as absent so the user re-consents cleanly. | |
| if (!hasScopes(auth.scope, REQUIRED_SCOPES)) return null; | |
| return auth; | |
| } catch { return null; } | |
| } | |
| async function ensureAuth() { | |
| // A fresh authorization redirect ALWAYS outranks the cache: the user just | |
| // consented, possibly to new scopes — ignoring it kept stale tokens alive. | |
| if (new URLSearchParams(location.search).has("code")) { | |
| const fresh = await hub().oauthHandleRedirectIfPresent(); | |
| if (fresh) { | |
| sessionStorage.setItem("oauth", JSON.stringify(fresh)); | |
| history.replaceState(null, "", location.pathname); // ?code is single-use | |
| return fresh; | |
| } | |
| } | |
| return storedAuth(); | |
| } | |
| async function signIn() { | |
| sessionStorage.removeItem("oauth"); // never carry a stale grant | |
| const url = await hub().oauthLoginUrl(); // reads window.huggingface.variables in a Space | |
| // prompt=consent re-shows the consent screen (the HF client-side-oauth | |
| // idiom) so scope changes are actually granted, not silently skipped. | |
| window.location.href = url + "&orgIds=" + ORG_ID + "&prompt=consent"; | |
| } | |
| // ── state ──────────────────────────────────────────────────────────────────── | |
| const state = { | |
| auth: null, user: null, items: [], order: [], idx: 0, | |
| events: [], // append-only, mirrored to localStorage | |
| unsaved: 0, sessionId: crypto.randomUUID().slice(0, 8), | |
| shownAt: 0, rubricVersion: "", resultsRepo: null, last: null, | |
| breakShownAt: Date.now(), stickyBanner: null, stickyActions: [], stickyKind: null, | |
| gradedSinceBreak: 0, infoNavsLeft: 0, | |
| hbTimer: null, pushing: null, pushQueued: false, publishBlocked: null, | |
| onPushSuccess: null, lastVerdictKey: null, lastVerdictAt: 0, | |
| }; | |
| const logKey = () => `audit:${CONFIG.task}:${state.user}`; | |
| function loadLocalEvents() { | |
| try { return JSON.parse(localStorage.getItem(logKey()) ?? "[]"); } | |
| catch { return []; } | |
| } | |
| function persistLocal() { | |
| localStorage.setItem(logKey(), JSON.stringify(state.events)); | |
| } | |
| function buildOrder() { | |
| state.order = orderItems(state.items, `${CONFIG.task}:${state.user}`); | |
| } | |
| function firstUngraded() { | |
| return firstUngradedPure(state.order, state.items, state.events, | |
| (ev) => CONFIG.isComplete(ev)); | |
| } | |
| // ── results repo (own namespace) ───────────────────────────────────────────── | |
| // GET /api/datasets/{name} with the user's token: 200 = exists and visible. | |
| async function repoProbe() { | |
| try { | |
| const res = await hub().fetch( | |
| `https://huggingface.co/api/datasets/${state.resultsRepo.name}`, | |
| { headers: { Authorization: `Bearer ${state.auth.accessToken}` }, | |
| cache: "no-store" }); | |
| return classifyProbe(res.ok ? 200 : res.status); | |
| } catch { return "network"; } | |
| } | |
| // huggingface_hub's own exist_ok logic, ported: create, treat the | |
| // already-exists 409 as success, retry the concurrency 409, and on a | |
| // permission 401/403 probe whether the repo exists anyway (write-repos can | |
| // commit to it even when creation is not granted). Returns a status object — | |
| // boot decides policy; grading must never be blocked by this. | |
| async function ensureResultsRepo() { | |
| const name = `${state.user}/localgate-audit-results`; | |
| state.resultsRepo = { type: "dataset", name }; | |
| for (let attempt = 0; attempt < 3; attempt++) { | |
| try { | |
| await hub().createRepo({ repo: state.resultsRepo, | |
| accessToken: state.auth.accessToken, private: true }); | |
| return { ok: true, repo: "created" }; | |
| } catch (err) { | |
| const status = statusOf(err); | |
| if (status === 409 || /already/i.test(String(err))) { | |
| if (/conflicting operation/i.test(String(err))) continue; // create race | |
| return { ok: true, repo: "present" }; | |
| } | |
| if (status === 401 || status === 403) { | |
| const probe = await repoProbe(); | |
| if (probe === "present") return { ok: true, repo: "present" }; | |
| if (probe === "absent") return { ok: false, kind: "cannot-create" }; | |
| return { ok: false, kind: probe === "denied" ? "stale-token" : "network" }; | |
| } | |
| return { ok: false, kind: "network", detail: String(err).slice(0, 120) }; | |
| } | |
| } | |
| return { ok: false, kind: "network", detail: "create kept conflicting" }; | |
| } | |
| const remotePath = () => `${CONFIG.task}/${state.user}.jsonl`; | |
| // Read one of the user's own log files with honest outcomes: an absent repo | |
| // or file is "absent" (legitimately not started); permission problems are | |
| // "denied" (stale token — NOT the same as zero progress); anything else is | |
| // "error". Callers must never render "0 graded" for denied/error. | |
| async function readOwnLog(task) { | |
| try { | |
| const blob = await hub().downloadFile({ | |
| repo: state.resultsRepo, accessToken: state.auth.accessToken, | |
| path: `${task}/${state.user}.jsonl` }); | |
| if (blob === null) return { kind: "absent", rows: [] }; | |
| const rows = (await blob.text()).split("\n").filter(Boolean) | |
| .map((line) => JSON.parse(line)); | |
| return { kind: "rows", rows }; | |
| } catch (err) { | |
| const status = statusOf(err); | |
| if (status === 404) return { kind: "absent", rows: [] }; // repo not created yet | |
| if (status === 401 || status === 403) return { kind: "denied", rows: [] }; | |
| return { kind: "error", rows: [], detail: String(err).slice(0, 100) }; | |
| } | |
| } | |
| async function pullRemote() { | |
| const blob = await hub().downloadFile({ repo: state.resultsRepo, path: remotePath(), | |
| accessToken: state.auth.accessToken }); | |
| if (blob === null) return []; // no file yet — first session | |
| return (await blob.text()).split("\n").filter(Boolean).map((line) => JSON.parse(line)); | |
| } | |
| // Head commit at pull time — passed as parentCommit so the upload is | |
| // compare-and-swap: a concurrent device's commit makes ours 412 instead of | |
| // silently clobbering it (the one cross-device loss the merge can't prevent). | |
| async function headCommit() { | |
| const res = await hub().fetch( | |
| `https://huggingface.co/api/datasets/${state.resultsRepo.name}/revision/main`, | |
| { headers: { Authorization: `Bearer ${state.auth.accessToken}` }, | |
| cache: "no-store" }); | |
| if (!res.ok) throw new Error(`head lookup failed: HTTP ${res.status}`); | |
| return (await res.json()).sha; | |
| } | |
| async function pushOnce() { | |
| // Read-merge-write: never upload without having just read the remote head. | |
| // A pull failure aborts the push — an unreadable remote must not be replaced. | |
| for (let attempt = 0; ; attempt++) { | |
| const parent = await headCommit(); | |
| const remote = await pullRemote(); | |
| state.events = mergeEvents(remote, state.events); | |
| persistLocal(); | |
| const snapshot = state.events.length; | |
| const jsonl = state.events.map((ev) => JSON.stringify(ev)).join("\n") + "\n"; | |
| try { | |
| await hub().uploadFiles({ | |
| repo: state.resultsRepo, accessToken: state.auth.accessToken, | |
| commitTitle: `${CONFIG.task}: ${state.user} — ${snapshot} events`, | |
| files: [{ path: remotePath(), content: new Blob([jsonl]) }], | |
| parentCommit: parent, | |
| }); | |
| } catch (err) { | |
| if (attempt < 3 && (statusOf(err) === 412 | |
| || /412|precondition/i.test(String(err)))) continue; | |
| throw err; // someone kept committing, or a real failure | |
| } | |
| // Events recorded while the upload was in flight are still unsaved — | |
| // assigning 0 here would make the pagehide flush skip them. | |
| state.unsaved = state.events.length - snapshot; | |
| state.publishBlocked = null; // publishing works again | |
| clearErrorBanner(); // never eats an info banner | |
| renderStatus(); | |
| state.onPushSuccess?.(); | |
| return; | |
| } | |
| } | |
| // One push at a time: concurrent triggers (threshold, 's', tab-hide) queue a | |
| // follow-up cycle instead of racing two whole-file commits against each other. | |
| async function push() { | |
| if (!state.events.length) return; | |
| if (state.pushing) { state.pushQueued = true; return state.pushing; } | |
| state.pushing = (async () => { | |
| try { | |
| do { state.pushQueued = false; await pushOnce(); } while (state.pushQueued); | |
| } finally { state.pushing = null; } | |
| })(); | |
| return state.pushing; | |
| } | |
| function downloadLog() { | |
| // Escape hatch when publishing is stuck: a file in the user's hands cannot | |
| // be evicted by the browser. | |
| const jsonl = state.events.map((ev) => JSON.stringify(ev)).join("\n") + "\n"; | |
| const url = URL.createObjectURL(new Blob([jsonl], { type: "application/json" })); | |
| const link = el("a", null, ""); | |
| link.href = url; | |
| link.download = `${CONFIG.task}-${state.user}.jsonl`; | |
| link.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 30000); | |
| } | |
| async function pushGuarded() { | |
| try { await push(); } | |
| catch (err) { | |
| if (document.visibilityState === "hidden") return; // browser killed the | |
| // fetch on backgrounding — expected on phones; retried on return/boot | |
| const status = statusOf(err); | |
| if (!storedAuth()) { | |
| // Tokens expire (no refresh) and stale-scope tokens are rejected — a | |
| // retry can never succeed. Re-login; localStorage keeps everything. | |
| banner("Your login needs refreshing — all your work is saved in this " | |
| + "browser. Sign in again to publish it; you will continue " | |
| + "exactly where you left off.", true, | |
| [{ label: "Sign in again (refreshes permissions)", onClick: signIn }, | |
| { label: "Download your log", onClick: downloadLog }]); | |
| return; | |
| } | |
| if (status === 401 || status === 403 || /forbidden/i.test(String(err))) { | |
| state.publishBlocked = "permissions"; // stop burning auto-pushes | |
| banner("Your work is saved in this browser, but publishing was refused " | |
| + "(permissions). Sign in again to refresh your access — you will " | |
| + "continue exactly where you left off.", true, | |
| [{ label: "Sign in again (refreshes permissions)", onClick: signIn }, | |
| { label: "Download your log", onClick: downloadLog }]); | |
| return; | |
| } | |
| banner("Your work is saved in this browser, but publishing to the server failed " | |
| + `(${String(err).slice(0, 80)}). Use the save button to retry (s on a keyboard).`, | |
| true, [{ label: "Download your log (send it to Samuel if this persists)", | |
| onClick: downloadLog }]); | |
| } | |
| } | |
| // ── one-tab guard (heartbeat in localStorage; pagehide releases it) ────────── | |
| const HEARTBEAT_MS = 5000; | |
| const heartbeatKey = () => `audit:hb:${CONFIG.task}`; | |
| function foreignTabAlive() { | |
| try { | |
| const hb = JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null"); | |
| return !!hb && hb.session !== state.sessionId && Date.now() - hb.at < HEARTBEAT_MS * 3; | |
| } catch { return false; } | |
| } | |
| function readHeartbeat() { | |
| try { return JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null"); } | |
| catch { return null; } | |
| } | |
| // Ownership is an epoch: taking over bumps it, and a tab stands down only to a | |
| // fresh foreign heartbeat of an equal-or-newer epoch — so a seizure is | |
| // one-directional even while the old tab is still beating. (Predicates live | |
| // in logic.mjs and are unit-tested there.) | |
| function startHeartbeat(force = false) { | |
| state.hbEpoch = nextEpoch(readHeartbeat(), force); | |
| const beat = () => { | |
| const hb = readHeartbeat(); | |
| if (standsDownTo(hb, state.sessionId, state.hbEpoch, Date.now(), | |
| HEARTBEAT_MS * 3)) { supersede(); return; } | |
| localStorage.setItem(heartbeatKey(), | |
| JSON.stringify({ session: state.sessionId, at: Date.now(), epoch: state.hbEpoch })); | |
| }; | |
| beat(); | |
| clearInterval(state.hbTimer); | |
| state.hbTimer = setInterval(beat, HEARTBEAT_MS); | |
| } | |
| function supersede() { | |
| clearInterval(state.hbTimer); | |
| commitCurrent(); | |
| if (state.unsaved) pushGuarded(); // merge-safe: the other tab re-pulls | |
| $("#controls").hidden = true; | |
| $("#main").replaceChildren(el("p", null, | |
| "Grading moved to another tab. This tab is paused — you can close it; everything here was saved.")); | |
| } | |
| function releaseHeartbeat() { | |
| try { | |
| const hb = JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null"); | |
| if (hb?.session === state.sessionId) localStorage.removeItem(heartbeatKey()); | |
| } catch { /* releasing is best-effort */ } | |
| } | |
| // ── recording ──────────────────────────────────────────────────────────────── | |
| function record(item, values, unsure, note) { | |
| const key = keyOf(item); | |
| const prior = [...state.events].reverse().find((ev) => ev.key === key); | |
| state.events.push({ | |
| key, ...values, | |
| unsure: !!unsure, note: note || "", | |
| seq: state.events.length, // position cross-check for the loader | |
| user: state.user, ts: new Date().toISOString(), | |
| order_index: state.idx, elapsed_ms: Date.now() - state.shownAt, | |
| edit_count: prior ? (prior.edit_count ?? 0) + 1 : 0, | |
| session_id: state.sessionId, rubric_version: state.rubricVersion, | |
| client: `${CONFIG.task}@${CONFIG.build}`, | |
| ...(item.warmup ? { warmup: true } : {}), | |
| }); | |
| persistLocal(); | |
| state.last = { key, text: CONFIG.summarize(values), unsure: !!unsure, | |
| cls: stampClass(values) }; | |
| state.unsaved += 1; | |
| if (!item.warmup) state.gradedSinceBreak += 1; | |
| if (state.unsaved >= SAVE_EVERY && !state.publishBlocked) pushGuarded(); | |
| } | |
| // ── UI ─────────────────────────────────────────────────────────────────────── | |
| // Sticky banners (save failures) survive item renders until a push succeeds; | |
| // transient ones (break reminder) clear on the next item. | |
| // kind "err" (save failures — outlives everything until a push succeeds) or | |
| // "info" (break reminders, resume notice — cleared after a couple more grades | |
| // so it survives item transitions without nagging forever). | |
| function banner(text, sticky = false, actions = [], kind = "err") { | |
| if (sticky) { | |
| state.stickyBanner = text || null; | |
| state.stickyActions = text ? actions : []; | |
| state.stickyKind = text ? kind : null; | |
| } | |
| const box = $("#banner"); | |
| const shown = text || state.stickyBanner || ""; | |
| const shownActions = text ? actions : state.stickyActions; | |
| box.replaceChildren(); | |
| if (shown) box.append(document.createTextNode(shown + " ")); | |
| for (const action of shownActions) { | |
| const button = el("button", "banner-act", action.label); | |
| button.addEventListener("click", action.onClick); | |
| box.append(button); | |
| } | |
| box.classList.toggle("err", (text ? kind : state.stickyKind) === "err"); | |
| box.hidden = !shown; | |
| } | |
| // A successful push clears failure notices but must not eat an info banner. | |
| function clearErrorBanner() { | |
| if (state.stickyKind !== "info") { state.stickyBanner = null; state.stickyActions = []; } | |
| banner(""); | |
| } | |
| function renderStatus() { | |
| const done = [...latestByKey(state.events).values()] | |
| .filter((ev) => !ev.warmup && CONFIG.isComplete(ev)).length; | |
| const total = state.items.filter((item) => !item.warmup).length; | |
| $("#progress").textContent = | |
| `${done}/${total} graded · ${state.unsaved} unsaved` + | |
| (state.items[state.order[state.idx]]?.warmup ? " · WARM-UP" : ""); | |
| $("#bar-fill").style.width = total ? `${(100 * done) / total}%` : "0%"; | |
| } | |
| function contentBlock(label, text, opts = {}) { | |
| const wrap = el("section", "field" + (opts.scroll ? " response" : "") | |
| + (opts.ref ? " reference" : "")); | |
| const head = el("b", null, label + (opts.count ? ` · ${text.length} chars` : "")); | |
| wrap.append(head); | |
| const body = el("div", "content"); | |
| body.textContent = text; // textContent ONLY — never innerHTML | |
| wrap.append(body); | |
| if (opts.scroll) wrap.append(el("div", "endmark", "· · · end of response · · ·")); | |
| return wrap; | |
| } | |
| function renderItem() { | |
| const main = $("#main"); | |
| main.replaceChildren(); | |
| if (state.stickyKind === "info") { | |
| state.infoNavsLeft = (state.infoNavsLeft ?? 1) - 1; | |
| if (state.infoNavsLeft <= 0) { | |
| state.stickyBanner = null; state.stickyActions = []; state.stickyKind = null; | |
| } | |
| } | |
| banner(""); | |
| if (state.idx >= state.order.length) { | |
| const pos = firstUngraded(); | |
| if (pos < state.order.length) { | |
| // Not actually done — walking past the last item must never claim | |
| // completion. Jump back to the first ungraded item instead. | |
| state.idx = pos; | |
| renderItem(); | |
| banner(`Not finished yet — jumped back to the next ungraded item.`, | |
| false, [], "info"); | |
| return; | |
| } | |
| main.append(el("h2", null, "All items graded — thank you!")); | |
| const status = el("p", null, "Publishing your final save…"); | |
| status.setAttribute("role", "status"); | |
| main.append(status); | |
| let nextShown = false, retryBtn = null; | |
| const refresh = () => { | |
| if (state.unsaved === 0) { | |
| status.textContent = "Everything is published. You can close this tab."; | |
| if (retryBtn) { retryBtn.remove(); retryBtn = null; } | |
| if (CONFIG.next && !nextShown) { | |
| nextShown = true; | |
| const link = el("a", "open-out", CONFIG.next.label); | |
| link.href = CONFIG.next.url; link.rel = "noopener"; | |
| main.append(link); | |
| } | |
| } else { | |
| status.textContent = "Some judgments are still only in this browser — " | |
| + "see the notice above; do not clear this browser's storage."; | |
| if (!retryBtn) { | |
| retryBtn = el("button", "signin", "Retry publishing"); | |
| retryBtn.addEventListener("click", () => pushGuarded()); | |
| main.append(retryBtn); | |
| } | |
| } | |
| renderStatus(); | |
| }; | |
| $("#controls").hidden = true; | |
| // Any later successful push (retry button, tab-return heal) re-renders | |
| // the truth — the screen must never stay on a stale failure claim. | |
| state.onPushSuccess = refresh; | |
| if (state.unsaved) pushGuarded().then(refresh); | |
| else refresh(); | |
| return; | |
| } | |
| state.onPushSuccess = null; | |
| const item = state.items[state.order[state.idx]]; | |
| window.scrollTo(0, 0); // a new item always starts at its top | |
| const shownId = item.warmup && !CONFIG.opaqueIds | |
| ? "warm-up" // raw corpus qids never reach the screen | |
| : displayId(keyOf(item), state.idx, CONFIG.opaqueIds); | |
| main.append(el("div", "item-id", | |
| `${shownId} · ${state.idx + 1}/${state.order.length}`)); | |
| if (item.warmup) { | |
| main.append(el("p", "warmup-note", | |
| "Warm-up item — discussable with the others; real items start after these and must be graded independently.")); | |
| } | |
| for (const block of CONFIG.blocks(item)) { | |
| main.append(contentBlock(block.label, block.text, block)); | |
| } | |
| // Math: items carry \( \) / \[ \] delimiters (normalized at build time); | |
| // KaTeX walks the text nodes of our textContent-built DOM. trust:false and | |
| // throwOnError:false — a malformed expression shows as source, never breaks. | |
| if (window.renderMathInElement) { | |
| window.renderMathInElement(main, { | |
| throwOnError: false, trust: false, | |
| delimiters: [{ left: "\\(", right: "\\)", display: false }, | |
| { left: "\\[", right: "\\]", display: true }, | |
| { left: "$$", right: "$$", display: true }], | |
| }); | |
| } | |
| state.shownAt = Date.now(); | |
| renderControls(item); | |
| renderStatus(); | |
| // Break cadence: ~40 items or 45 minutes, whichever first. Sticky info — | |
| // survives item changes and autosaves, expires two grades later. | |
| if (state.gradedSinceBreak >= 40 || Date.now() - state.breakShownAt > 45 * 60 * 1000) { | |
| state.infoNavsLeft = 3; // this item + two more | |
| banner("Good stopping point — consider ending this sitting here. Everything " | |
| + "graded so far is saved; you will resume exactly where you left off.", | |
| true, [], "info"); | |
| state.breakShownAt = Date.now(); | |
| state.gradedSinceBreak = 0; | |
| } | |
| } | |
| function renderControls(item) { | |
| const controls = $("#controls"); | |
| controls.hidden = false; | |
| controls.replaceChildren(); | |
| const current = latestByKey(state.events).get(keyOf(item)); | |
| const selection = { ...(current ? CONFIG.valuesOf(current) : {}) }; | |
| let unsure = current?.unsure ?? false; | |
| const groups = []; | |
| for (const field of CONFIG.fields) { | |
| const group = el("div", "grp"); | |
| group.append(el("span", "lbl", field.label)); | |
| for (const [value, hotkey] of field.options) { | |
| const button = el("button", null, value.replaceAll("_", " ")); | |
| button.dataset.v = value; // semantic verdict color hook | |
| button.append(el("kbd", null, hotkey)); | |
| if (selection[field.name] === value) button.classList.add("sel"); | |
| button.addEventListener("click", () => choose(field.name, value)); | |
| group.append(button); | |
| } | |
| controls.append(group); | |
| groups.push(group); | |
| } | |
| const unsureBtn = el("button", "unsure" + (unsure ? " sel" : ""), "unsure"); | |
| unsureBtn.append(el("kbd", null, "u")); | |
| unsureBtn.title = "marks this judgment as uncertain — it still counts, the flag is analysis metadata"; | |
| unsureBtn.addEventListener("click", () => { unsure = !unsure; unsureBtn.classList.toggle("sel", unsure); }); | |
| controls.append(unsureBtn); | |
| const note = el("input", null); | |
| note.id = "note"; note.placeholder = CONFIG.notePlaceholder; | |
| note.value = current?.note ?? ""; | |
| note.addEventListener("keydown", (ev) => { | |
| // Enter/Escape hand the keyboard back to grading; hotkeys otherwise type | |
| // here by design (the global handler ignores INPUT targets). | |
| if (ev.key === "Enter" || ev.key === "Escape") { ev.preventDefault(); note.blur(); } | |
| }); | |
| controls.append(note); | |
| const nav = el("div", "nav"); | |
| const prev = el("button", null, "← prev"); prev.append(el("kbd", null, "j")); | |
| prev.addEventListener("click", () => move(-1)); | |
| const skip = el("button", null, "next →"); skip.append(el("kbd", null, "k")); | |
| skip.addEventListener("click", () => move(1)); | |
| const save = el("button", null, "save"); save.append(el("kbd", null, "s")); | |
| save.addEventListener("click", () => { commitCurrent(); pushGuarded(); }); | |
| nav.append(prev, skip, save); | |
| controls.append(nav); | |
| if (state.last) { | |
| // The previous verdict is deliberately NOT shown (sequential anchoring); | |
| // the colored dot confirms it recorded, hover reveals it if needed. | |
| const last = el("div", "last"); | |
| last.append(el("span", null, `last · ${state.last.key}`)); | |
| const stamp = el("span", "stamp " + state.last.cls, "recorded ▪"); | |
| stamp.title = state.last.text + (state.last.unsure ? " · unsure" : ""); | |
| last.append(stamp); | |
| last.append(el("span", null, "j to revisit")); | |
| controls.append(last); | |
| } | |
| function choose(name, value) { | |
| const wasComplete = CONFIG.fields.every((field) => selection[field.name]); | |
| selection[name] = value; | |
| CONFIG.fields.forEach((field, fi) => { | |
| groups[fi].querySelectorAll("button").forEach((button) => { | |
| button.classList.toggle("sel", selection[field.name] === button.dataset.v); | |
| }); | |
| }); | |
| // Advance only when this choice COMPLETES the item. A revisited item is | |
| // already complete, so changing one answer must never jump away — the | |
| // change commits on j/k/s/tab-hide via _commitIfDirty. | |
| if (!wasComplete && CONFIG.fields.every((field) => selection[field.name])) move(1); | |
| } | |
| // The one recording path: leaving an item (nav, save, tab-hide) commits a | |
| // complete selection whose verdicts, unsure flag, or note differ from the | |
| // last recorded event — so a note typed or unsure toggled after the final | |
| // verdict click is never lost. | |
| controls._commitIfDirty = () => { | |
| if (!CONFIG.fields.every((field) => selection[field.name])) return; | |
| const now = latestByKey(state.events).get(keyOf(item)); | |
| const values = Object.fromEntries(CONFIG.fields.map((f) => [f.name, selection[f.name]])); | |
| const dirty = !now | |
| || CONFIG.fields.some((field) => now[field.name] !== selection[field.name]) | |
| || (now.unsure ?? false) !== unsure | |
| || (now.note ?? "") !== (note.value || ""); | |
| if (dirty) record(item, values, unsure, note.value); | |
| }; | |
| // the mobile rubric pins itself just above the bench's real height | |
| document.documentElement.style.setProperty("--bench-h", `${controls.offsetHeight}px`); | |
| controls._noteDiscarded = () => | |
| !CONFIG.fields.every((field) => selection[field.name]) && !!note.value; | |
| controls._choose = choose; // for the keyboard handler | |
| controls._toggleUnsure = () => unsureBtn.click(); | |
| } | |
| function commitCurrent() { $("#controls")._commitIfDirty?.(); } | |
| function move(delta) { | |
| const discarded = $("#controls")._noteDiscarded?.(); | |
| commitCurrent(); | |
| state.idx = Math.max(0, Math.min(state.order.length, state.idx + delta)); | |
| renderItem(); | |
| if (discarded) { | |
| banner("Heads up: the note on the item you just left was not recorded — " | |
| + "notes only save once both of its questions are answered.", | |
| false, [], "info"); | |
| } | |
| } | |
| document.addEventListener("keydown", (event) => { | |
| if (event.target.tagName === "INPUT" || $("#controls").hidden) return; | |
| const controls = $("#controls"); | |
| if (event.key === "j" || event.key === "ArrowLeft") { event.preventDefault(); move(-1); return; } | |
| if (event.key === "k" || event.key === "ArrowRight") { event.preventDefault(); move(1); return; } | |
| if (event.key === "s") { event.preventDefault(); commitCurrent(); pushGuarded(); return; } | |
| if (event.key === "u") { event.preventDefault(); controls._toggleUnsure?.(); return; } | |
| if (event.key === "e") { | |
| event.preventDefault(); | |
| const resp = document.querySelector(".response .content"); | |
| if (resp) resp.parentElement.scrollTop = resp.parentElement.scrollHeight; | |
| return; | |
| } | |
| const binding = CONFIG.hotkeys[event.key]; | |
| if (binding) { | |
| event.preventDefault(); | |
| // Double-press bounce: the SAME key repeating into the next item's first | |
| // 150ms is the keystroke that just advanced the previous item. A | |
| // different key (or a deliberate press later) always lands. | |
| const now = Date.now(); | |
| if (event.key === state.lastVerdictKey && now - state.shownAt < 150 | |
| && now - (state.lastVerdictAt ?? 0) < 300) return; | |
| state.lastVerdictKey = event.key; | |
| state.lastVerdictAt = now; | |
| controls._choose?.(binding[0], binding[1]); | |
| } | |
| }); | |
| document.addEventListener("visibilitychange", () => { | |
| if (document.visibilityState === "visible") { | |
| if (state.unsaved) pushGuarded(); // heal a killed background push | |
| return; | |
| } | |
| commitCurrent(); // capture a trailing note/unsure edit | |
| if (state.unsaved) pushGuarded(); // best-effort | |
| }); | |
| window.addEventListener("pagehide", () => { | |
| commitCurrent(); | |
| if (state.unsaved) pushGuarded(); // best-effort; localStorage is the backstop | |
| releaseHeartbeat(); // so a reload doesn't trip the one-tab guard | |
| }); | |
| // ── boot ───────────────────────────────────────────────────────────────────── | |
| const withTimeout = (promise, seconds, label) => Promise.race([ | |
| promise, | |
| new Promise((_, reject) => setTimeout( | |
| () => reject(new Error(`${label} timed out after ${seconds}s`)), seconds * 1000)), | |
| ]); | |
| async function boot() { | |
| // Watchdog: if nothing has replaced the shell after 30s, the network | |
| // stalled mid-boot (common on phones) — offer a reload instead of an | |
| // eternal "Loading...". | |
| setTimeout(() => { | |
| const main = $("#main"); | |
| if (main && main.textContent.includes("Loading")) { | |
| main.replaceChildren(el("p", null, | |
| "Loading stalled — the connection may have dropped mid-request. " | |
| + "Nothing is lost.")); | |
| const retry = el("button", "signin", "Reload"); | |
| retry.addEventListener("click", () => location.reload()); | |
| main.append(retry); | |
| } | |
| }, 30000); | |
| $("#task-title").textContent = CONFIG.title; | |
| document.title = CONFIG.title; // three distinguishable tabs | |
| $("#banner").setAttribute("aria-live", "assertive"); | |
| $("#progress").setAttribute("aria-live", "polite"); | |
| state.rubricVersion = await rubricVersion(); | |
| $("#rubric-body").textContent = CONFIG.rubric; | |
| // Wide screens hold the rubric in a side rail — open it so it reads at a glance. | |
| if (matchMedia("(min-width: 1240px)").matches) $("#rubric").open = true; | |
| if (window.self !== window.top) { | |
| // Embedded in hf.co: OAuth storage is partitioned here — link out instead. | |
| const main = $("#main"); | |
| main.replaceChildren(el("p", null, "Open this Space in its own tab to sign in:")); | |
| const link = el("a", "open-out", location.href); | |
| link.href = location.href; link.target = "_blank"; link.rel = "noopener"; | |
| main.append(link); | |
| return; | |
| } | |
| state.auth = await ensureAuth(); | |
| if (!state.auth) { | |
| const main = $("#main"); | |
| main.replaceChildren(el("p", null, CONFIG.landing)); | |
| // A returning annotator (new tab, expired login, reclaimed mobile tab) | |
| // must never mistake this screen for a fresh start: their log is here. | |
| let stored = 0; | |
| for (let i = 0; i < localStorage.length; i++) { | |
| const key = localStorage.key(i); | |
| if (!key || !key.startsWith(`audit:${CONFIG.task}:`)) continue; | |
| try { | |
| const events = JSON.parse(localStorage.getItem(key) ?? "[]"); | |
| stored += [...latestByKey(events).values()] | |
| .filter((ev) => !ev.warmup && CONFIG.isComplete(ev)).length; | |
| } catch { /* unreadable log — claim nothing */ } | |
| } | |
| if (stored) { | |
| main.append(el("p", "resume-note", | |
| `Welcome back — ${stored} of your judgments are safely stored in this ` | |
| + "browser. Sign in with the same Hugging Face account to pick up " | |
| + "exactly where you left off.")); | |
| } | |
| main.append(el("p", "independence", CONFIG.independence)); | |
| const button = el("button", "signin", "Sign in with Hugging Face"); | |
| button.addEventListener("click", signIn); | |
| main.append(button); | |
| return; | |
| } | |
| const username = state.auth.userInfo.preferred_username; | |
| const inOrg = (state.auth.userInfo.orgs ?? []).some((org) => org.preferred_username === "localgate"); | |
| if (!inOrg) { | |
| $("#main").replaceChildren(el("p", null, | |
| `@${username} is not on this study's annotator list — ask Samuel to add you to the localgate org.`)); | |
| return; | |
| } | |
| state.user = username; | |
| $("#whoami").textContent = `@${username}`; | |
| if (!state.hbTimer && foreignTabAlive()) { | |
| const main = $("#main"); | |
| main.replaceChildren(el("p", null, | |
| "This audit is already open in another tab of this browser. Two open copies " + | |
| "can overwrite each other's work, so grading is paused here.")); | |
| const takeOver = el("button", "signin", "Continue in this tab instead"); | |
| takeOver.addEventListener("click", () => { startHeartbeat(true); boot(); }); | |
| main.append(takeOver); | |
| return; | |
| } | |
| if (!state.hbTimer) startHeartbeat(); | |
| try { | |
| const blob = await withTimeout( | |
| hub().downloadFile({ repo: ITEMS_REPO, path: CONFIG.itemsPath, | |
| accessToken: state.auth.accessToken }), | |
| 60, "loading the items"); | |
| if (blob === null) throw new Error("items file missing from localgate/audit-items"); | |
| state.items = JSON.parse(await blob.text()); | |
| } catch (err) { | |
| $("#main").replaceChildren(el("p", null, `Could not load items: ${String(err).slice(0, 140)}`)); | |
| const retry = el("button", null, "retry"); | |
| retry.addEventListener("click", boot); | |
| $("#main").append(retry); | |
| return; | |
| } | |
| let prep; | |
| try { | |
| prep = await withTimeout(ensureResultsRepo(), 60, "preparing your results dataset"); | |
| } catch { prep = { ok: false, kind: "network" }; } | |
| if (!prep.ok) { | |
| // NEVER block grading on this: every judgment lands in localStorage and | |
| // the backlog auto-publishes once publishing works. Only the "published" | |
| // claim is blocked (completion screen + counters stay honest). | |
| state.publishBlocked = prep.kind; | |
| const text = prep.kind === "stale-token" | |
| ? "Your access needs refreshing before results can publish. You can " | |
| + "grade now — everything is kept in this browser — but do sign in " | |
| + "again soon so it uploads." | |
| : prep.kind === "cannot-create" | |
| ? "Your results dataset does not exist yet and this login cannot " | |
| + "create it. You can grade now — everything is kept in this " | |
| + "browser — then sign in again to set it up." | |
| : "Could not reach your results dataset (network). You can grade — " | |
| + "everything is kept in this browser and publishing retries."; | |
| banner(text, true, | |
| [{ label: "Sign in again (refreshes permissions)", onClick: signIn }, | |
| { label: "Download your log", onClick: downloadLog }]); | |
| } | |
| // Ordering gate: a task can require another task's completion first (the | |
| // conversion fidelity pass must not open before the blind verdict pass, | |
| // because it reveals which items the filter kept). | |
| if (CONFIG.requires) { | |
| const log = await readOwnLog(CONFIG.requires.task); | |
| if (log.kind === "denied" || log.kind === "error") { | |
| // Saying "you have graded 0 items" here would be FALSE — the read | |
| // failed; their work may be complete. Fail closed with the truth. | |
| $("#main").replaceChildren(el("p", null, | |
| `Could not read your ${CONFIG.requires.label} progress` | |
| + (log.kind === "denied" ? " (permissions — your login may need refreshing)." | |
| : " (network)." ) | |
| + " This pass stays locked until it can be verified.")); | |
| const again = el("button", "signin", log.kind === "denied" | |
| ? "Sign in again (refreshes permissions)" : "Retry"); | |
| again.addEventListener("click", | |
| log.kind === "denied" ? signIn : () => location.reload()); | |
| $("#main").append(again); | |
| $("#controls").hidden = true; | |
| return; | |
| } | |
| const done = countComplete(log.rows, CONFIG.requires.fields); | |
| if (done < CONFIG.requires.count) { | |
| const ownWork = loadLocalEvents().filter((ev) => !ev.warmup).length; | |
| $("#main").replaceChildren(el("p", null, | |
| `${CONFIG.requires.label} must be finished first — you have graded ` + | |
| `${done} of ${CONFIG.requires.count} items there. This pass reveals ` + | |
| "information that must not color that one, so it stays locked until " + | |
| "you are done. (Just finished it? Make sure its tab says everything " | |
| + "is published, then reload this page.)" | |
| + (typeof ownWork !== "undefined" && ownWork | |
| ? ` Your ${ownWork} judgments already recorded here are safe and ` | |
| + "will be waiting when this pass reopens." | |
| : ""))); | |
| const link = el("a", "open-out", | |
| CONFIG.requires.linkLabel ?? CONFIG.requires.url); | |
| link.href = CONFIG.requires.url; link.rel = "noopener"; | |
| $("#main").append(link); | |
| return; | |
| } | |
| } | |
| // Reciprocal lock: once the FOLLOWING pass has begun, this one's blindness | |
| // window has ended — it closes for revision. Fail CLOSED on a read error: | |
| // wrongly locking is a reload; wrongly unlocking is an invisible validity | |
| // hole (post-unblinding edits are only detectable, not preventable). | |
| if (CONFIG.lockWhen) { | |
| const log = await readOwnLog(CONFIG.lockWhen.task); | |
| const started = log.kind === "rows" && log.rows.some((ev) => !ev.warmup); | |
| const unreadable = log.kind === "denied" || log.kind === "error"; | |
| if (started || unreadable) { | |
| $("#main").replaceChildren(el("p", null, unreadable | |
| ? "Could not verify whether the next pass has already started, so this " | |
| + "one stays closed to be safe." | |
| + (log.kind === "denied" ? " Your login may need refreshing." : "") | |
| : `${CONFIG.lockWhen.label} has begun, so this pass is closed for ` | |
| + "revision — its blindness window has ended. Every verdict you " | |
| + "recorded here is safe and counted. If a correction is genuinely " | |
| + "needed, tell Samuel; corrections after unblinding are flagged in " | |
| + "the analysis rather than silently applied.")); | |
| if (unreadable) { | |
| const again = el("button", "signin", log.kind === "denied" | |
| ? "Sign in again (refreshes permissions)" : "Retry"); | |
| again.addEventListener("click", | |
| log.kind === "denied" ? signIn : () => location.reload()); | |
| $("#main").append(again); | |
| } | |
| $("#controls").hidden = true; | |
| return; | |
| } | |
| } | |
| // Merge remote history with the local log, remote order first (mergeEvents). | |
| // A pull FAILURE is not "no file yet": grading continues from the local log, | |
| // and because every push re-pulls first, nothing can be overwritten blind. | |
| let remote = []; | |
| try { remote = await pullRemote(); } | |
| catch (err) { | |
| banner(`Could not read your previous progress (${String(err).slice(0, 80)}) — ` + | |
| "grading continues and is kept in this browser; publishing retries on the next save.", true); | |
| } | |
| state.events = mergeEvents(remote, loadLocalEvents()); | |
| persistLocal(); | |
| state.unsaved = state.events.length - remote.length; | |
| if (state.unsaved > 0) pushGuarded(); // publish the backlog now, | |
| // while the page is visible | |
| buildOrder(); | |
| state.idx = firstUngraded(); | |
| renderItem(); | |
| const done = [...latestByKey(state.events).values()] | |
| .filter((ev) => !ev.warmup && CONFIG.isComplete(ev)).length; | |
| if (done > 0 && state.idx < state.order.length) { | |
| // Transient on purpose: mobile browsers reload the tab on every app | |
| // switch, so a sticky notice re-arms forever and reads as stuck. | |
| banner(`Welcome back — ${done} already recorded. ` | |
| + "Continuing exactly where you left off.", false, [], "info"); | |
| } | |
| } | |
| boot(); | |