abullard1 commited on
Commit
3afefa8
·
verified ·
1 Parent(s): 57ade10

Pass-1 verdicts Space; conversion locks until pass 1 is complete

Browse files
README.md CHANGED
@@ -1,10 +1,27 @@
1
  ---
2
- title: Audit Verdicts
3
- emoji: 🐨
4
- colorFrom: purple
5
- colorTo: red
6
  sdk: static
 
7
  pinned: false
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: LocalGate Audit — Conversion Pass 1
3
+ emoji:
4
+ colorFrom: gray
5
+ colorTo: gray
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ license: apache-2.0
10
+ hf_oauth: true
11
+ hf_oauth_expiration_minutes: 1440
12
+ hf_oauth_scopes:
13
+ - read-repos
14
+ - contribute-repos
15
+ hf_oauth_authorized_org: localgate
16
  ---
17
 
18
+ # LocalGate audit conversion pass 1 (blind verdicts)
19
+
20
+ Private static annotation Space. Use the **direct URL**
21
+ (https://localgate-audit-verdicts.static.hf.space) — the embedded view links out.
22
+ Sign in with your HF account; judgments are committed to a private dataset in
23
+ **your own namespace** (`{you}/localgate-audit-results`) under your own token,
24
+ so every grade is attributed by commit author. Progress saves continuously and
25
+ resumes across sessions. Incident response: revoke a leaked token at
26
+ https://huggingface.co/settings/tokens and remove this app under Settings →
27
+ Connected Applications.
app.js ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* LocalGate audit engine — shared by both audit Spaces.
2
+ *
3
+ * Design contract (deliverables/AUDIT_SPACES_BUILD.md, FINAL SPEC):
4
+ * - Same-tab OAuth on the direct *.static.hf.space URL; embedded views link out.
5
+ * - Token lives in sessionStorage only; the hub library persists nothing itself.
6
+ * - Scopes are read-repos + contribute-repos: results go to a private dataset in
7
+ * the ANNOTATOR'S OWN namespace ({user}/localgate-audit-results), created on
8
+ * first login. Nobody can write anyone else's results at the permission layer.
9
+ * - Append-only event log: every judgment/revision is a new row; analysis takes
10
+ * the LAST row per key in file order (position is the authority — client
11
+ * clocks are metadata, immune to skew). Every push is read-merge-write: the
12
+ * remote file is pulled and unioned first, so a push can only grow the file.
13
+ * - One tab at a time: a localStorage heartbeat blocks a second tab of the
14
+ * same task in the same browser (two browsers merge safely, last-write races
15
+ * are bounded by the merge-before-push).
16
+ * - All content rendered via textContent. No innerHTML anywhere in this file.
17
+ * - localStorage holds the event log per (task, user) — loss bound is zero;
18
+ * uploads every SAVE_EVERY judgments / on 's' / on tab-hide / at the end.
19
+ */
20
+ import { oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles, downloadFile,
21
+ createRepo } from "./hub-2.15.0.bundle.mjs";
22
+
23
+ /* global CONFIG */
24
+ const ORG_ID = "6a7af86a89612db0d39b0b14"; // localgate — forces the org grant
25
+ const ITEMS_REPO = { type: "dataset", name: "localgate/audit-items" };
26
+ const SAVE_EVERY = 5;
27
+
28
+ const $ = (sel) => document.querySelector(sel);
29
+ const el = (tag, cls, text) => {
30
+ const node = document.createElement(tag);
31
+ if (cls) node.className = cls;
32
+ if (text !== undefined) node.textContent = text;
33
+ return node;
34
+ };
35
+
36
+ // ── rubric fingerprint (same discipline as convert.py's PROMPT_VERSION) ──────
37
+ async function rubricVersion() {
38
+ const data = new TextEncoder().encode(CONFIG.rubric + "\0" + JSON.stringify(CONFIG.fields));
39
+ const hash = await crypto.subtle.digest("SHA-256", data);
40
+ return [...new Uint8Array(hash)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join("");
41
+ }
42
+
43
+ // ── auth ─────────────────────────────────────────────────────────────────────
44
+ function storedAuth() {
45
+ try {
46
+ const raw = sessionStorage.getItem("oauth");
47
+ if (!raw) return null;
48
+ const auth = JSON.parse(raw);
49
+ if (new Date(auth.accessTokenExpiresAt) <= new Date()) return null;
50
+ return auth;
51
+ } catch { return null; }
52
+ }
53
+
54
+ async function ensureAuth() {
55
+ let auth = storedAuth();
56
+ if (auth) return auth;
57
+ const fresh = await oauthHandleRedirectIfPresent();
58
+ if (fresh) {
59
+ sessionStorage.setItem("oauth", JSON.stringify(fresh));
60
+ history.replaceState(null, "", location.pathname); // ?code is single-use; keep it out of history
61
+ return fresh;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ async function signIn() {
67
+ const url = await oauthLoginUrl(); // reads window.huggingface.variables in a Space
68
+ window.location.href = url + "&orgIds=" + ORG_ID; // no param passthrough in the helper; append
69
+ }
70
+
71
+ // ── state ────────────────────────────────────────────────────────────────────
72
+ const state = {
73
+ auth: null, user: null, items: [], order: [], idx: 0,
74
+ events: [], // append-only, mirrored to localStorage
75
+ unsaved: 0, sessionId: crypto.randomUUID().slice(0, 8),
76
+ shownAt: 0, rubricVersion: "", resultsRepo: null, last: null,
77
+ breakShownAt: Date.now(), stickyBanner: null, hbTimer: null,
78
+ };
79
+
80
+ const logKey = () => `audit:${CONFIG.task}:${state.user}`;
81
+ const latestByKey = (events) => {
82
+ const map = new Map();
83
+ for (const ev of events) map.set(ev.key, ev); // log order: later position wins
84
+ return map;
85
+ };
86
+
87
+ // Union preserving log order: remote rows first (their order is the published
88
+ // history), then local rows not yet in remote, in local append order. No ts
89
+ // sort — a skewed clock must never reorder a revision before its original.
90
+ function mergeEvents(remote, local) {
91
+ const seen = new Set(remote.map((ev) => ev.key + "|" + ev.ts));
92
+ return [...remote, ...local.filter((ev) => !seen.has(ev.key + "|" + ev.ts))];
93
+ }
94
+
95
+ function loadLocalEvents() {
96
+ try { return JSON.parse(localStorage.getItem(logKey()) ?? "[]"); }
97
+ catch { return []; }
98
+ }
99
+ function persistLocal() {
100
+ localStorage.setItem(logKey(), JSON.stringify(state.events));
101
+ }
102
+
103
+ // Seeded per-user order (fatigue decorrelation) — mulberry32 over a string hash.
104
+ function seededOrder(n, seed) {
105
+ let h = 1779033703;
106
+ for (const ch of seed) { h = Math.imul(h ^ ch.charCodeAt(0), 3432918353); h = (h << 13) | (h >>> 19); }
107
+ const rand = () => {
108
+ h = Math.imul(h ^ (h >>> 16), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909);
109
+ return ((h ^= h >>> 16) >>> 0) / 4294967296;
110
+ };
111
+ const order = [...Array(n).keys()];
112
+ for (let i = n - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; }
113
+ return order;
114
+ }
115
+
116
+ // Warm-ups always come first, in file order; real items follow in seeded order.
117
+ function buildOrder() {
118
+ const warm = [], real = [];
119
+ state.items.forEach((item, i) => (item.warmup ? warm : real).push(i));
120
+ const shuffled = seededOrder(real.length, `${CONFIG.task}:${state.user}`).map((i) => real[i]);
121
+ state.order = [...warm, ...shuffled];
122
+ }
123
+
124
+ function keyOf(item) { return String(item.id ?? item.key); }
125
+
126
+ function firstUngraded() {
127
+ const done = latestByKey(state.events);
128
+ for (let pos = 0; pos < state.order.length; pos++) {
129
+ const item = state.items[state.order[pos]];
130
+ const ev = done.get(keyOf(item));
131
+ if (!ev || !CONFIG.isComplete(ev)) return pos;
132
+ }
133
+ return state.order.length;
134
+ }
135
+
136
+ // ── results repo (own namespace; contribute-repos scope) ─────────────────────
137
+ async function ensureResultsRepo() {
138
+ const name = `${state.user}/localgate-audit-results`;
139
+ state.resultsRepo = { type: "dataset", name };
140
+ try {
141
+ await createRepo({ repo: state.resultsRepo, accessToken: state.auth.accessToken,
142
+ private: true });
143
+ } catch (err) {
144
+ // Already exists (any prior session) is the expected steady state; the hub's
145
+ // message says "You already created this dataset repo" with no status code.
146
+ if (!/409|exist|already/i.test(String(err))) throw err;
147
+ }
148
+ }
149
+
150
+ const remotePath = () => `${CONFIG.task}/${state.user}.jsonl`;
151
+
152
+ async function pullRemote() {
153
+ const blob = await downloadFile({ repo: state.resultsRepo, path: remotePath(),
154
+ accessToken: state.auth.accessToken });
155
+ if (blob === null) return []; // no file yet — first session
156
+ return (await blob.text()).split("\n").filter(Boolean).map((line) => JSON.parse(line));
157
+ }
158
+
159
+ async function push() {
160
+ if (!state.events.length) return;
161
+ // Read-merge-write: never upload without having just read the remote head.
162
+ // A pull failure aborts the push — an unreadable remote must not be replaced.
163
+ const remote = await pullRemote();
164
+ state.events = mergeEvents(remote, state.events);
165
+ persistLocal();
166
+ const jsonl = state.events.map((ev) => JSON.stringify(ev)).join("\n") + "\n";
167
+ await uploadFiles({
168
+ repo: state.resultsRepo, accessToken: state.auth.accessToken,
169
+ commitTitle: `${CONFIG.task}: ${state.user} — ${state.events.length} events`,
170
+ files: [{ path: remotePath(), content: new Blob([jsonl]) }],
171
+ });
172
+ state.unsaved = 0;
173
+ banner("", true); // clear any stale failure notice
174
+ renderStatus();
175
+ }
176
+
177
+ async function pushGuarded() {
178
+ try { await push(); }
179
+ catch (err) {
180
+ banner(/403|401/.test(String(err))
181
+ ? "Saved locally, but publishing failed (permissions). Nothing is lost — press s to retry after it's fixed."
182
+ : `Saved locally; publishing failed: ${String(err).slice(0, 120)} — press s to retry.`, true);
183
+ }
184
+ }
185
+
186
+ // ── one-tab guard (heartbeat in localStorage; pagehide releases it) ──────────
187
+ const HEARTBEAT_MS = 5000;
188
+ const heartbeatKey = () => `audit:hb:${CONFIG.task}`;
189
+
190
+ function foreignTabAlive() {
191
+ try {
192
+ const hb = JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null");
193
+ return !!hb && hb.session !== state.sessionId && Date.now() - hb.at < HEARTBEAT_MS * 3;
194
+ } catch { return false; }
195
+ }
196
+ function readHeartbeat() {
197
+ try { return JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null"); }
198
+ catch { return null; }
199
+ }
200
+
201
+ // Ownership is an epoch: taking over bumps it, and a tab stands down only to a
202
+ // fresh foreign heartbeat of an equal-or-newer epoch — so a seizure is
203
+ // one-directional even while the old tab is still beating.
204
+ function startHeartbeat(force = false) {
205
+ const stored = readHeartbeat();
206
+ state.hbEpoch = (stored?.epoch ?? 0) + (force ? 1 : 0);
207
+ const beat = () => {
208
+ const hb = readHeartbeat();
209
+ const foreignFresh = hb && hb.session !== state.sessionId
210
+ && Date.now() - hb.at < HEARTBEAT_MS * 3;
211
+ if (foreignFresh && (hb.epoch ?? 0) >= state.hbEpoch) { supersede(); return; }
212
+ localStorage.setItem(heartbeatKey(),
213
+ JSON.stringify({ session: state.sessionId, at: Date.now(), epoch: state.hbEpoch }));
214
+ };
215
+ beat();
216
+ clearInterval(state.hbTimer);
217
+ state.hbTimer = setInterval(beat, HEARTBEAT_MS);
218
+ }
219
+
220
+ function supersede() {
221
+ clearInterval(state.hbTimer);
222
+ commitCurrent();
223
+ if (state.unsaved) pushGuarded(); // merge-safe: the other tab re-pulls
224
+ $("#controls").hidden = true;
225
+ $("#main").replaceChildren(el("p", null,
226
+ "Grading moved to another tab. This tab is paused — you can close it; everything here was saved."));
227
+ }
228
+ function releaseHeartbeat() {
229
+ try {
230
+ const hb = JSON.parse(localStorage.getItem(heartbeatKey()) ?? "null");
231
+ if (hb?.session === state.sessionId) localStorage.removeItem(heartbeatKey());
232
+ } catch { /* releasing is best-effort */ }
233
+ }
234
+
235
+ // ── recording ────────────────────────────────────────────────────────────────
236
+ function stampClass(values) {
237
+ // The stamp inks in the verdict's own color; any failing answer turns it red.
238
+ const vals = Object.values(values);
239
+ if (vals.includes("no_match") || vals.includes("no")) return "v-no_match";
240
+ if (vals.includes("no_answer") || vals.includes("borderline")) return "v-no_answer";
241
+ return "v-match";
242
+ }
243
+
244
+ function record(item, values, unsure, note) {
245
+ const key = keyOf(item);
246
+ const prior = [...state.events].reverse().find((ev) => ev.key === key);
247
+ state.events.push({
248
+ key, ...values,
249
+ unsure: !!unsure, note: note || "",
250
+ seq: state.events.length, // position cross-check for the loader
251
+ user: state.user, ts: new Date().toISOString(),
252
+ order_index: state.idx, elapsed_ms: Date.now() - state.shownAt,
253
+ edit_count: prior ? (prior.edit_count ?? 0) + 1 : 0,
254
+ session_id: state.sessionId, rubric_version: state.rubricVersion,
255
+ client: `${CONFIG.task}@${CONFIG.build}`,
256
+ ...(item.warmup ? { warmup: true } : {}),
257
+ });
258
+ persistLocal();
259
+ state.last = { key, text: CONFIG.summarize(values), unsure: !!unsure,
260
+ cls: stampClass(values) };
261
+ state.unsaved += 1;
262
+ if (state.unsaved >= SAVE_EVERY) pushGuarded();
263
+ }
264
+
265
+ // ── UI ───────────────────────────────────────────────────────────────────────
266
+ // Sticky banners (save failures) survive item renders until a push succeeds;
267
+ // transient ones (break reminder) clear on the next item.
268
+ function banner(text, sticky = false) {
269
+ if (sticky) state.stickyBanner = text || null;
270
+ const box = $("#banner");
271
+ const shown = text || state.stickyBanner || "";
272
+ box.textContent = shown;
273
+ box.hidden = !shown;
274
+ }
275
+
276
+ function renderStatus() {
277
+ const done = [...latestByKey(state.events).values()]
278
+ .filter((ev) => !ev.warmup && CONFIG.isComplete(ev)).length;
279
+ const total = state.items.filter((item) => !item.warmup).length;
280
+ $("#progress").textContent =
281
+ `${done}/${total} graded · ${state.unsaved} unsaved` +
282
+ (state.items[state.order[state.idx]]?.warmup ? " · WARM-UP" : "");
283
+ $("#bar-fill").style.width = `${(100 * done) / total}%`;
284
+ }
285
+
286
+ function contentBlock(label, text, opts = {}) {
287
+ const wrap = el("section", "field" + (opts.scroll ? " response" : "")
288
+ + (opts.ref ? " reference" : ""));
289
+ const head = el("b", null, label + (opts.count ? ` · ${text.length} chars` : ""));
290
+ wrap.append(head);
291
+ const body = el("div", "content");
292
+ body.textContent = text; // textContent ONLY — never innerHTML
293
+ wrap.append(body);
294
+ if (opts.scroll) wrap.append(el("div", "endmark", "· · · end of response · · ·"));
295
+ return wrap;
296
+ }
297
+
298
+ function renderItem() {
299
+ const main = $("#main");
300
+ main.replaceChildren();
301
+ banner("");
302
+
303
+ if (state.idx >= state.order.length) {
304
+ main.append(el("h2", null, "All items graded — thank you!"));
305
+ main.append(el("p", null, "Final save in progress. You can close this tab once the counter reads 0 unsaved."));
306
+ pushGuarded();
307
+ renderStatus();
308
+ $("#controls").hidden = true;
309
+ return;
310
+ }
311
+ const item = state.items[state.order[state.idx]];
312
+ if (item.warmup) {
313
+ main.append(el("p", "warmup-note",
314
+ "Warm-up item — discussable with the others; real items start after these and must be graded independently."));
315
+ }
316
+ for (const block of CONFIG.blocks(item)) {
317
+ main.append(contentBlock(block.label, block.text, block));
318
+ }
319
+ state.shownAt = Date.now();
320
+ renderControls(item);
321
+ renderStatus();
322
+ if (Date.now() - state.breakShownAt > 45 * 60 * 1000) {
323
+ banner("You've been at this a while — good moment for a break. Everything up to the counter is saved.");
324
+ state.breakShownAt = Date.now();
325
+ }
326
+ }
327
+
328
+ function renderControls(item) {
329
+ const controls = $("#controls");
330
+ controls.hidden = false;
331
+ controls.replaceChildren();
332
+ const current = latestByKey(state.events).get(keyOf(item));
333
+ const selection = { ...(current ? CONFIG.valuesOf(current) : {}) };
334
+ let unsure = current?.unsure ?? false;
335
+
336
+ const groups = [];
337
+ for (const field of CONFIG.fields) {
338
+ const group = el("div", "grp");
339
+ group.append(el("span", "lbl", field.label));
340
+ for (const [value, hotkey] of field.options) {
341
+ const button = el("button", null, value);
342
+ button.dataset.v = value; // semantic verdict color hook
343
+ button.append(el("kbd", null, hotkey));
344
+ if (selection[field.name] === value) button.classList.add("sel");
345
+ button.addEventListener("click", () => choose(field.name, value));
346
+ group.append(button);
347
+ }
348
+ controls.append(group);
349
+ groups.push(group);
350
+ }
351
+
352
+ const unsureBtn = el("button", "unsure" + (unsure ? " sel" : ""), "unsure");
353
+ unsureBtn.append(el("kbd", null, "u"));
354
+ unsureBtn.title = "marks this judgment as uncertain — it still counts, the flag is analysis metadata";
355
+ unsureBtn.addEventListener("click", () => { unsure = !unsure; unsureBtn.classList.toggle("sel", unsure); });
356
+ controls.append(unsureBtn);
357
+
358
+ const note = el("input", null);
359
+ note.id = "note"; note.placeholder = CONFIG.notePlaceholder;
360
+ note.value = current?.note ?? "";
361
+ controls.append(note);
362
+
363
+ const nav = el("div", "nav");
364
+ const prev = el("button", null, "← prev"); prev.append(el("kbd", null, "j"));
365
+ prev.addEventListener("click", () => move(-1));
366
+ const skip = el("button", null, "next →"); skip.append(el("kbd", null, "k"));
367
+ skip.addEventListener("click", () => move(1));
368
+ const save = el("button", null, "save"); save.append(el("kbd", null, "s"));
369
+ save.addEventListener("click", () => { commitCurrent(); pushGuarded(); });
370
+ nav.append(prev, skip, save);
371
+ controls.append(nav);
372
+
373
+ if (state.last) {
374
+ const last = el("div", "last");
375
+ last.append(el("span", null, `last · ${state.last.key}`));
376
+ last.append(el("span", "stamp " + state.last.cls,
377
+ state.last.text + (state.last.unsure ? " · unsure" : "")));
378
+ last.append(el("span", null, "j to revisit"));
379
+ controls.append(last);
380
+ }
381
+
382
+ function choose(name, value) {
383
+ selection[name] = value;
384
+ CONFIG.fields.forEach((field, fi) => {
385
+ groups[fi].querySelectorAll("button").forEach((button) => {
386
+ const label = button.childNodes[0].textContent;
387
+ button.classList.toggle("sel", selection[field.name] === label);
388
+ });
389
+ });
390
+ if (CONFIG.fields.every((field) => selection[field.name])) move(1);
391
+ }
392
+
393
+ // The one recording path: leaving an item (nav, save, tab-hide) commits a
394
+ // complete selection whose verdicts, unsure flag, or note differ from the
395
+ // last recorded event — so a note typed or unsure toggled after the final
396
+ // verdict click is never lost.
397
+ controls._commitIfDirty = () => {
398
+ if (!CONFIG.fields.every((field) => selection[field.name])) return;
399
+ const now = latestByKey(state.events).get(keyOf(item));
400
+ const values = Object.fromEntries(CONFIG.fields.map((f) => [f.name, selection[f.name]]));
401
+ const dirty = !now
402
+ || CONFIG.fields.some((field) => now[field.name] !== selection[field.name])
403
+ || (now.unsure ?? false) !== unsure
404
+ || (now.note ?? "") !== (note.value || "");
405
+ if (dirty) record(item, values, unsure, note.value);
406
+ };
407
+ controls._choose = choose; // for the keyboard handler
408
+ controls._toggleUnsure = () => unsureBtn.click();
409
+ }
410
+
411
+ function commitCurrent() { $("#controls")._commitIfDirty?.(); }
412
+
413
+ function move(delta) {
414
+ commitCurrent();
415
+ state.idx = Math.max(0, Math.min(state.order.length, state.idx + delta));
416
+ renderItem();
417
+ }
418
+
419
+ document.addEventListener("keydown", (event) => {
420
+ if (event.target.tagName === "INPUT" || $("#controls").hidden) return;
421
+ const controls = $("#controls");
422
+ if (event.key === "j") { event.preventDefault(); move(-1); return; }
423
+ if (event.key === "k") { event.preventDefault(); move(1); return; }
424
+ if (event.key === "s") { event.preventDefault(); commitCurrent(); pushGuarded(); return; }
425
+ if (event.key === "u") { event.preventDefault(); controls._toggleUnsure?.(); return; }
426
+ if (event.key === "e") {
427
+ event.preventDefault();
428
+ const resp = document.querySelector(".response .content");
429
+ if (resp) resp.parentElement.scrollTop = resp.parentElement.scrollHeight;
430
+ return;
431
+ }
432
+ const binding = CONFIG.hotkeys[event.key];
433
+ if (binding) { event.preventDefault(); controls._choose?.(binding[0], binding[1]); }
434
+ });
435
+
436
+ document.addEventListener("visibilitychange", () => {
437
+ if (document.visibilityState !== "hidden") return;
438
+ commitCurrent(); // capture a trailing note/unsure edit
439
+ if (state.unsaved) pushGuarded(); // best-effort
440
+ });
441
+ window.addEventListener("pagehide", () => {
442
+ commitCurrent();
443
+ if (state.unsaved) pushGuarded(); // best-effort; localStorage is the backstop
444
+ releaseHeartbeat(); // so a reload doesn't trip the one-tab guard
445
+ });
446
+
447
+ // ── boot ─────────────────────────────────────────────────────────────────────
448
+ async function boot() {
449
+ $("#task-title").textContent = CONFIG.title;
450
+ state.rubricVersion = await rubricVersion();
451
+ $("#rubric-body").textContent = CONFIG.rubric;
452
+ // Wide screens hold the rubric in a side rail — open it so it reads at a glance.
453
+ if (matchMedia("(min-width: 1240px)").matches) $("#rubric").open = true;
454
+
455
+ if (window.self !== window.top) {
456
+ // Embedded in hf.co: OAuth storage is partitioned here — link out instead.
457
+ const main = $("#main");
458
+ main.replaceChildren(el("p", null, "Open this Space in its own tab to sign in:"));
459
+ const link = el("a", "open-out", location.href);
460
+ link.href = location.href; link.target = "_blank"; link.rel = "noopener";
461
+ main.append(link);
462
+ return;
463
+ }
464
+
465
+ state.auth = await ensureAuth();
466
+ if (!state.auth) {
467
+ const main = $("#main");
468
+ main.replaceChildren(el("p", null, CONFIG.landing));
469
+ main.append(el("p", "independence", CONFIG.independence));
470
+ const button = el("button", "signin", "Sign in with Hugging Face");
471
+ button.addEventListener("click", signIn);
472
+ main.append(button);
473
+ return;
474
+ }
475
+
476
+ const username = state.auth.userInfo.preferred_username;
477
+ const inOrg = (state.auth.userInfo.orgs ?? []).some((org) => org.preferred_username === "localgate");
478
+ if (!inOrg) {
479
+ $("#main").replaceChildren(el("p", null,
480
+ `@${username} is not on this study's annotator list — ask Samuel to add you to the localgate org.`));
481
+ return;
482
+ }
483
+ state.user = username;
484
+ $("#whoami").textContent = `@${username}`;
485
+
486
+ if (!state.hbTimer && foreignTabAlive()) {
487
+ const main = $("#main");
488
+ main.replaceChildren(el("p", null,
489
+ "This audit is already open in another tab of this browser. Two open copies " +
490
+ "can overwrite each other's work, so grading is paused here."));
491
+ const takeOver = el("button", "signin", "Continue in this tab instead");
492
+ takeOver.addEventListener("click", () => { startHeartbeat(true); boot(); });
493
+ main.append(takeOver);
494
+ return;
495
+ }
496
+ if (!state.hbTimer) startHeartbeat();
497
+
498
+ try {
499
+ const blob = await downloadFile({ repo: ITEMS_REPO, path: CONFIG.itemsPath,
500
+ accessToken: state.auth.accessToken });
501
+ if (blob === null) throw new Error("items file missing from localgate/audit-items");
502
+ state.items = JSON.parse(await blob.text());
503
+ } catch (err) {
504
+ $("#main").replaceChildren(el("p", null, `Could not load items: ${String(err).slice(0, 140)}`));
505
+ const retry = el("button", null, "retry");
506
+ retry.addEventListener("click", boot);
507
+ $("#main").append(retry);
508
+ return;
509
+ }
510
+
511
+ await ensureResultsRepo();
512
+
513
+ // Ordering gate: a task can require another task's completion first (the
514
+ // conversion fidelity pass must not open before the blind verdict pass,
515
+ // because it reveals which items the filter kept).
516
+ if (CONFIG.requires) {
517
+ let done = 0;
518
+ try {
519
+ const blob = await downloadFile({
520
+ repo: state.resultsRepo, accessToken: state.auth.accessToken,
521
+ path: `${CONFIG.requires.task}/${state.user}.jsonl`,
522
+ });
523
+ if (blob !== null) {
524
+ const rows = (await blob.text()).split("\n").filter(Boolean).map((l) => JSON.parse(l));
525
+ done = [...latestByKey(rows).values()]
526
+ .filter((ev) => !ev.warmup && CONFIG.requires.fields.every((f) => ev[f])).length;
527
+ }
528
+ } catch { /* unreadable counts as not done — the gate stays shut */ }
529
+ if (done < CONFIG.requires.count) {
530
+ $("#main").replaceChildren(el("p", null,
531
+ `${CONFIG.requires.label} must be finished first — you have graded ` +
532
+ `${done} of ${CONFIG.requires.count} items there. This pass reveals ` +
533
+ "information that must not color that one, so it stays locked until " +
534
+ "you are done."));
535
+ const link = el("a", "open-out", CONFIG.requires.url);
536
+ link.href = CONFIG.requires.url; link.rel = "noopener";
537
+ $("#main").append(link);
538
+ return;
539
+ }
540
+ }
541
+
542
+ // Merge remote history with the local log, remote order first (mergeEvents).
543
+ // A pull FAILURE is not "no file yet": grading continues from the local log,
544
+ // and because every push re-pulls first, nothing can be overwritten blind.
545
+ let remote = [];
546
+ try { remote = await pullRemote(); }
547
+ catch (err) {
548
+ banner(`Could not read your previous progress (${String(err).slice(0, 80)}) — ` +
549
+ "grading continues and is kept in this browser; publishing retries on the next save.", true);
550
+ }
551
+ state.events = mergeEvents(remote, loadLocalEvents());
552
+ persistLocal();
553
+
554
+ buildOrder();
555
+ state.idx = firstUngraded();
556
+ renderItem();
557
+ }
558
+
559
+ boot();
config.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.CONFIG = {
2
+ task: "verdicts",
3
+ title: "Conversion audit, pass 1 — 248 items",
4
+ itemsPath: "verdict_items.json",
5
+ build: "ef8b962-p1space",
6
+ landing: "Pass 1 of the conversion audit. You will see original exam questions "
7
+ + "with their answer options and judge whether each could survive losing its "
8
+ + "options — you are never told what the automated filter decided. 248 items; "
9
+ + "expect 2–3 hours, in as many sittings as you like — progress saves "
10
+ + "continuously. Please stick to one browser: work is guarded per browser, "
11
+ + "and switching browsers mid-audit merges on save rather than instantly.",
12
+ independence: "Grade independently: no discussing items until everyone has "
13
+ + "finished both conversion passes. Do not try to guess or track what the "
14
+ + "filter kept — every item is shown the same way on purpose.",
15
+ rubric: "CONVERTS — imagine the options deleted and the question reworded to "
16
+ + "stand alone. Would a knowledgeable person give the reference answer, "
17
+ + "rather than a different, equally correct one?\n\nyes — the reference is "
18
+ + "the single correct answer. no — stripped of options it admits many "
19
+ + "correct answers, or stops meaning anything. borderline — you genuinely "
20
+ + "cannot decide; use sparingly.\n\nAlways 'no': exclusion questions "
21
+ + "('which is NOT', 'EXCEPT', 'least likely') and answers that only mean "
22
+ + "something beside the other options ('all of the above') — except when "
23
+ + "the stem itself lists the alternatives ('i) … ii) … iii)'). Common "
24
+ + "failures: asking for one member of a large category; ranking the "
25
+ + "options ('best', 'closest'); an answer true among these ten but false "
26
+ + "about the world; leaning on a unit or convention only the options "
27
+ + "supplied. A superlative is not automatically a ranking — if the stem "
28
+ + "fixes the quantity, 'best approximates' has nothing left to rank.\n\n"
29
+ + "Not failures: multi-part questions (judge uniqueness, not simplicity), "
30
+ + "working the answer out from supplied figures, clumsy phrasing (a "
31
+ + "rewrite fixes that), and the reference's wording (a later stage "
32
+ + "accepts any phrasing of the same fact).\n\nANSWER STANDS ALONE — judge "
33
+ + "this independently: a grader will see the question, this reference, "
34
+ + "and a model response. yes — usable target. no — units or magnitude "
35
+ + "stripped ('1.12' for a speed), a fragment ('greater and grander'), it "
36
+ + "does not answer what was asked, or it names option labels ('I and II "
37
+ + "only'). borderline — sparingly.\n\nWHY — half a line on what decided "
38
+ + "it, written for the other two annotators when you disagree. Do not "
39
+ + "track your own yes/no ratio: the sample is deliberately not a picture "
40
+ + "of the corpus.",
41
+ fields: [
42
+ { name: "converts", label: "converts?",
43
+ options: [["yes","1"],["no","2"],["borderline","3"]] },
44
+ { name: "answer_stands_alone", label: "answer stands alone?",
45
+ options: [["yes","4"],["no","5"],["borderline","6"]] },
46
+ ],
47
+ hotkeys: { "1":["converts","yes"], "2":["converts","no"],
48
+ "3":["converts","borderline"],
49
+ "4":["answer_stands_alone","yes"], "5":["answer_stands_alone","no"],
50
+ "6":["answer_stands_alone","borderline"] },
51
+ notePlaceholder: "why (half a line)",
52
+ isComplete: (ev) => !!(ev.converts && ev.answer_stands_alone),
53
+ valuesOf: (ev) => ({ converts: ev.converts,
54
+ answer_stands_alone: ev.answer_stands_alone }),
55
+ summarize: (v) => `converts:${v.converts} alone:${v.answer_stands_alone}`,
56
+ blocks: (item) => [
57
+ { label: "original (with options)",
58
+ text: item.original + "\n\n" + item.options.map((o,i)=>String.fromCharCode(65+i)+". "+o).join("\n") },
59
+ { label: "correct answer", text: item.reference_answer, ref: true },
60
+ ],
61
+ };
fonts/ibm-plex-mono-latin-400-normal.woff2 ADDED
Binary file (14.7 kB). View file
 
fonts/ibm-plex-mono-latin-600-normal.woff2 ADDED
Binary file (15.6 kB). View file
 
fonts/source-serif-4-latin-400-normal.woff2 ADDED
Binary file (20.1 kB). View file
 
fonts/source-serif-4-latin-600-normal.woff2 ADDED
Binary file (21.5 kB). View file
 
hub-2.15.0.bundle.mjs ADDED
The diff for this file is too large to render. See raw diff
 
index.html CHANGED
@@ -1,19 +1,27 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src https://huggingface.co https://*.hf.co; img-src 'self' data:; base-uri 'none'; form-action 'none'">
7
+ <title>LocalGate audit</title>
8
+ <link rel="stylesheet" href="style.css?v=ef8b962-p1space">
9
+ </head>
10
+ <body>
11
+ <header>
12
+ <h1 id="task-title">Loading…</h1>
13
+ <span id="whoami"></span>
14
+ <span id="progress"></span>
15
+ </header>
16
+ <div id="bar"><div id="bar-fill"></div></div>
17
+ <div id="banner" hidden></div>
18
+ <div id="layout">
19
+ <main id="main"><p>Loading…</p></main>
20
+ <details id="rubric"><summary>Rubric &amp; edge rules (keep open while grading)</summary>
21
+ <pre id="rubric-body"></pre></details>
22
+ </div>
23
+ <div id="controls" hidden></div>
24
+ <script src="config.js?v=ef8b962-p1space"></script>
25
+ <script type="module" src="app.js?v=ef8b962-p1space"></script>
26
+ </body>
27
  </html>
style.css CHANGED
@@ -1,28 +1,181 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
 
 
 
 
 
 
 
 
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
 
 
 
1
+ /* LocalGate audit instrument — grading-desk design.
2
+ Chroma is semantic only: verdict inks + the reference yardstick. Everything
3
+ instrumental speaks mono; everything read speaks serif. */
4
+
5
+ @font-face { font-family: "Source Serif 4"; src: url("fonts/source-serif-4-latin-400-normal.woff2") format("woff2");
6
+ font-weight: 400; font-display: swap; }
7
+ @font-face { font-family: "Source Serif 4"; src: url("fonts/source-serif-4-latin-600-normal.woff2") format("woff2");
8
+ font-weight: 600; font-display: swap; }
9
+ @font-face { font-family: "IBM Plex Mono"; src: url("fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");
10
+ font-weight: 400; font-display: swap; }
11
+ @font-face { font-family: "IBM Plex Mono"; src: url("fonts/ibm-plex-mono-latin-600-normal.woff2") format("woff2");
12
+ font-weight: 600; font-display: swap; }
13
 
14
+ :root {
15
+ --ground: #f3f5f4; /* cool exam paper, not cream */
16
+ --field: #ffffff;
17
+ --ink: #1c2430; /* blue-black */
18
+ --dim: #5c6672;
19
+ --line: #d4dad8;
20
+ --line-strong: #aeb8b4;
21
+ --ref-ground: #fbf7e6; /* annotation-yellow: the yardstick, nothing else */
22
+ --ref-line: #e3d9ae;
23
+ --match: #2f7a4d;
24
+ --nomatch: #a63d2a;
25
+ --noanswer: #58657a;
26
+ --unsure: #9a7b1f;
27
+ --yes: #2f7a4d;
28
+ --no: #a63d2a;
29
+ --serif: "Source Serif 4", Charter, "Iowan Old Style", Georgia, serif;
30
+ --mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
31
+ }
32
+ @media (prefers-color-scheme: dark) {
33
+ :root {
34
+ --ground: #171b1f; --field: #1f242a; --ink: #dde3e8; --dim: #8b95a0;
35
+ --line: #323a41; --line-strong: #49535c;
36
+ --ref-ground: #2b2618; --ref-line: #4a3f1e;
37
+ --match: #5cb885; --nomatch: #d9765f; --noanswer: #93a2b8; --unsure: #cfa93e;
38
+ --yes: #5cb885; --no: #d9765f;
39
+ }
40
  }
41
 
42
+ * { box-sizing: border-box; }
43
+ html { height: 100%; }
44
+ body { margin: 0; min-height: 100%; background: var(--ground); color: var(--ink);
45
+ font: 16.5px/1.62 var(--serif); }
46
+
47
+ /* ── header: instrument panel ── */
48
+ header { display: flex; gap: 1.2rem; align-items: baseline; flex-wrap: wrap;
49
+ padding: .65rem 1.2rem; background: var(--field);
50
+ border-bottom: 2px solid var(--ink); font-family: var(--mono); }
51
+ header h1 { margin: 0; font-size: .8rem; font-weight: 600; letter-spacing: .14em;
52
+ text-transform: uppercase; }
53
+ #whoami { color: var(--dim); font-size: .75rem; }
54
+ #progress { margin-left: auto; color: var(--dim); font-size: .75rem;
55
+ font-variant-numeric: tabular-nums; }
56
+
57
+ /* gauge with ticks every 10% */
58
+ #bar { height: 6px; background:
59
+ repeating-linear-gradient(to right,
60
+ transparent 0, transparent calc(10% - 1px),
61
+ var(--line-strong) calc(10% - 1px), var(--line-strong) 10%),
62
+ var(--line); }
63
+ #bar-fill { height: 100%; width: 0; background: var(--ink); transition: width .25s ease; }
64
+
65
+ #banner { padding: .55rem 1.2rem; font-family: var(--mono); font-size: .78rem;
66
+ color: var(--unsure); background: color-mix(in srgb, var(--unsure) 9%, var(--field));
67
+ border-bottom: 1px solid var(--line); }
68
+
69
+ /* ── layout: reading column + rubric rail on wide screens ── */
70
+ #layout { max-width: 100rem; margin: 0 auto; }
71
+ main { max-width: 46rem; margin: 1.1rem auto 12rem; padding: 0 1.2rem; }
72
+ @media (min-width: 1240px) {
73
+ #layout { display: grid; grid-template-columns: 1fr 21rem; gap: 0; }
74
+ main { grid-column: 1; margin-left: auto; margin-right: 3vw; }
75
+ details#rubric { grid-column: 2; position: sticky; top: 0; align-self: start;
76
+ max-height: calc(100vh - 8rem); border-left: 1px solid var(--line);
77
+ border-top: none; background: transparent; }
78
+ details#rubric[open] summary { border-bottom: 1px solid var(--line); }
79
  }
80
 
81
+ /* ── fields ── */
82
+ .field { background: var(--field); border: 1px solid var(--line);
83
+ border-left: 3px solid var(--line-strong); padding: .8rem 1.1rem;
84
+ margin: .8rem 0; }
85
+ .field b { display: block; font-family: var(--mono); font-weight: 600;
86
+ font-size: .66rem; letter-spacing: .12em; text-transform: uppercase;
87
+ color: var(--dim); margin-bottom: .4rem; }
88
+ .field .content { white-space: pre-wrap; overflow-wrap: anywhere; }
89
+
90
+ /* the yardstick: only the reference wears the annotation highlight */
91
+ .field.reference { background: var(--ref-ground); border-color: var(--ref-line);
92
+ border-left-color: var(--unsure); }
93
+
94
+ .response { max-height: 26rem; overflow-y: auto; border-left-color: var(--ink); }
95
+ .endmark { text-align: center; color: var(--dim); font-family: var(--mono);
96
+ font-size: .68rem; letter-spacing: .18em; padding-top: .6rem; }
97
+
98
+ .warmup-note { font-family: var(--mono); font-size: .78rem; padding: .55rem .9rem;
99
+ border: 1px dashed var(--line-strong); color: var(--dim); }
100
+ .independence { color: var(--dim); font-style: italic; }
101
+
102
+ button.signin { font: 600 .95rem var(--mono); padding: .7rem 1.5rem; cursor: pointer;
103
+ border: 2px solid var(--ink); background: var(--ink); color: var(--ground); }
104
+ button.signin:hover { background: transparent; color: var(--ink); }
105
+
106
+ /* ── rubric ── */
107
+ details#rubric { background: var(--field); border-top: 1px solid var(--line);
108
+ font-family: var(--mono); font-size: .74rem; line-height: 1.55;
109
+ overflow-y: auto; }
110
+ details#rubric summary { cursor: pointer; padding: .55rem 1.1rem; font-weight: 600;
111
+ letter-spacing: .08em; text-transform: uppercase;
112
+ font-size: .66rem; color: var(--dim); list-style: none; }
113
+ details#rubric summary::before { content: "▸ "; }
114
+ details#rubric[open] summary::before { content: "▾ "; }
115
+ details#rubric pre { white-space: pre-wrap; font: inherit; margin: 0;
116
+ padding: .3rem 1.1rem 1rem; }
117
+ @media (max-width: 1239px) {
118
+ details#rubric { position: fixed; bottom: 5.9rem; left: 0; right: 0; max-height: 38vh;
119
+ border-top: 2px solid var(--line-strong); }
120
  }
121
 
122
+ /* ── controls: the bench ── */
123
+ #controls { position: fixed; bottom: 0; left: 0; right: 0; background: var(--field);
124
+ border-top: 2px solid var(--ink); padding: .65rem 1.2rem;
125
+ display: flex; gap: .7rem; align-items: center; flex-wrap: wrap;
126
+ font-family: var(--mono); }
127
+ #controls .grp { display: flex; gap: .4rem; align-items: center; }
128
+ #controls .lbl { font-size: .68rem; letter-spacing: .08em; text-transform: uppercase;
129
+ color: var(--dim); }
130
+ #controls button { font: 600 .82rem var(--mono); padding: .48rem .8rem; cursor: pointer;
131
+ background: var(--field); color: var(--ink);
132
+ border: 2px solid var(--line-strong); }
133
+ #controls button:hover { border-color: var(--ink); }
134
+
135
+ /* verdict identity: a colored stamp edge; full ink when committed */
136
+ #controls button[data-v] { border-left-width: 5px; }
137
+ #controls button[data-v="match"], #controls button[data-v="yes"] { border-left-color: var(--match); }
138
+ #controls button[data-v="no_match"], #controls button[data-v="no"] { border-left-color: var(--nomatch); }
139
+ #controls button[data-v="no_answer"] { border-left-color: var(--noanswer); }
140
+ #controls button[data-v].sel { color: var(--field); }
141
+ #controls button[data-v="match"].sel, #controls button[data-v="yes"].sel
142
+ { background: var(--match); border-color: var(--match); }
143
+ #controls button[data-v="no_match"].sel, #controls button[data-v="no"].sel
144
+ { background: var(--nomatch); border-color: var(--nomatch); }
145
+ #controls button[data-v="no_answer"].sel
146
+ { background: var(--noanswer); border-color: var(--noanswer); }
147
+
148
+ #controls button.unsure { border-style: dashed; color: var(--dim); }
149
+ #controls button.unsure.sel { background: var(--unsure); border-color: var(--unsure);
150
+ border-style: solid; color: var(--field); }
151
+
152
+ #controls kbd { display: inline-block; font-size: .62rem; font-weight: 400;
153
+ padding: 0 .3em; margin-left: .45em; border: 1px solid currentColor;
154
+ border-bottom-width: 2px; border-radius: 3px; opacity: .65; }
155
+
156
+ #controls #note { font: .8rem var(--mono); padding: .45rem .6rem; width: 13rem;
157
+ border: 1px solid var(--line); background: var(--ground);
158
+ color: var(--ink); }
159
+ #controls #note::placeholder { color: var(--dim); }
160
+ #controls .nav { margin-left: auto; display: flex; gap: .4rem; }
161
+
162
+ /* ── the signature: the verdict stamp ── */
163
+ #controls .last { width: 100%; color: var(--dim); font-size: .72rem;
164
+ display: flex; align-items: center; gap: .5rem; }
165
+ #controls .last .stamp { display: inline-block; font-weight: 600; font-size: .7rem;
166
+ letter-spacing: .06em; padding: .12rem .5rem;
167
+ border: 2px solid var(--stamp, var(--dim));
168
+ color: var(--stamp, var(--dim));
169
+ transform: rotate(-1.2deg);
170
+ animation: stamp .14s ease-out; }
171
+ #controls .last .stamp.v-match { --stamp: var(--match); }
172
+ #controls .last .stamp.v-no_match { --stamp: var(--nomatch); }
173
+ #controls .last .stamp.v-no_answer { --stamp: var(--noanswer); }
174
+ @keyframes stamp { from { transform: rotate(-1.2deg) scale(1.12); opacity: .4; }
175
+ to { transform: rotate(-1.2deg) scale(1); opacity: 1; } }
176
+ @media (prefers-reduced-motion: reduce) {
177
+ .last .stamp { animation: none; }
178
+ #bar-fill { transition: none; }
179
  }
180
+
181
+ :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }