abullard1's picture
Deploy calibration @ d2b2018-space
8b45cc8 verified
Raw
History Blame Contribute Delete
42.5 kB
/* 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();