/* 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();