paperlint 14.0.0 → 14.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,6 +82,29 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
82
82
  background: var(--good); margin-right: 8px; vertical-align: middle; }
83
83
  .status.missing { color: var(--mark); }
84
84
  .status.failed { color: var(--bad); }
85
+ /* The content blocks (#380): a code excerpt with its line numbers and the marked line lit, references,
86
+ lists and tables folded away. A long text shows three lines and a «more». */
87
+ a { color: var(--accent); }
88
+ summary { cursor: pointer; color: var(--accent); min-height: 44px; display: flex; align-items: center; gap: 8px; }
89
+ .situation { color: var(--strong); font-weight: 500; }
90
+ pre.numbered { white-space: normal; padding: 8px 0; }
91
+ pre.numbered .line { display: grid; grid-template-columns: 2ch 5ch 1fr; white-space: pre-wrap; padding: 0 10px 0 0; }
92
+ /* The marked line reads as marked without its colour: a marker in the gutter and a label after the code. */
93
+ pre.numbered .g { color: var(--mark); text-align: center; user-select: none; }
94
+ pre.numbered .this { color: var(--mark); font-weight: 700; margin-left: 1ch; white-space: nowrap; user-select: none; }
95
+ pre.numbered .n { color: var(--muted); text-align: right; padding-right: 1.5ch; user-select: none; }
96
+ pre.numbered .hit { background: var(--mark-bg); }
97
+ pre.numbered .hit .n { color: var(--mark); font-weight: 700; }
98
+ .rulebox { display: flex; flex-direction: column; gap: 8px; }
99
+ .clamped { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
100
+ /* The clamp cuts at the padding box, so a code block's bottom padding is a border: no fourth line peeks in. */
101
+ pre.clamped { padding-bottom: 0; border-bottom: 10px solid var(--surface); box-shadow: 0 1px 0 var(--line); }
102
+ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; align-self: flex-start; }
103
+ .setting { display: flex; flex-direction: column; gap: 2px; padding-top: 12px; border-top: 1px solid var(--line); }
104
+ .match.yes { color: var(--good); }
105
+ .match.no { color: var(--bad); }
106
+ .who { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); }
107
+ .who button { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; }
85
108
  </style>
86
109
  <main id="app"><p class="muted">Loading…</p></main>
87
110
  <script type="application/json" id="data">__DATA__</script>
@@ -100,24 +123,160 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
100
123
  whyTimers: {}, // "<item id>|<question id>" -> the pending save of a reason being typed
101
124
  whyBoxes: {}, // "<item id>|<question id>" -> the reason box of the last render
102
125
  typingIn: null, // the item whose reason box has the keyboard: shown even when complete
126
+ lastAnswered: null, // the item answered last: shown under "Next unanswered" until the next answer
127
+ pending: {}, // item id -> answers chosen whose reason is still empty: NOT in the store
128
+ refusedWhy: {}, // item id -> true when an empty reason was refused for its answer
103
129
  mine: {}, // item/group key -> the answers this page has just written (ahead of the store)
104
130
  inflight: {}, // item/group key -> writes not yet confirmed
105
131
  failed: {}, // item/group key -> error code of the last refused write
106
132
  loaded: { answers: false, groups: false, practice: false },
107
133
  store: "loading", // loading | ok | none
108
- filter: "all"
134
+ filter: "all",
135
+ who: null, // the answerer picked, on a page with DATA.answerers; null until one is
136
+ raw: { answers: {}, groups: {}, practice: {} }, // every document of the store, every answerer's
137
+ dwell: {}, // item id -> ms its card was on screen, clocks stopped (DATA.min_seconds)
138
+ seenSince: {}, // item id -> when its card came on screen, while it is
139
+ firstSeconds: {}, // item id -> the seconds this page stored with the item's first save
140
+ gates: {} // item id -> the locked buttons and the countdown of the last render
109
141
  };
110
142
  var db = null;
111
143
  var chains = {};
112
144
  var WHY_PAUSE_MS = 800;
113
145
 
114
146
  try { if (localStorage.getItem("label-filter") === "next") state.filter = "next"; } catch (e) {}
147
+ // ── who is answering (DATA.answerers): each answerer's documents are "<answerer>~<id>" ─────
148
+ // "~" is outside the build's name pattern, so no answerer, item, group or practice id holds it.
149
+ var SEP = "~";
150
+ var ANSWERERS = DATA.answerers || [];
151
+ var ASKS_WHO = ANSWERERS.length > 0;
152
+ try {
153
+ var remembered = localStorage.getItem("label-answerer");
154
+ if (ANSWERERS.indexOf(remembered) !== -1) state.who = remembered;
155
+ } catch (e) {}
156
+ // The store's id of one of this page's documents: as before on a page without answerers.
157
+ function docName(id) { return ASKS_WHO ? state.who + SEP + id : id; }
158
+ // The documents of the current answerer, by their names without the answerer.
159
+ function mineOf(docs) {
160
+ if (!ASKS_WHO) return docs;
161
+ var out = {}, prefix = state.who + SEP;
162
+ if (state.who) Object.keys(docs).forEach(function (k) { if (k.indexOf(prefix) === 0) out[k.slice(prefix.length)] = docs[k]; });
163
+ return out;
164
+ }
165
+ // The items shown to the current answerer: an item's own `answerers`, else everyone's.
166
+ function itemsShown() {
167
+ return DATA.items.filter(function (i) { return !i.answerers || i.answerers.indexOf(state.who) !== -1; });
168
+ }
169
+ function groupsShown() { return DATA.groups.filter(function (g) { return groupItems(g.id).length > 0; }); }
170
+ function busy() {
171
+ return Object.keys(state.inflight).some(function (k) { return state.inflight[k] > 0; }) || Object.keys(state.whyTimers).length > 0;
172
+ }
173
+ // Picking (or switching) the answerer: everything this page holds of the last one is dropped.
174
+ function choose(name) {
175
+ if (busy()) return;
176
+ Object.keys(state.seenSince).forEach(stopClock);
177
+ state.who = name;
178
+ try {
179
+ if (name) localStorage.setItem("label-answerer", name);
180
+ else localStorage.removeItem("label-answerer");
181
+ } catch (e) {}
182
+ ["editing", "mineWhy", "draftWhy", "pending", "refusedWhy", "mine", "failed", "dwell", "seenSince", "firstSeconds"]
183
+ .forEach(function (k) { state[k] = {}; });
184
+ state.lastAnswered = null;
185
+ ["answers", "groups", "practice"].forEach(function (c) { state[c] = mineOf(state.raw[c]); });
186
+ render();
187
+ }
188
+ // ── how long a card was on screen (DATA.min_seconds): its answer saves only after that long ──
189
+ // On screen: half the card in view, or half the viewport filled by a card taller than it.
190
+ var MIN_SECONDS = DATA.min_seconds || 0;
191
+ function dwellMs(id) {
192
+ var ms = state.dwell[id] || 0;
193
+ return state.seenSince[id] !== undefined ? ms + Date.now() - state.seenSince[id] : ms;
194
+ }
195
+ function stopClock(id) {
196
+ if (state.seenSince[id] === undefined) return;
197
+ state.dwell[id] = dwellMs(id);
198
+ delete state.seenSince[id];
199
+ }
200
+ function startClock(id) { if (state.seenSince[id] === undefined) state.seenSince[id] = Date.now(); }
201
+ // An item already saved (by this page or in the store) is never locked again.
202
+ function unsaved(id) { return !state.answers[id] && state.firstSeconds[id] === undefined; }
203
+ function locked(id) { return MIN_SECONDS > 0 && unsaved(id) && dwellMs(id) < MIN_SECONDS * 1000; }
204
+ function waitText(id) {
205
+ return "\u23f3 You can answer in " + Math.ceil((MIN_SECONDS * 1000 - dwellMs(id)) / 1000) + " s";
206
+ }
207
+ // The seconds stored with an item: those of its first save, kept on every later write.
208
+ function secondsOf(id) {
209
+ var d = state.answers[id];
210
+ if (d && typeof d.seconds === "number") return d.seconds;
211
+ if (state.firstSeconds[id] === undefined) state.firstSeconds[id] = Math.floor(dwellMs(id) / 1000);
212
+ return state.firstSeconds[id];
213
+ }
214
+ // An observer reports only when a card crosses one of its thresholds, so each card gets its own
215
+ // observer whose threshold is that card's share: half the card, or half the viewport for a card
216
+ // taller than it. The share follows the card's height and the viewport's: a card that changes
217
+ // size, or a resized window, is watched again with its new share.
218
+ var observers = [];
219
+ var sizes = null;
220
+ function share(h) { return h > 0 ? Math.min(h, window.innerHeight) / 2 / h : 0.5; }
221
+ function onScreen(entry) {
222
+ var h = entry.boundingClientRect.height;
223
+ return entry.isIntersecting && h > 0 &&
224
+ entry.intersectionRect.height >= Math.min(h, window.innerHeight) / 2 - 0.5;
225
+ }
226
+ function watchCard(card) {
227
+ var id = card.getAttribute("data-id");
228
+ var o = new IntersectionObserver(function (entries) {
229
+ entries.forEach(function (e) {
230
+ if (onScreen(e) && document.visibilityState !== "hidden") startClock(id);
231
+ else stopClock(id);
232
+ });
233
+ }, { threshold: [0, share(card.getBoundingClientRect().height)] });
234
+ o.observe(card);
235
+ return o;
236
+ }
237
+ // Every render draws new cards: the clocks stop, and the new cards are watched from scratch.
238
+ function watchCards() {
239
+ observers.forEach(function (o) { o.disconnect(); });
240
+ observers = [];
241
+ if (sizes) sizes.disconnect();
242
+ sizes = null;
243
+ var cards = Array.prototype.slice.call(document.querySelectorAll("article.item[data-id]"));
244
+ if (typeof IntersectionObserver !== "function") {
245
+ // No way to tell what is in view: a card counts as on screen from the moment it is drawn.
246
+ cards.forEach(function (c) { startClock(c.getAttribute("data-id")); });
247
+ return;
248
+ }
249
+ observers = cards.map(watchCard);
250
+ if (typeof ResizeObserver !== "function") return;
251
+ var heights = cards.map(function (c) { return c.getBoundingClientRect().height; });
252
+ sizes = new ResizeObserver(function (entries) {
253
+ entries.forEach(function (e) {
254
+ var i = cards.indexOf(e.target);
255
+ var h = e.target.getBoundingClientRect().height;
256
+ if (i === -1 || Math.abs(h - heights[i]) < 1) return;
257
+ heights[i] = h;
258
+ observers[i].disconnect();
259
+ observers[i] = watchCard(cards[i]);
260
+ });
261
+ });
262
+ cards.forEach(function (c) { sizes.observe(c); });
263
+ }
264
+ // The countdown on each locked card, and its buttons let go when its time is up; no render, so
265
+ // a section the labeller opened stays open.
266
+ function tick() {
267
+ Object.keys(state.gates).forEach(function (id) {
268
+ var g = state.gates[id];
269
+ if (locked(id)) { g.note.textContent = waitText(id); return; }
270
+ g.note.textContent = "";
271
+ g.buttons.forEach(function (b) { b.disabled = !canWrite(); });
272
+ delete state.gates[id];
273
+ });
274
+ setTimeout(tick, 250);
275
+ }
115
276
 
116
277
  // ── what "done" means ────────────────────────────────────────────────────────────────────
117
278
  function answersOf(id) {
118
- if (state.mine[id]) return state.mine[id];
119
- var d = state.answers[id];
120
- return d && d.answers ? d.answers : {};
279
+ return state.pending[id] || savedOf(id);
121
280
  }
122
281
  // A stored value is an answer only if the build offers it (DATA.allowed, the same lists the
123
282
  // export reads from key.json): after a rebuild that removed an option, or a typo merged from chat,
@@ -141,6 +300,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
141
300
  return d[qid];
142
301
  }
143
302
  function needsWhy(q, v) { return (DATA.allowed.needs_why[q.id] || []).indexOf(v) !== -1; }
303
+ function questionOf(qid) { return DATA.questions.filter(function (q) { return q.id === qid; })[0]; }
144
304
  // What an item still needs: an answer, or the reason its answer asks for (named "<short> (why)").
145
305
  function missingFor(id) {
146
306
  var a = answersOf(id), r = reasonsOf(id);
@@ -161,7 +321,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
161
321
  var d = state.mine["group:" + g] || state.groups[g];
162
322
  return d && valid(DATA.allowed.familiarity, d.familiarity) ? d.familiarity : undefined;
163
323
  }
164
- function groupItems(g) { return DATA.items.filter(function (i) { return i.group === g; }); }
324
+ function groupItems(g) { return itemsShown().filter(function (i) { return i.group === g; }); }
165
325
  // ── the practice (rule 8): known answers, before the real items ──────────────────────────
166
326
  function practiceDoc(pid) { return "r" + DATA.practice.round + "-" + pid; }
167
327
  function practiceAnswers(pid) {
@@ -186,7 +346,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
186
346
  function write(key, coll, docId, body) {
187
347
  state.inflight[key] = (state.inflight[key] || 0) + 1;
188
348
  delete state.failed[key];
189
- var step = function () { return db.collection(coll).doc(docId).set(body); };
349
+ var step = function () { return db.collection(coll).doc(docName(docId)).set(body); };
190
350
  var run = (chains[key] || Promise.resolve()).then(step, step);
191
351
  chains[key] = run.then(function () {
192
352
  state.inflight[key] -= 1;
@@ -200,27 +360,76 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
200
360
  });
201
361
  }
202
362
  function canWrite() {
203
- return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok";
363
+ return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok" &&
364
+ (!ASKS_WHO || state.who !== null);
365
+ }
366
+ // An answer that asks why is not written while its reason is empty (#380): it waits on the page, and
367
+ // the store keeps what it had, so an export never meets an answer without the reason it needs. Every
368
+ // write leaves out each such answer, not only the one just picked: on a page of several questions the
369
+ // next answer would otherwise carry the waiting one into the store without its reason.
370
+ // A waiting answer that replaces a saved one leaves the saved one in the write until its reason comes.
371
+ // A stored answer to a question this page no longer asks (removed or renamed between builds) is kept
372
+ // as it was and never checked: the page has no question to check it against.
373
+ function resolved(answers, reasons, saved) {
374
+ var out = {}, waiting = false;
375
+ Object.keys(answers).forEach(function (q) {
376
+ var asked = questionOf(q);
377
+ if (!(asked && needsWhy(asked, answers[q]) && !(reasons[q] || "").trim())) out[q] = answers[q];
378
+ else {
379
+ waiting = true;
380
+ if (Object.prototype.hasOwnProperty.call(saved, q)) out[q] = saved[q];
381
+ }
382
+ });
383
+ return { ready: out, waiting: waiting };
384
+ }
385
+ // What the store holds for an item, or what this page last wrote to it ahead of the store.
386
+ function savedOf(id) {
387
+ if (state.mine[id]) return state.mine[id];
388
+ var d = state.answers[id];
389
+ return d && d.answers ? d.answers : {};
390
+ }
391
+ function keep(id, next, reasons) {
392
+ var r = resolved(next, reasons, savedOf(id)), ready = r.ready;
393
+ if (r.waiting) state.pending[id] = next;
394
+ else delete state.pending[id];
395
+ delete state.refusedWhy[id];
396
+ state.mine[id] = ready;
397
+ var body = { answers: ready, reasons: reasons, savedAt: new Date().toISOString(), source: "page" };
398
+ if (MIN_SECONDS) body.seconds = secondsOf(id);
399
+ write(id, "answers", id, body);
400
+ render();
204
401
  }
205
402
  function answer(id, qid, value) {
206
- if (!canWrite()) return;
403
+ if (!canWrite() || locked(id)) return;
207
404
  var next = Object.assign({}, answersOf(id));
208
405
  next[qid] = value;
209
- state.mine[id] = next;
210
- write(id, "answers", id, { answers: next, reasons: reasonsOf(id), savedAt: new Date().toISOString(), source: "page" });
211
- render();
406
+ state.lastAnswered = id;
407
+ var draft = draftOf(id, qid);
408
+ var why = draft !== undefined ? draft : reasonsOf(id)[qid] || "";
409
+ if (needsWhy(questionOf(qid), value) && !why.trim()) {
410
+ state.pending[id] = next;
411
+ render();
412
+ return;
413
+ }
414
+ var reasons = Object.assign({}, reasonsOf(id));
415
+ if (why.trim()) reasons[qid] = why;
416
+ keep(id, next, reasons);
212
417
  }
213
- // A reason saves when the labeller leaves the box (the text equivalent of a tap).
418
+ // A reason saves when the labeller leaves the box (the text equivalent of a tap); an empty one is refused.
214
419
  function setReason(id, qid, text) {
215
420
  clearTimeout(state.whyTimers[id + "|" + qid]);
216
421
  delete state.whyTimers[id + "|" + qid];
217
422
  if (!canWrite()) return;
423
+ var a = answersOf(id);
424
+ if (needsWhy(questionOf(qid), a[qid]) && !text.trim()) {
425
+ state.refusedWhy[id] = true;
426
+ render();
427
+ return;
428
+ }
218
429
  var why = Object.assign({}, reasonsOf(id));
219
430
  why[qid] = text;
220
431
  state.mineWhy[id] = why;
221
- state.mine[id] = Object.assign({}, answersOf(id));
222
- write(id, "answers", id, { answers: answersOf(id), reasons: why, savedAt: new Date().toISOString(), source: "page" });
223
- render();
432
+ keep(id, Object.assign({}, a), why);
224
433
  }
225
434
  // The first answer to a practice question is the one scored: it cannot be changed afterwards.
226
435
  function answerPractice(pid, qid, value) {
@@ -257,8 +466,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
257
466
  });
258
467
  return n;
259
468
  }
260
- // `withExamples`: the practice shows each answer's example on its button; the real items keep the buttons
261
- // short, the examples stay on the first screen.
469
+ // `withExamples`: the practice shows each answer's example on its button, after `labels.practice_example`;
470
+ // the real items keep the buttons short, the examples stay on the first screen.
262
471
  // A code block shows what a labeller would otherwise count by eye: leading and trailing whitespace as
263
472
  // faint marks (a space as a middle dot, a tab as an arrow) and each line's length in characters.
264
473
  function visible(ws) { return ws.replace(/ /g, "\u00b7").replace(/\t/g, "\u2192"); }
@@ -281,7 +490,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
281
490
  var b = el("button", "");
282
491
  if (o.icon) b.appendChild(el("span", "icon", o.icon));
283
492
  b.appendChild(el("span", "label", o.label));
284
- if (withExamples && o.example) b.appendChild(rich("span", "ex", o.example));
493
+ if (withExamples && o.example) b.appendChild(rich("span", "ex", DATA.labels.practice_example + o.example));
285
494
  b.type = "button";
286
495
  b.setAttribute("aria-pressed", String(current === o.value));
287
496
  b.disabled = disabled;
@@ -296,7 +505,10 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
296
505
  }
297
506
  function statusLine(key, missing, savedAt) {
298
507
  var s = el("p", "status");
299
- if (state.failed[key]) {
508
+ if (state.pending[key] || state.refusedWhy[key]) {
509
+ s.className = "status missing";
510
+ s.textContent = "Not saved yet: this answer needs a reason. Write it above, or pick another answer.";
511
+ } else if (state.failed[key]) {
300
512
  s.className = "status failed";
301
513
  s.textContent = "Not saved (" + state.failed[key] + "). Tap the answer again.";
302
514
  } else if (state.inflight[key]) {
@@ -312,15 +524,21 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
312
524
  return s;
313
525
  }
314
526
  function itemCard(item, off) {
315
- var done = itemDone(item.id);
316
527
  var card = el("article", "item");
317
- card.appendChild(el("div", "id", item.id));
528
+ var shown = itemsShown();
529
+ if (MIN_SECONDS && unsaved(item.id)) card.setAttribute("data-id", item.id);
530
+ card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: shown.indexOf(item) + 1, total: shown.length })));
318
531
  evidence(card, item);
319
532
  var a = answersOf(item.id);
320
- askOne(card, a, state.editing[item.id], function (q, v) {
533
+ var gate = locked(item.id);
534
+ var ask = askOne(card, a, state.editing[item.id], function (q, v) {
321
535
  delete state.editing[item.id];
322
536
  answer(item.id, q.id, v);
323
- }, function (q) { state.editing[item.id] = q.id; render(); }, off);
537
+ }, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item.situation);
538
+ if (gate && ask) {
539
+ var note = ask.appendChild(el("p", "status missing", waitText(item.id)));
540
+ state.gates[item.id] = { note: note, buttons: Array.prototype.slice.call(ask.querySelectorAll("button")) };
541
+ }
324
542
  DATA.questions.forEach(function (q) {
325
543
  var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
326
544
  if (!o) return;
@@ -344,6 +562,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
344
562
  });
345
563
  var saved = state.answers[item.id] && state.answers[item.id].savedAt;
346
564
  card.appendChild(statusLine(item.id, missingFor(item.id), saved));
565
+ blocksBelow(card, item);
347
566
  return card;
348
567
  }
349
568
  // A labelled row: a short word, then what it labels.
@@ -361,17 +580,136 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
361
580
  p.appendChild(rich("span", "", u.text.slice(at + u.mark.length)));
362
581
  return p;
363
582
  }
583
+ // A situation's text with each `{line}` written out: «line N: » and the marked line's code as code
584
+ // (the build passes `line` whenever the text holds the token).
585
+ function situated(tag, cls, text, line) {
586
+ var n = el(tag, cls);
587
+ String(text).split("{line}").forEach(function (part, i) {
588
+ if (i) {
589
+ n.appendChild(el("span", "", "line " + line.n + ": "));
590
+ n.appendChild(el("code", "", line.text));
591
+ }
592
+ if (part) n.appendChild(rich("span", "", part));
593
+ });
594
+ return n;
595
+ }
596
+ // A long text shows its first three lines and a «more» that opens the rest.
597
+ function clamp(node, text) {
598
+ if (String(text).length < 200 && String(text).split("\n").length <= 3) return node;
599
+ var wrap = el("div", "rulebox");
600
+ node.className += " clamped";
601
+ var more = el("button", "more", "more");
602
+ more.type = "button";
603
+ more.addEventListener("click", function () {
604
+ node.className = node.className.replace(" clamped", "");
605
+ wrap.removeChild(more);
606
+ });
607
+ wrap.appendChild(node);
608
+ wrap.appendChild(more);
609
+ return wrap;
610
+ }
611
+ function link(text, url) {
612
+ var a = el("a", "", text);
613
+ a.href = url;
614
+ a.target = "_blank";
615
+ a.rel = "noopener";
616
+ return a;
617
+ }
618
+ function plainCode(text, cls) {
619
+ var pre = el("pre", cls || "");
620
+ pre.textContent = text;
621
+ return pre;
622
+ }
623
+ // `{name}` in a page label, from `vars`.
624
+ function fill(text, vars) {
625
+ return String(text).replace(/\{(\w+)\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
626
+ }
627
+ // A code excerpt: its file (a link when there is one), its lines numbered, the marked line with a
628
+ // gutter marker and a label besides its background, and the caption under it.
629
+ function excerptBlock(ex) {
630
+ var box = el("div", "rulebox");
631
+ if (ex.file) {
632
+ var name = el("p");
633
+ name.appendChild(ex.url ? link(ex.file, ex.url) : el("code", "", ex.file));
634
+ box.appendChild(name);
635
+ }
636
+ var pre = el("pre", "numbered");
637
+ ex.lines.forEach(function (l) {
638
+ var line = el("div", "line" + (l.mark ? " hit" : ""));
639
+ line.appendChild(el("span", "g", l.mark ? "\u25b6" : ""));
640
+ line.appendChild(el("span", "n", String(l.n)));
641
+ var code = line.appendChild(el("span", "", l.text));
642
+ if (l.mark) code.appendChild(el("span", "this", ex.mark_label || "\u25c0 this line"));
643
+ pre.appendChild(line);
644
+ });
645
+ box.appendChild(pre);
646
+ if (ex.caption) box.appendChild(rich("p", "said", ex.caption));
647
+ return box;
648
+ }
649
+ // A reference entry: its title, then each field (a paragraph, a code block on a labelled row, or a
650
+ // quiet note), then its link.
651
+ function referenceBlock(r) {
652
+ var box = el("div", "rulebox");
653
+ box.appendChild(rich("p", "", r.title));
654
+ r.fields.forEach(function (f) {
655
+ if (f.style === "note") box.appendChild(el("p", "muted", f.text));
656
+ else if (f.style === "code") box.appendChild(row(f.label || "", clamp(plainCode(f.text), f.text)));
657
+ else box.appendChild(clamp(rich("p", "", (f.label ? f.label + ": " : "") + f.text), f.text));
658
+ });
659
+ if (r.url) box.appendChild(el("p")).appendChild(link(r.url_label || r.url, r.url));
660
+ return box;
661
+ }
662
+ function folded(summary) {
663
+ var d = el("details");
664
+ d.appendChild(el("summary", "", summary));
665
+ return d;
666
+ }
667
+ function references(x) {
668
+ var d = folded(x.summary);
669
+ x.entries.forEach(function (r) { d.appendChild(row(r.label || "", referenceBlock(r))); });
670
+ return d;
671
+ }
672
+ function moreList(m) {
673
+ var d = folded(m.summary);
674
+ var ul = el("ul");
675
+ m.items.forEach(function (f) { ul.appendChild(rich("li", "", f)); });
676
+ if (m.rest) ul.appendChild(el("li", "muted", m.rest));
677
+ d.appendChild(ul);
678
+ return d;
679
+ }
680
+ // A folded table: its note, then each row in the order given: label, value as code, plain words, mark.
681
+ function table(t) {
682
+ var d = folded(t.summary);
683
+ if (t.note) d.appendChild(el("p", "muted", t.note));
684
+ t.rows.forEach(function (r) {
685
+ var s = el("div", "setting");
686
+ s.appendChild(el("span", "lbl", r.label));
687
+ s.appendChild(el("p")).appendChild(el("code", "", r.value));
688
+ if (r.plain) s.appendChild(rich("p", "", r.plain));
689
+ if (r.mark) s.appendChild(el("p", "match " + r.mark.tone, r.mark.text));
690
+ d.appendChild(s);
691
+ });
692
+ return d;
693
+ }
694
+ // Below the question and its answers: the references, the list of more like it and the table, each
695
+ // folded, so the question sits right under the code it names.
696
+ function blocksBelow(card, item) {
697
+ if (item.references) card.appendChild(references(item.references));
698
+ if (item.more) card.appendChild(moreList(item.more));
699
+ if (item.table) card.appendChild(table(item.table));
700
+ }
364
701
  // What a card shows above its question, one labelled row per field (an item and a practice item alike).
365
702
  function evidence(card, item) {
366
703
  if (item.title) card.appendChild(rich("h3", "", item.title));
367
- card.appendChild(rich("p", "body", item.body));
704
+ var body = rich("p", "body", item.body);
705
+ card.appendChild(DATA.labels.body ? row(DATA.labels.body, body) : body);
706
+ if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(DATA.labels.repeat, item.repeat)));
368
707
  if (item.code) card.appendChild(codeBlock(item.code));
369
- if (item.unit) card.appendChild(row(item.unit.of > 1 ? "Line · part " + item.unit.part + " of " + item.unit.of : "Line",
708
+ if (item.unit) card.appendChild(row(item.unit.of > 1 ? fill(DATA.labels.line_part, { line: DATA.labels.line, part: item.unit.part, of: item.unit.of }) : DATA.labels.line,
370
709
  markedLine(item.unit)));
371
710
  // A rule the item names, with what it does in the linter's own words (looked up at build time).
372
711
  (item.rules || []).forEach(function (r) {
373
- card.appendChild(row("Rule", rich("p", "", r.linter + " `" + r.id + "`" + (r.family ? " (" + r.family + ")" : "") +
374
- ": " + r.description)));
712
+ card.appendChild(row("Rule", rich("p", "", r.linter + " `" + r.id + "`" + (r.family ? " (" + r.family + ")" : "") + ": " + r.description)));
375
713
  });
376
714
  if (item.readings) {
377
715
  card.appendChild(row("Reading A", rich("p", "", item.readings.a), "reading a"));
@@ -379,6 +717,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
379
717
  }
380
718
  if (item.file) card.appendChild(row("File", rich("p", "", item.file)));
381
719
  if (item.context) card.appendChild(rich("p", "muted", item.context));
720
+ if (item.situation) card.appendChild(situated("p", "situation", item.situation.summary, item.situation.line));
721
+ if (item.excerpt) card.appendChild(excerptBlock(item.excerpt));
382
722
  }
383
723
  function labelOf(q, v) {
384
724
  var o = q.options.filter(function (x) { return x.value === v; })[0];
@@ -386,7 +726,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
386
726
  }
387
727
  // One question per screen: the answered ones stay as a line (with "Change"), and only one question
388
728
  // is asked: the one being changed, else the first unanswered.
389
- function askOne(card, a, editing, onPick, onChange, off, withExamples) {
729
+ function askOne(card, a, editing, onPick, onChange, off, withExamples, situation) {
390
730
  var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
391
731
  var asked = DATA.questions.filter(function (q) { return q.id === editing; })[0] || open[0];
392
732
  DATA.questions.forEach(function (q) {
@@ -401,17 +741,25 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
401
741
  }
402
742
  card.appendChild(line);
403
743
  });
404
- if (!asked) return;
744
+ if (!asked) return null;
405
745
  var ask = el("div", "ask");
406
- ask.appendChild(el("p", "q", asked.text));
746
+ // A card's situation asks the page's first question in its own words; the others keep theirs.
747
+ ask.appendChild(situation && asked === DATA.questions[0] ? situated("p", "q", situation.question, situation.line) : rich("p", "q", asked.text));
407
748
  ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples));
408
749
  card.appendChild(ask);
750
+ return ask;
409
751
  }
752
+ // Open until the practice is done; then folded into a toggle, so the cards start near the top.
410
753
  function howTo() {
411
754
  var sec = el("section");
412
- sec.appendChild(el("h2", "", "📖 How to answer"));
755
+ var body = sec;
756
+ if (practiceOpen()) sec.appendChild(el("h2", "", "📖 How to answer"));
757
+ else {
758
+ body = sec.appendChild(el("details"));
759
+ body.appendChild(el("summary", "", "📖 How to answer"));
760
+ }
413
761
  DATA.questions.forEach(function (q) {
414
- sec.appendChild(el("p", "q", q.text));
762
+ body.appendChild(el("p", "q", q.text));
415
763
  var list = el("div", "examples");
416
764
  q.options.forEach(function (o) {
417
765
  var row = el("div", "example");
@@ -419,7 +767,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
419
767
  row.appendChild(rich("span", "muted", "For example: " + o.example));
420
768
  list.appendChild(row);
421
769
  });
422
- sec.appendChild(list);
770
+ body.appendChild(list);
423
771
  });
424
772
  return sec;
425
773
  }
@@ -428,13 +776,14 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
428
776
  card.appendChild(el("div", "id", "Practice " + p.id));
429
777
  evidence(card, p);
430
778
  var a = practiceAnswers(p.id);
431
- askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true);
779
+ askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p.situation);
432
780
  if (practiceAnswered(p)) {
433
781
  var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
434
782
  card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
435
- (practiceMatched(p) ? "Matched. " : "Expected: " + expected + ". ") + p.why));
783
+ (practiceMatched(p) ? "Matched. " : "Not matched. Expected: " + expected + ". ") + p.why));
436
784
  }
437
785
  card.appendChild(statusLine("practice:" + p.id, [], null));
786
+ blocksBelow(card, p);
438
787
  return card;
439
788
  }
440
789
  function practiceBlock(off) {
@@ -470,7 +819,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
470
819
  sec.appendChild(fam);
471
820
  }
472
821
  groupItems(g.id).forEach(function (i) {
473
- if (onlyOpen && itemDone(i.id) && i.id !== state.typingIn) return;
822
+ if (onlyOpen && itemDone(i.id) && i.id !== state.typingIn && i.id !== state.lastAnswered) return;
474
823
  sec.appendChild(itemCard(i, off));
475
824
  });
476
825
  return sec;
@@ -505,14 +854,43 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
505
854
  // Under "Next unanswered" the save that completes an item would hide it, box and all, mid-sentence:
506
855
  // the item being typed in stays on the page until the labeller leaves its box.
507
856
  state.typingIn = typing ? typing.key.slice(0, typing.key.indexOf("|")) : null;
857
+ Object.keys(state.seenSince).forEach(stopClock);
858
+ state.gates = {};
508
859
  paint();
860
+ if (MIN_SECONDS) watchCards();
509
861
  var again = typing && state.whyBoxes[typing.key];
510
862
  if (again) { again.focus(); again.setSelectionRange(typing.start, typing.end); }
511
863
  }
864
+ // The answerer's choice, before anything else of the page.
865
+ function whoBlock() {
866
+ var sec = el("section");
867
+ sec.appendChild(el("h2", "", "Who is answering?"));
868
+ sec.appendChild(el("p", "muted", "Each person answers under their own name and sees only their own answers."));
869
+ var row = el("div", "opts");
870
+ ANSWERERS.forEach(function (name) {
871
+ var b = el("button", "", name);
872
+ b.type = "button";
873
+ b.addEventListener("click", function () { choose(name); });
874
+ row.appendChild(b);
875
+ });
876
+ sec.appendChild(row);
877
+ return sec;
878
+ }
879
+ function whoLine() {
880
+ var line = el("p", "who");
881
+ line.appendChild(el("span", "", "Answering as " + state.who));
882
+ var b = el("button", "", "Switch");
883
+ b.type = "button";
884
+ b.disabled = busy();
885
+ b.addEventListener("click", function () { choose(null); });
886
+ line.appendChild(b);
887
+ return line;
888
+ }
512
889
  function paint() {
513
890
  var off = !canWrite();
514
- var done = DATA.items.filter(function (i) { return itemDone(i.id); }).length;
515
- var total = DATA.items.length;
891
+ var items = itemsShown(), groups = groupsShown();
892
+ var done = items.filter(function (i) { return itemDone(i.id); }).length;
893
+ var total = items.length;
516
894
  app.textContent = "";
517
895
  var top = el("header", "top");
518
896
  app.appendChild(top);
@@ -525,6 +903,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
525
903
  } else if (state.store === "loading" || !(state.loaded.answers && state.loaded.groups)) {
526
904
  top.appendChild(el("p", "muted", "Loading saved answers…"));
527
905
  }
906
+ if (ASKS_WHO && !state.who) { app.appendChild(whoBlock()); return; }
907
+ if (ASKS_WHO) top.appendChild(whoLine());
528
908
  app.appendChild(howTo());
529
909
  app.appendChild(practiceBlock(off));
530
910
  // Without the store nothing can be answered, practice included: show the real items read-only (rule 7).
@@ -536,6 +916,12 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
536
916
  fill.style.width = (total ? (100 * done / total) : 0) + "%";
537
917
  bar.appendChild(fill);
538
918
  progress.appendChild(bar);
919
+ // Done means every item AND every group's familiarity, as «Next unanswered» counts it.
920
+ var rated = groups.every(function (g) { return familiarity(g.id) !== undefined; });
921
+ if (total && done === total && rated && state.filter === "all") {
922
+ progress.appendChild(el("p", "status saved", "All " + total + " items are complete. Tell the agent you are " +
923
+ "done; you can still change any answer."));
924
+ }
539
925
 
540
926
  var filters = el("div", "tabs");
541
927
  [["all", "All items"], ["next", "Next unanswered"]].forEach(function (f) {
@@ -553,8 +939,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
553
939
 
554
940
  var onlyOpen = state.filter === "next";
555
941
  if (onlyOpen) {
556
- var rest = DATA.items.filter(function (i) { return !itemDone(i.id); });
557
- var open = DATA.groups.filter(function (g) { return familiarity(g.id) === undefined; });
942
+ var rest = items.filter(function (i) { return !itemDone(i.id); });
943
+ var open = groups.filter(function (g) { return familiarity(g.id) === undefined; });
558
944
  var note = el("div", "muted");
559
945
  if (!rest.length && !open.length) {
560
946
  note.appendChild(el("p", "", "Nothing left. Every item and every group is answered."));
@@ -570,8 +956,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
570
956
  }
571
957
  progress.appendChild(note);
572
958
  }
573
- DATA.groups.forEach(function (g) {
574
- var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn; });
959
+ groups.forEach(function (g) {
960
+ var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn || i.id === state.lastAnswered; });
575
961
  if (onlyOpen && groupDone(g.id) && !typingHere) return;
576
962
  app.appendChild(groupBlock(g, off, onlyOpen));
577
963
  });
@@ -582,16 +968,24 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
582
968
  return function (snap) {
583
969
  var next = {};
584
970
  snap.docs.forEach(function (d) { next[d.id] = d.data(); });
585
- state[target] = next;
971
+ state.raw[target] = next;
972
+ state[target] = mineOf(next);
586
973
  state.loaded[loadedKey] = true;
587
974
  render();
588
975
  };
589
976
  }
590
977
  function broken() { state.store = "none"; render(); }
591
978
 
979
+ // Hidden: every clock stops. Back: the cards are watched afresh, because a change of tab alone
980
+ // fires no intersection, and a card drawn while the tab was hidden never started its clock.
592
981
  document.addEventListener("visibilitychange", function () {
593
- if (document.visibilityState !== "visible") flushReasons();
982
+ if (document.visibilityState !== "visible") {
983
+ flushReasons();
984
+ Object.keys(state.seenSince).forEach(stopClock);
985
+ } else if (MIN_SECONDS) watchCards();
594
986
  });
987
+ window.addEventListener("resize", function () { if (MIN_SECONDS) watchCards(); });
988
+ if (MIN_SECONDS) tick();
595
989
  window.addEventListener("pagehide", flushReasons);
596
990
  render();
597
991
  if (!window.claude || typeof window.claude.use !== "function") { broken(); return; }