File size: 42,511 Bytes
2e3db49
 
 
 
 
 
 
 
 
490790a
 
 
 
 
 
2e3db49
 
 
 
 
 
e27289c
 
 
 
 
8b45cc8
 
e27289c
 
 
 
 
 
 
 
 
2e3db49
 
 
0db4d7f
 
 
2e3db49
5f1aaf7
2e3db49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0db4d7f
 
 
2e3db49
 
 
 
 
0db4d7f
 
 
 
 
 
 
 
 
2e3db49
0db4d7f
2e3db49
 
 
0db4d7f
 
 
 
 
2e3db49
 
 
 
 
 
 
9126057
f0bfa49
2f47f3f
0db4d7f
5da77ee
2e3db49
 
 
490790a
2e3db49
 
 
 
 
 
 
 
 
e27289c
2e3db49
 
 
e27289c
 
2e3db49
 
0db4d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3db49
 
 
0db4d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3db49
0db4d7f
2e3db49
 
 
 
0db4d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3db49
e27289c
2e3db49
 
 
 
 
e27289c
 
 
 
 
 
 
 
 
 
 
 
 
490790a
 
e27289c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0db4d7f
 
e27289c
 
 
 
 
0db4d7f
f0bfa49
e27289c
8d19d30
e27289c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3db49
e27289c
 
 
 
 
 
2e3db49
 
 
 
 
5f1aaf7
 
0db4d7f
e27289c
0db4d7f
 
 
 
 
 
e27289c
 
 
0db4d7f
 
5cb6969
 
 
0db4d7f
5cb6969
 
 
 
766336e
f0bfa49
 
2e3db49
 
 
490790a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e27289c
 
490790a
e27289c
490790a
 
e27289c
 
490790a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3db49
 
 
 
 
 
 
490790a
2e3db49
 
 
 
 
 
 
 
9126057
 
2e3db49
f0bfa49
0db4d7f
2e3db49
 
 
490790a
 
f0bfa49
 
 
 
e27289c
 
 
f0bfa49
e27289c
2e3db49
490790a
e27289c
 
 
 
 
 
 
 
f0bfa49
490790a
2e3db49
 
f0bfa49
 
 
 
 
 
2e3db49
 
 
 
 
 
 
e27289c
2e3db49
 
 
9126057
 
2e3db49
 
 
 
 
 
 
 
 
 
 
 
2f47f3f
 
 
 
 
 
2e3db49
 
 
5da77ee
 
 
 
 
 
 
 
 
 
2e3db49
8d19d30
f0bfa49
e27289c
8d19d30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0bfa49
2e3db49
8d19d30
 
 
 
 
2e3db49
 
8d19d30
2e3db49
8d19d30
8b45cc8
 
 
f0bfa49
8b45cc8
2e3db49
 
 
 
 
 
 
f0bfa49
 
 
 
 
 
 
 
 
 
 
2e3db49
 
 
f0bfa49
 
 
2f47f3f
f0bfa49
 
 
2e3db49
f0bfa49
2e3db49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0bfa49
9126057
2e3db49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0bfa49
 
 
 
 
2e3db49
 
 
 
 
 
 
 
490790a
2e3db49
 
 
9126057
f0bfa49
 
9126057
 
f0bfa49
 
 
9126057
 
 
2e3db49
 
5da77ee
2e3db49
 
 
f0bfa49
2e3db49
 
5da77ee
 
 
 
2e3db49
490790a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0bfa49
 
 
 
2e3db49
 
 
 
490790a
 
2e3db49
f0bfa49
490790a
2e3db49
 
f0bfa49
 
0db4d7f
 
f0bfa49
2e3db49
 
 
 
 
f0bfa49
 
490790a
2e3db49
 
 
 
 
 
 
 
5da77ee
 
 
 
 
 
 
 
 
 
 
 
2e3db49
 
 
5f1aaf7
 
 
 
490790a
 
 
 
 
 
 
2e3db49
 
 
4ca2363
 
 
 
 
 
2e3db49
4ca2363
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3db49
f0bfa49
 
 
2e3db49
 
9126057
 
2e3db49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766336e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3db49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490790a
 
 
 
 
 
 
 
 
 
 
 
2e3db49
4ca2363
 
 
 
2e3db49
 
 
 
 
 
 
 
 
 
0db4d7f
4ca2363
0db4d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ca2363
060cc4d
 
 
 
 
0db4d7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
060cc4d
8d19d30
060cc4d
 
 
 
e27289c
8d19d30
 
 
 
 
f0bfa49
 
060cc4d
 
 
 
 
 
f0bfa49
 
 
 
 
0db4d7f
 
 
f0bfa49
 
 
0db4d7f
 
f0bfa49
 
 
 
 
0db4d7f
 
 
 
 
 
 
f0bfa49
 
 
 
 
490790a
 
 
2e3db49
 
490790a
 
 
 
 
2e3db49
5f1aaf7
 
 
2e3db49
 
 
f0bfa49
 
 
2f47f3f
 
 
0db4d7f
f0bfa49
2e3db49
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
/* 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();