abullard1's picture
Deploy conversion @ 6703023-space
1c35483 verified
Raw History Blame Contribute Delete
48.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, isForeignFresh, statusOf, classifyProbe, hasScopes, displayId,
RESET_KEY, effectiveEvents }
from "./logic.mjs?v=6703023-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;
};
// Completeness and verdict-shape are pure functions of CONFIG.fields, derived
// here so the three config files never hand-maintain (and drift on) parallel
// copies. hotkeyMap inverts fields[].options ([value, key]) into key → [field, value].
const isComplete = (ev) => CONFIG.fields.every((f) => ev[f.name]);
const valuesOf = (ev) => Object.fromEntries(CONFIG.fields.map((f) => [f.name, ev[f.name]]));
const hotkeyMap = Object.fromEntries(
CONFIG.fields.flatMap((f) => f.options.map(([value, key]) => [key, [f.name, value]])));
// All reads go through the post-reset view: a redo voids earlier judgments for
// the UI (they stay in the log; the loader applies the same cut).
const completeCount = (events) =>
[...latestByKey(effectiveEvents(events)).values()]
.filter((ev) => !ev.warmup && isComplete(ev)).length;
const latestEffective = (key) => latestByKey(effectiveEvents(state.events)).get(key);
const unsureEntries = () =>
[...latestByKey(effectiveEvents(state.events)).values()]
.filter((ev) => !ev.warmup && ev.unsure && isComplete(ev));
// jsonl is the wire format for every log file — one definition each way.
const toJsonl = (events) => events.map((ev) => JSON.stringify(ev)).join("\n") + "\n";
const parseJsonl = (text) => text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
// The two recovery affordances offered whenever publishing needs a fresh login.
const recoveryActions = () => [
{ label: "Sign in again (refreshes permissions)", onClick: signIn },
{ label: "Download your log", onClick: downloadLog },
];
const recoveryButton = (denied) => {
const again = el("button", "signin",
denied ? "Sign in again (refreshes permissions)" : "Retry");
again.addEventListener("click", denied ? signIn : () => location.reload());
return again;
};
// ── 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, lastPushAt: 0, modalOpen: false,
};
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, isComplete);
}
// ── 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) {
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: [] };
return { kind: "rows", rows: parseJsonl(await blob.text()) };
} 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 parseJsonl(await blob.text());
}
// 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 = toJsonl(state.events);
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
state.lastPushAt = Date.now(); // the menu's trust line
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 = toJsonl(state.events);
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, recoveryActions());
return;
}
if (status === 401 || status === 403) {
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, recoveryActions());
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() {
return isForeignFresh(readHeartbeat(), state.sessionId, Date.now(), HEARTBEAT_MS * 3);
}
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 = [...effectiveEvents(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 = completeCount(state.events);
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 -= 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 = latestEffective(keyOf(item));
const selection = { ...(current ? 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 cue = el("span", "cue", "✓ answered — add a note if needed, then next");
cue.hidden = true;
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(cue, prev, skip, save);
controls.append(nav);
const updateCue = () => {
const complete = CONFIG.fields.every((field) => selection[field.name]);
cue.hidden = !complete;
skip.classList.toggle("ready", complete);
};
updateCue();
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) {
selection[name] = value;
CONFIG.fields.forEach((field, fi) => {
groups[fi].querySelectorAll("button").forEach((button) => {
button.classList.toggle("sel", selection[field.name] === button.dataset.v);
});
});
// No auto-advance: the natural flow is answer, then type the why — jumping
// on the completing click forced a back-navigation for every note. The cue
// and the lit next button say the item is ready; j/k/s/tab-hide commit it.
updateCue();
}
// 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 = latestEffective(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 (state.modalOpen || 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 = hotkeyMap[event.key];
if (binding) {
event.preventDefault();
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
});
// ── menu (☰): sync status, navigation aids, and the double-confirmed redo ────
// Installed only once grading is live (never on locked/gated screens, so a
// redo can never breach a blindness window the boot gates enforce).
function installMenu() {
if ($("#menu-btn")) return;
const btn = el("button", null, "☰");
btn.id = "menu-btn";
btn.setAttribute("aria-haspopup", "true");
btn.setAttribute("aria-expanded", "false");
btn.setAttribute("aria-label", "menu");
const panel = el("div"); panel.id = "menu"; panel.hidden = true;
const close = () => { panel.hidden = true; btn.setAttribute("aria-expanded", "false"); };
btn.addEventListener("click", () => {
if (panel.hidden) { renderMenu(panel, close); panel.hidden = false;
btn.setAttribute("aria-expanded", "true"); }
else close();
});
document.addEventListener("click", (ev) => {
// composedPath, not contains: a menu click may REBUILD the panel (the
// unsure list) before this bubble handler runs — the detached target
// would read as "outside" and instantly close the menu it just opened.
if (!panel.hidden && !ev.composedPath().includes(panel)
&& ev.target !== btn) close();
});
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && !panel.hidden) close();
});
document.querySelector("header").append(btn);
document.body.append(panel);
}
function menuItem(label, onClick) {
const button = el("button", "menu-item", label);
button.addEventListener("click", onClick);
return button;
}
// Rebuilt on every open so the status lines are always current.
function renderMenu(panel, close) {
panel.replaceChildren();
const done = completeCount(state.events);
const total = state.items.filter((item) => !item.warmup).length;
const published = state.lastPushAt
? new Date(state.lastPushAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: "not yet this session";
const status = el("div", "menu-status");
status.append(el("div", null, `${done}/${total} graded · ${state.unsaved} unsaved`));
status.append(el("div", null, `last published: ${published}`));
panel.append(status);
const jumpTo = (pos) => { commitCurrent(); state.idx = pos; renderItem(); close(); };
panel.append(menuItem("Jump to first ungraded", () => {
commitCurrent(); // committing may complete the current item
jumpTo(firstUngraded());
}));
const unsure = unsureEntries();
panel.append(menuItem(`Review unsure items (${unsure.length})`, () => {
const posByKey = new Map(state.order.map(
(itemIdx, pos) => [keyOf(state.items[itemIdx]), pos]));
panel.replaceChildren(el("div", "menu-status", "Marked unsure — tap to revisit:"));
for (const ev of unsure) {
const pos = posByKey.get(ev.key);
if (pos === undefined) continue;
panel.append(menuItem(displayId(ev.key, pos, CONFIG.opaqueIds), () => jumpTo(pos)));
}
if (!unsure.length) panel.append(el("div", "menu-status", "none yet"));
}));
panel.append(menuItem("Download my log", () => { downloadLog(); close(); }));
const danger = el("div", "menu-danger");
danger.append(menuItem("Redo this task…", () => { close(); openRedoModal(); }));
panel.append(danger);
}
// The redo is a supersede, never a delete: a reset marker is APPENDED and every
// read path ignores what precedes it. Old judgments stay in the log and the
// loader reports the redo — analysis integrity requires it visible, not erased.
function openRedoModal() {
const overlay = el("div", "modal-overlay");
const box = el("div", "modal");
box.setAttribute("role", "dialog");
box.setAttribute("aria-modal", "true");
const done = completeCount(state.events);
box.append(el("h2", null, "Redo this task from the start?"));
box.append(el("p", null,
`Your ${done} recorded judgments are kept in the saved history — a redo `
+ "never deletes anything. The task restarts at the first item and your "
+ "new judgments replace the old ones in the analysis. This cannot be "
+ "undone from this page."));
box.append(el("p", "modal-hint", `Type "${CONFIG.resetPhrase}" to confirm:`));
const input = el("input");
input.id = "confirm-input";
const actions = el("div", "modal-actions");
const cancel = el("button", null, "cancel");
const confirm = el("button", "danger", "redo — start over");
confirm.disabled = true;
const dismiss = () => { overlay.remove(); state.modalOpen = false; };
input.addEventListener("input", () => {
confirm.disabled = input.value.trim() !== CONFIG.resetPhrase;
});
cancel.addEventListener("click", dismiss);
confirm.addEventListener("click", () => { dismiss(); performReset(); });
overlay.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") dismiss();
});
overlay.addEventListener("click", (ev) => { if (ev.target === overlay) dismiss(); });
actions.append(cancel, confirm);
box.append(input, actions);
overlay.append(box);
state.modalOpen = true;
document.body.append(overlay);
input.focus();
}
function performReset() {
commitCurrent(); // a trailing edit belongs to the history too
state.events.push({
key: RESET_KEY, type: "reset",
seq: state.events.length,
user: state.user, ts: new Date().toISOString(),
session_id: state.sessionId, rubric_version: state.rubricVersion,
client: `${CONFIG.task}@${CONFIG.build}`,
});
persistLocal();
state.unsaved += 1;
state.last = null;
state.gradedSinceBreak = 0;
pushGuarded();
state.idx = firstUngraded(); // back to the top, warm-ups included
renderItem();
banner("Task reset — starting over from the first item. Your previous "
+ "judgments remain in the saved history.", false, [], "info");
}
// ── boot ─────────────────────────────────────────────────────────────────────
async function boot() {
// The single boot deadline: if nothing has replaced the shell after 30s, some
// boot-path await (items, results-repo prep, gate reads) stalled — common on
// phones. One watchdog covers every await, so no per-request timeout races it;
// a reload is the recovery, and localStorage means nothing is lost.
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 += completeCount(events);
} 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 hub().downloadFile({ repo: ITEMS_REPO, path: CONFIG.itemsPath,
accessToken: state.auth.accessToken });
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 ensureResultsRepo();
} 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."));
$("#main").append(recoveryButton(log.kind === "denied"));
$("#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.)"
+ (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) {
$("#main").append(recoveryButton(log.kind === "denied"));
}
$("#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();
installMenu();
state.idx = firstUngraded();
renderItem();
const done = completeCount(state.events);
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();