paperlint 14.1.0 → 14.3.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,19 +82,57 @@ 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». */
85
+ /* The content blocks (#380, #392): a code excerpt with its line numbers, hidden ranges as ⋮ rows, an
86
+ optional extra gutter column and the marked line lit, with a review-style note under it; references,
87
+ lists and tables shown in place, never folded. A long text shows three lines and a «more». */
87
88
  a { color: var(--accent); }
88
89
  summary { cursor: pointer; color: var(--accent); min-height: 44px; display: flex; align-items: center; gap: 8px; }
89
90
  .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; }
91
+ .path { font-size: 0.8rem; color: var(--muted); }
92
+ .path code, .anno code, .src code, blockquote.quote code, .opts .sub code { background: transparent; border: 0;
93
+ padding: 0; }
94
+ pre.numbered { white-space: normal; padding: 8px 0; font-size: 12px; line-height: 1.45; border-radius: 12px; }
95
+ pre.numbered .line { display: grid; grid-template-columns: 7ch 1fr; white-space: pre-wrap; padding: 0 10px 0 0; }
96
+ pre.numbered.gutter .line { grid-template-columns: 7ch 5ch 1fr; }
97
+ pre.numbered .c { color: var(--accent); text-align: center; font-weight: 500; user-select: none; }
98
+ pre.numbered .hdr .c { font-family: var(--sans); font-size: 10px; color: var(--muted); font-weight: 400; }
99
+ /* The marked line reads as marked without its colour: a marker before its number, and a label after
100
+ the code or the note under it. */
94
101
  pre.numbered .this { color: var(--mark); font-weight: 700; margin-left: 1ch; white-space: nowrap; user-select: none; }
95
102
  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; }
103
+ pre.numbered .ctx .t, pre.numbered .gap .t { color: var(--muted); }
104
+ pre.numbered .gap .t { font-family: var(--sans); font-size: 11px; }
105
+ pre.numbered .hit { background: var(--mark-bg); box-shadow: inset 3px 0 0 var(--mark); }
106
+ pre.numbered .hit .n, pre.numbered .hit .t { color: var(--mark); }
107
+ .anno { position: relative; margin: 7px 8px 5px; padding: 6px 10px; border: 1px solid var(--accent); border-radius: 8px;
108
+ background: var(--bg); font-family: var(--sans); font-size: 14px; line-height: 1.4; color: var(--strong);
109
+ white-space: normal; }
110
+ .anno-arrow { position: absolute; top: -6px; left: 30px; width: 10px; height: 10px; background: var(--bg);
111
+ border-left: 1px solid var(--accent); border-top: 1px solid var(--accent); transform: rotate(45deg); }
112
+ /* The words a card asks about, as a quote, with their source as a small line under them. */
113
+ blockquote.quote { margin: 0; font-size: 1.1rem; line-height: 1.4; color: var(--strong); font-weight: 500;
114
+ background: transparent; border: 0; padding: 0; }
115
+ .src { color: var(--muted); font-size: 0.9rem; }
116
+ .opts button .sub { color: var(--muted); grid-column: 2; font-size: 0.8rem; line-height: 1.3; }
117
+ .ask .gate { background: var(--accent); color: var(--bg); opacity: 0.45; border-radius: 10px; min-height: 44px;
118
+ display: flex; align-items: center; justify-content: center; font-weight: 500; text-align: center; padding: 6px 12px; }
119
+ /* A term with a note: dotted underline; a tap (or hover, where there is hover) opens one floating note. */
120
+ button.term { display: inline; width: auto; min-height: 0; padding: 0; margin: 0; border: 0; border-radius: 0;
121
+ background: none; font: inherit; color: inherit; text-align: inherit; border-bottom: 1px dotted var(--accent); }
122
+ button.term[aria-expanded="true"] { border-bottom-style: solid; color: var(--strong); padding: 0; }
123
+ button.term code { background: transparent; border: 0; padding: 0; }
124
+ #tip { position: absolute; z-index: 10; width: max-content; max-width: 280px; background: var(--surface);
125
+ border: 1px solid var(--accent); border-radius: 8px; padding: 8px 10px; font-size: 13.5px; line-height: 1.45;
126
+ color: var(--text); font-family: var(--sans); }
127
+ #tip.snip { max-width: 300px; }
128
+ #tip p + p, #tip pre + p { margin-top: 6px; }
129
+ #tip a { font-size: 12px; }
130
+ #tip b, #tip q { color: var(--strong); font-weight: 500; }
131
+ #tip pre { margin: 0; padding: 0; border: 0; background: none; font-size: 10px; line-height: 1.45; white-space: pre;
132
+ overflow-x: auto; color: var(--strong); }
133
+ #tip .tip-arrow { position: absolute; width: 10px; height: 10px; background: var(--surface); transform: rotate(45deg); }
134
+ #tip.below .tip-arrow { top: -6px; border-left: 1px solid var(--accent); border-top: 1px solid var(--accent); }
135
+ #tip.above .tip-arrow { bottom: -6px; border-right: 1px solid var(--accent); border-bottom: 1px solid var(--accent); }
98
136
  .rulebox { display: flex; flex-direction: column; gap: 8px; }
99
137
  .clamped { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
100
138
  /* The clamp cuts at the padding box, so a code block's bottom padding is a border: no fourth line peeks in. */
@@ -103,6 +141,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
103
141
  .setting { display: flex; flex-direction: column; gap: 2px; padding-top: 12px; border-top: 1px solid var(--line); }
104
142
  .match.yes { color: var(--good); }
105
143
  .match.no { color: var(--bad); }
144
+ .who { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); }
145
+ .who button { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; }
106
146
  </style>
107
147
  <main id="app"><p class="muted">Loading…</p></main>
108
148
  <script type="application/json" id="data">__DATA__</script>
@@ -129,13 +169,151 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
129
169
  failed: {}, // item/group key -> error code of the last refused write
130
170
  loaded: { answers: false, groups: false, practice: false },
131
171
  store: "loading", // loading | ok | none
132
- filter: "all"
172
+ filter: "all",
173
+ who: null, // the answerer picked, on a page with DATA.answerers; null until one is
174
+ raw: { answers: {}, groups: {}, practice: {} }, // every document of the store, every answerer's
175
+ dwell: {}, // item id -> ms its card was on screen, clocks stopped (DATA.min_seconds)
176
+ seenSince: {}, // item id -> when its card came on screen, while it is
177
+ firstSeconds: {}, // item id -> the seconds this page stored with the item's first save
178
+ gates: {} // item id -> the locked buttons and the countdown of the last render
133
179
  };
134
180
  var db = null;
135
181
  var chains = {};
136
182
  var WHY_PAUSE_MS = 800;
137
183
 
138
184
  try { if (localStorage.getItem("label-filter") === "next") state.filter = "next"; } catch (e) {}
185
+ // ── who is answering (DATA.answerers): each answerer's documents are "<answerer>~<id>" ─────
186
+ // "~" is outside the build's name pattern, so no answerer, item, group or practice id holds it.
187
+ var SEP = "~";
188
+ var ANSWERERS = DATA.answerers || [];
189
+ var ASKS_WHO = ANSWERERS.length > 0;
190
+ try {
191
+ var remembered = localStorage.getItem("label-answerer");
192
+ if (ANSWERERS.indexOf(remembered) !== -1) state.who = remembered;
193
+ } catch (e) {}
194
+ // The store's id of one of this page's documents: as before on a page without answerers.
195
+ function docName(id) { return ASKS_WHO ? state.who + SEP + id : id; }
196
+ // The documents of the current answerer, by their names without the answerer.
197
+ function mineOf(docs) {
198
+ if (!ASKS_WHO) return docs;
199
+ var out = {}, prefix = state.who + SEP;
200
+ if (state.who) Object.keys(docs).forEach(function (k) { if (k.indexOf(prefix) === 0) out[k.slice(prefix.length)] = docs[k]; });
201
+ return out;
202
+ }
203
+ // The items shown to the current answerer: an item's own `answerers`, else everyone's.
204
+ function itemsShown() {
205
+ return DATA.items.filter(function (i) { return !i.answerers || i.answerers.indexOf(state.who) !== -1; });
206
+ }
207
+ function groupsShown() { return DATA.groups.filter(function (g) { return groupItems(g.id).length > 0; }); }
208
+ function busy() {
209
+ return Object.keys(state.inflight).some(function (k) { return state.inflight[k] > 0; }) || Object.keys(state.whyTimers).length > 0;
210
+ }
211
+ // Picking (or switching) the answerer: everything this page holds of the last one is dropped.
212
+ function choose(name) {
213
+ if (busy()) return;
214
+ Object.keys(state.seenSince).forEach(stopClock);
215
+ state.who = name;
216
+ try {
217
+ if (name) localStorage.setItem("label-answerer", name);
218
+ else localStorage.removeItem("label-answerer");
219
+ } catch (e) {}
220
+ ["editing", "mineWhy", "draftWhy", "pending", "refusedWhy", "mine", "failed", "dwell", "seenSince", "firstSeconds"]
221
+ .forEach(function (k) { state[k] = {}; });
222
+ state.lastAnswered = null;
223
+ ["answers", "groups", "practice"].forEach(function (c) { state[c] = mineOf(state.raw[c]); });
224
+ render();
225
+ }
226
+ // ── how long a card was on screen (DATA.min_seconds): its answer saves only after that long ──
227
+ // On screen: half the card in view, or half the viewport filled by a card taller than it.
228
+ var MIN_SECONDS = DATA.min_seconds || 0;
229
+ function dwellMs(id) {
230
+ var ms = state.dwell[id] || 0;
231
+ return state.seenSince[id] !== undefined ? ms + Date.now() - state.seenSince[id] : ms;
232
+ }
233
+ function stopClock(id) {
234
+ if (state.seenSince[id] === undefined) return;
235
+ state.dwell[id] = dwellMs(id);
236
+ delete state.seenSince[id];
237
+ }
238
+ function startClock(id) { if (state.seenSince[id] === undefined) state.seenSince[id] = Date.now(); }
239
+ // An item already saved (by this page or in the store) is never locked again.
240
+ function unsaved(id) { return !state.answers[id] && state.firstSeconds[id] === undefined; }
241
+ function locked(id) { return MIN_SECONDS > 0 && unsaved(id) && dwellMs(id) < MIN_SECONDS * 1000; }
242
+ function waitSeconds(id) { return Math.ceil((MIN_SECONDS * 1000 - dwellMs(id)) / 1000); }
243
+ function waitText(id) { return fill(DATA.labels.wait, { s: waitSeconds(id) }); }
244
+ // The seconds stored with an item: those of its first save, kept on every later write.
245
+ function secondsOf(id) {
246
+ var d = state.answers[id];
247
+ if (d && typeof d.seconds === "number") return d.seconds;
248
+ if (state.firstSeconds[id] === undefined) state.firstSeconds[id] = Math.floor(dwellMs(id) / 1000);
249
+ return state.firstSeconds[id];
250
+ }
251
+ // An observer reports only when a card crosses one of its thresholds, so each card gets its own
252
+ // observer whose threshold is that card's share: half the card, or half the viewport for a card
253
+ // taller than it. The share follows the card's height and the viewport's: a card that changes
254
+ // size, or a resized window, is watched again with its new share.
255
+ var observers = [];
256
+ var sizes = null;
257
+ function share(h) { return h > 0 ? Math.min(h, window.innerHeight) / 2 / h : 0.5; }
258
+ function onScreen(entry) {
259
+ var h = entry.boundingClientRect.height;
260
+ return entry.isIntersecting && h > 0 &&
261
+ entry.intersectionRect.height >= Math.min(h, window.innerHeight) / 2 - 0.5;
262
+ }
263
+ function watchCard(card) {
264
+ var id = card.getAttribute("data-id");
265
+ var o = new IntersectionObserver(function (entries) {
266
+ entries.forEach(function (e) {
267
+ if (onScreen(e) && document.visibilityState !== "hidden") startClock(id);
268
+ else stopClock(id);
269
+ });
270
+ }, { threshold: [0, share(card.getBoundingClientRect().height)] });
271
+ o.observe(card);
272
+ return o;
273
+ }
274
+ // Every render draws new cards: the clocks stop, and the new cards are watched from scratch.
275
+ function watchCards() {
276
+ observers.forEach(function (o) { o.disconnect(); });
277
+ observers = [];
278
+ if (sizes) sizes.disconnect();
279
+ sizes = null;
280
+ var cards = Array.prototype.slice.call(document.querySelectorAll("article.item[data-id]"));
281
+ if (typeof IntersectionObserver !== "function") {
282
+ // No way to tell what is in view: a card counts as on screen from the moment it is drawn.
283
+ cards.forEach(function (c) { startClock(c.getAttribute("data-id")); });
284
+ return;
285
+ }
286
+ observers = cards.map(watchCard);
287
+ if (typeof ResizeObserver !== "function") return;
288
+ var heights = cards.map(function (c) { return c.getBoundingClientRect().height; });
289
+ sizes = new ResizeObserver(function (entries) {
290
+ entries.forEach(function (e) {
291
+ var i = cards.indexOf(e.target);
292
+ var h = e.target.getBoundingClientRect().height;
293
+ if (i === -1 || Math.abs(h - heights[i]) < 1) return;
294
+ heights[i] = h;
295
+ observers[i].disconnect();
296
+ observers[i] = watchCard(cards[i]);
297
+ });
298
+ });
299
+ cards.forEach(function (c) { sizes.observe(c); });
300
+ }
301
+ // The countdown on each locked card, and its buttons let go when its time is up; no render, so
302
+ // a section the labeller opened stays open.
303
+ function tick() {
304
+ Object.keys(state.gates).forEach(function (id) {
305
+ var g = state.gates[id];
306
+ if (locked(id)) {
307
+ g.note.textContent = waitText(id);
308
+ g.note.setAttribute("data-test-meta", JSON.stringify({ id: id, seconds: waitSeconds(id) }));
309
+ return;
310
+ }
311
+ if (g.note.parentNode) g.note.parentNode.removeChild(g.note);
312
+ g.buttons.forEach(function (b) { b.disabled = !canWrite(); });
313
+ delete state.gates[id];
314
+ });
315
+ setTimeout(tick, 250);
316
+ }
139
317
 
140
318
  // ── what "done" means ────────────────────────────────────────────────────────────────────
141
319
  function answersOf(id) {
@@ -184,7 +362,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
184
362
  var d = state.mine["group:" + g] || state.groups[g];
185
363
  return d && valid(DATA.allowed.familiarity, d.familiarity) ? d.familiarity : undefined;
186
364
  }
187
- function groupItems(g) { return DATA.items.filter(function (i) { return i.group === g; }); }
365
+ function groupItems(g) { return itemsShown().filter(function (i) { return i.group === g; }); }
188
366
  // ── the practice (rule 8): known answers, before the real items ──────────────────────────
189
367
  function practiceDoc(pid) { return "r" + DATA.practice.round + "-" + pid; }
190
368
  function practiceAnswers(pid) {
@@ -209,7 +387,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
209
387
  function write(key, coll, docId, body) {
210
388
  state.inflight[key] = (state.inflight[key] || 0) + 1;
211
389
  delete state.failed[key];
212
- var step = function () { return db.collection(coll).doc(docId).set(body); };
390
+ var step = function () { return db.collection(coll).doc(docName(docId)).set(body); };
213
391
  var run = (chains[key] || Promise.resolve()).then(step, step);
214
392
  chains[key] = run.then(function () {
215
393
  state.inflight[key] -= 1;
@@ -223,7 +401,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
223
401
  });
224
402
  }
225
403
  function canWrite() {
226
- return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok";
404
+ return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok" &&
405
+ (!ASKS_WHO || state.who !== null);
227
406
  }
228
407
  // An answer that asks why is not written while its reason is empty (#380): it waits on the page, and
229
408
  // the store keeps what it had, so an export never meets an answer without the reason it needs. Every
@@ -256,11 +435,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
256
435
  else delete state.pending[id];
257
436
  delete state.refusedWhy[id];
258
437
  state.mine[id] = ready;
259
- write(id, "answers", id, { answers: ready, reasons: reasons, savedAt: new Date().toISOString(), source: "page" });
438
+ var body = { answers: ready, reasons: reasons, savedAt: new Date().toISOString(), source: "page" };
439
+ if (MIN_SECONDS) body.seconds = secondsOf(id);
440
+ write(id, "answers", id, body);
260
441
  render();
261
442
  }
262
443
  function answer(id, qid, value) {
263
- if (!canWrite()) return;
444
+ if (!canWrite() || locked(id)) return;
264
445
  var next = Object.assign({}, answersOf(id));
265
446
  next[qid] = value;
266
447
  state.lastAnswered = id;
@@ -318,6 +499,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
318
499
  if (text !== undefined) n.textContent = text;
319
500
  return n;
320
501
  }
502
+ // The hook a test finds an element by (`data-test`) and the values it reads from it (`data-test-meta`,
503
+ // JSON): classes are for the look only, and a test asserts no fact by reading text.
504
+ function mark(n, kind, meta) {
505
+ n.setAttribute("data-test", kind);
506
+ n.setAttribute("data-test-meta", JSON.stringify(meta || {}));
507
+ return n;
508
+ }
321
509
  // Text with `backticks` shown as code spans; everything stays textContent, so nothing is parsed as HTML.
322
510
  function rich(tag, cls, text) {
323
511
  var n = el(tag, cls);
@@ -344,12 +532,14 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
344
532
  });
345
533
  return pre;
346
534
  }
347
- function options(opts, current, onPick, disabled, withExamples) {
535
+ // `notes`: the card's sub-line per answer (its `answer_notes` for the question), what the answer means here.
536
+ function options(opts, current, onPick, disabled, withExamples, notes, qid) {
348
537
  var row = el("div", "opts");
349
538
  opts.forEach(function (o) {
350
- var b = el("button", "");
539
+ var b = mark(el("button", ""), "answer", { question: qid, value: o.value });
351
540
  if (o.icon) b.appendChild(el("span", "icon", o.icon));
352
541
  b.appendChild(el("span", "label", o.label));
542
+ if (notes && notes[o.value]) b.appendChild(mark(rich("span", "sub", notes[o.value]), "answer-sub", { question: qid, value: o.value }));
353
543
  if (withExamples && o.example) b.appendChild(rich("span", "ex", DATA.labels.practice_example + o.example));
354
544
  b.type = "button";
355
545
  b.setAttribute("aria-pressed", String(current === o.value));
@@ -364,20 +554,28 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
364
554
  return isNaN(d) ? "" : d.toLocaleTimeString();
365
555
  }
366
556
  function statusLine(key, missing, savedAt) {
367
- var s = el("p", "status");
557
+ var s = mark(el("p", "status"), "status", { state: "idle", missing: missing.map(function (q) { return q.id; }) });
558
+ var as = function (st) {
559
+ s.setAttribute("data-test-meta", JSON.stringify({ state: st, missing: missing.map(function (q) { return q.id; }) }));
560
+ };
368
561
  if (state.pending[key] || state.refusedWhy[key]) {
562
+ as("needs-reason");
369
563
  s.className = "status missing";
370
564
  s.textContent = "Not saved yet: this answer needs a reason. Write it above, or pick another answer.";
371
565
  } else if (state.failed[key]) {
566
+ as("failed");
372
567
  s.className = "status failed";
373
568
  s.textContent = "Not saved (" + state.failed[key] + "). Tap the answer again.";
374
569
  } else if (state.inflight[key]) {
570
+ as("saving");
375
571
  s.textContent = "Saving…";
376
572
  } else if (missing.length) {
573
+ as("missing");
377
574
  s.className = "status missing";
378
575
  s.textContent = "Still needs: " + missing.map(function (q) { return q.short || q.text; }).join("; ") +
379
576
  (savedAt ? ". Saved " + timeOf(savedAt) : "");
380
577
  } else if (savedAt) {
578
+ as("saved");
381
579
  s.className = "status saved";
382
580
  s.textContent = "Saved " + timeOf(savedAt);
383
581
  }
@@ -385,13 +583,22 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
385
583
  }
386
584
  function itemCard(item, off) {
387
585
  var card = el("article", "item");
388
- card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: DATA.items.indexOf(item) + 1, total: DATA.items.length })));
389
- evidence(card, item);
586
+ var shown = itemsShown();
587
+ if (MIN_SECONDS && unsaved(item.id)) card.setAttribute("data-id", item.id);
588
+ mark(card, "item", { id: item.id, index: shown.indexOf(item) + 1, total: shown.length });
589
+ card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: shown.indexOf(item) + 1, total: shown.length })));
590
+ var ctx = termsOf(item);
591
+ evidence(card, item, ctx);
390
592
  var a = answersOf(item.id);
391
- askOne(card, a, state.editing[item.id], function (q, v) {
593
+ var gate = locked(item.id);
594
+ var ask = askOne(card, a, state.editing[item.id], ctx, function (q, v) {
392
595
  delete state.editing[item.id];
393
596
  answer(item.id, q.id, v);
394
- }, function (q) { state.editing[item.id] = q.id; render(); }, off, false, item.situation);
597
+ }, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item);
598
+ if (gate && ask) {
599
+ var note = ask.appendChild(mark(el("p", "gate", waitText(item.id)), "wait", { id: item.id, seconds: waitSeconds(item.id) }));
600
+ state.gates[item.id] = { note: note, buttons: Array.prototype.slice.call(ask.querySelectorAll(".opts button")) };
601
+ }
395
602
  DATA.questions.forEach(function (q) {
396
603
  var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
397
604
  if (!o) return;
@@ -477,28 +684,194 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
477
684
  function fill(text, vars) {
478
685
  return String(text).replace(/\{(\w+)\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
479
686
  }
480
- // A code excerpt: its file (a link when there is one), its lines numbered, the marked line with a
481
- // gutter marker and a label besides its background, and the caption under it.
482
- function excerptBlock(ex) {
687
+ // One row of a numbered excerpt: the number (a marker before the marked one), the gutter value when
688
+ // the excerpt has that column, and the code.
689
+ function codeRow(cls, n, gutter, withGutter, text) {
690
+ var line = el("div", "line" + cls);
691
+ line.appendChild(el("span", "n", n));
692
+ if (withGutter) line.appendChild(mark(el("span", "c", gutter || ""), "gutter"));
693
+ return { row: line, code: line.appendChild(el("span", "t", text)) };
694
+ }
695
+ // A code excerpt: its file (a link when there is one) and language, its lines numbered, a ⋮ row for
696
+ // every range it leaves out, the extra gutter column under its header, the marked line lit with the
697
+ // annotation under it (or a label after its code), and the caption under the code.
698
+ function excerptBlock(ex, ctx) {
483
699
  var box = el("div", "rulebox");
484
700
  if (ex.file) {
485
- var name = el("p");
701
+ var name = el("p", "path");
702
+ mark(name, "excerpt-path", { file: ex.file, lang: ex.lang || "" });
486
703
  name.appendChild(ex.url ? link(ex.file, ex.url) : el("code", "", ex.file));
704
+ if (ex.lang) name.appendChild(el("span", "", " \u00b7 " + ex.lang));
487
705
  box.appendChild(name);
488
706
  }
489
- var pre = el("pre", "numbered");
490
- ex.lines.forEach(function (l) {
491
- var line = el("div", "line" + (l.mark ? " hit" : ""));
492
- line.appendChild(el("span", "g", l.mark ? "\u25b6" : ""));
493
- line.appendChild(el("span", "n", String(l.n)));
494
- var code = line.appendChild(el("span", "", l.text));
495
- if (l.mark) code.appendChild(el("span", "this", ex.mark_label || "\u25c0 this line"));
496
- pre.appendChild(line);
707
+ var withGutter = !!ex.gutter_header || ex.lines.some(function (l) { return l.gutter; });
708
+ var pre = el("pre", "numbered" + (withGutter ? " gutter" : ""));
709
+ if (ex.gutter_header) pre.appendChild(mark(codeRow(" hdr", "", ex.gutter_header, true, "").row, "code-header", { gutter: ex.gutter_header }));
710
+ var annotated = false;
711
+ ex.lines.forEach(function (l, i) {
712
+ var prev = ex.lines[i - 1];
713
+ if (prev && l.n > prev.n + 1) {
714
+ var from = prev.n + 1, to = l.n - 1;
715
+ pre.appendChild(mark(codeRow(" gap", "\u22ee", "", withGutter,
716
+ fill(from === to ? DATA.labels.hidden_one : DATA.labels.hidden, { from: from, to: to })).row, "gap", { from: from, to: to }));
717
+ }
718
+ var r = codeRow((l.mark ? " hit" : "") + (l.context ? " ctx" : ""), (l.mark ? "\u25b6" : "") + l.n,
719
+ l.gutter, withGutter, l.text);
720
+ mark(r.row, "code-line", { line: l.n, marked: !!l.mark, gutter: l.gutter || "", context: !!l.context });
721
+ pre.appendChild(r.row);
722
+ if (!l.mark) return;
723
+ if (ex.annotation && !annotated) {
724
+ annotated = true;
725
+ var anno = pre.appendChild(mark(el("div", "anno"), "annotation", { line: l.n }));
726
+ anno.setAttribute("role", "note");
727
+ anno.appendChild(el("span", "anno-arrow"));
728
+ termed(anno, ex.annotation, ctx, "annotation");
729
+ } else if (!ex.annotation) r.code.appendChild(el("span", "this", ex.mark_label || "\u25c0 this line"));
497
730
  });
498
731
  box.appendChild(pre);
499
732
  if (ex.caption) box.appendChild(rich("p", "said", ex.caption));
500
733
  return box;
501
734
  }
735
+ // ── terms with a note (#392): a dotted term opens ONE floating note, anchored to it ─────────────
736
+ // `ctx` is the card's terms, the text each opens from, and which are drawn already. A term opens from
737
+ // its first occurrence in the quote, else its source line, else the annotation: the words asked
738
+ // about come first. A text is split at backticks before it is searched, as the build searches it.
739
+ var tips = {}; // "<card id>:<k>" -> the note entries of the term drawn under that key
740
+ function termsOf(card) {
741
+ var texts = [["quote", card.quote && card.quote.text], ["source", card.quote && card.quote.source],
742
+ ["annotation", card.excerpt && card.excerpt.annotation]];
743
+ var home = (card.terms || []).map(function (t) {
744
+ var at = texts.filter(function (x) {
745
+ return x[1] && String(x[1]).split("`").some(function (part) { return part.indexOf(t.term) !== -1; });
746
+ })[0];
747
+ return at ? at[0] : null;
748
+ });
749
+ return { card: card.id, terms: card.terms || [], home: home, used: {} };
750
+ }
751
+ function pieces(text, ctx, where) {
752
+ var best = null;
753
+ ctx.terms.forEach(function (t, k) {
754
+ var at = ctx.used[k] || ctx.home[k] !== where ? -1 : text.indexOf(t.term);
755
+ if (at !== -1 && (!best || at < best.at)) best = { at: at, k: k };
756
+ });
757
+ if (!best) return text ? [{ text: text }] : [];
758
+ ctx.used[best.k] = true;
759
+ var end = best.at + ctx.terms[best.k].term.length;
760
+ return (best.at ? [{ text: text.slice(0, best.at) }] : [])
761
+ .concat([{ text: text.slice(best.at, end), k: best.k }], pieces(text.slice(end), ctx, where));
762
+ }
763
+ // `text` into `node`, `backticks` as code, each term of `ctx` whose home is `where` a button that
764
+ // opens its note.
765
+ function termed(node, text, ctx, where) {
766
+ String(text).split("`").forEach(function (part, i) {
767
+ pieces(part, ctx, where).forEach(function (p) {
768
+ var inner = el(i % 2 ? "code" : "span", "", p.text);
769
+ if (p.k === undefined) { node.appendChild(inner); return; }
770
+ var key = ctx.card + ":" + p.k;
771
+ tips[key] = ctx.terms[p.k].note;
772
+ var b = mark(el("button", "term"), "term", { term: ctx.terms[p.k].term });
773
+ b.type = "button";
774
+ b.setAttribute("data-tip", key);
775
+ b.setAttribute("aria-expanded", "false");
776
+ b.setAttribute("aria-describedby", "tip");
777
+ b.appendChild(inner);
778
+ b.addEventListener("click", function () { tapTerm(b); });
779
+ b.addEventListener("mouseenter", function () { if (HOVER && b !== tipAt) { openTip(b); hovered = true; } });
780
+ node.appendChild(b);
781
+ });
782
+ });
783
+ return node;
784
+ }
785
+ var HOVER = !!(window.matchMedia && window.matchMedia("(hover: hover)").matches);
786
+ var tip = null, tipAt = null, hovered = false, tipTerm = "", leaving = null;
787
+ // The grace delay; a test sets `window.__tipGraceMs` to slow or shorten it.
788
+ function graceMs() { return window.__tipGraceMs || 250; }
789
+ function stayTip() { if (leaving !== null) { clearTimeout(leaving); leaving = null; } }
790
+ function tipMeta(open) { mark(tip, "tip", { term: tipTerm, open: open }); }
791
+ function closeTip() {
792
+ stayTip();
793
+ if (tipAt) tipAt.setAttribute("aria-expanded", "false");
794
+ tipAt = null;
795
+ hovered = false;
796
+ if (tip) { tip.hidden = true; tipMeta(false); }
797
+ }
798
+ // One entry: its title, its words, the quote as a quote, the example, then the link to the source;
799
+ // a snippet above its link. Everything is textContent.
800
+ function tipEntry(e) {
801
+ var out = [];
802
+ if (e.code) out.push(plainCode(e.code));
803
+ var p = el("p");
804
+ if (e.title) p.appendChild(el("b", "", e.title + ": "));
805
+ if (e.says) p.appendChild(mark(rich("span", "", e.says + " "), "tip-says"));
806
+ if (e.quote) p.appendChild(mark(el("q", "", e.quote), "tip-quote"));
807
+ if (e.example) p.appendChild(rich("span", "", " " + e.example));
808
+ p.appendChild(el("span", "", " "));
809
+ p.appendChild(mark(link(e.url_label, e.url), "tip-link", { url: e.url }));
810
+ return out.concat([p]);
811
+ }
812
+ // Below the term if it fits, else above; inside the viewport sideways; the arrow points at the term.
813
+ function openTip(b) {
814
+ closeTip();
815
+ if (!tip) {
816
+ tip = document.body.appendChild(el("div"));
817
+ tip.id = "tip";
818
+ tip.setAttribute("role", "tooltip");
819
+ }
820
+ tipAt = b;
821
+ tipTerm = JSON.parse(b.getAttribute("data-test-meta")).term;
822
+ b.setAttribute("aria-expanded", "true");
823
+ var note = tips[b.getAttribute("data-tip")];
824
+ tip.textContent = "";
825
+ note.forEach(function (e) { tipEntry(e).forEach(function (n) { tip.appendChild(n); }); });
826
+ var arrow = tip.appendChild(el("span", "tip-arrow"));
827
+ tip.className = note.some(function (e) { return e.code; }) ? "snip" : "";
828
+ tip.hidden = false;
829
+ tipMeta(true);
830
+ var r = b.getBoundingClientRect(), vw = document.documentElement.clientWidth;
831
+ var w = tip.offsetWidth, h = tip.offsetHeight;
832
+ var x = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), vw - w - 8);
833
+ var below = r.bottom + h + 12 < window.innerHeight || r.top < h + 12;
834
+ tip.className += below ? " below" : " above";
835
+ tip.style.left = (x + window.scrollX) + "px";
836
+ tip.style.top = ((below ? r.bottom + 8 : r.top - h - 8) + window.scrollY) + "px";
837
+ arrow.style.left = Math.min(Math.max(10, r.left + r.width / 2 - x - 5), w - 20) + "px";
838
+ }
839
+ // A tap opens the term's note, or closes it when it is open; a tap on a note opened by hover keeps it.
840
+ function tapTerm(b) {
841
+ if (b !== tipAt) openTip(b);
842
+ else if (hovered) hovered = false;
843
+ else closeTip();
844
+ }
845
+ // A tap outside the note and its term only closes the note: it never reaches the answer under it.
846
+ document.addEventListener("click", function (e) {
847
+ if (!tipAt || !e.target.closest || e.target.closest("button.term, #tip")) return;
848
+ closeTip();
849
+ e.preventDefault();
850
+ e.stopPropagation();
851
+ }, true);
852
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeTip(); });
853
+ // A note opened by hover closes a moment after the pointer leaves its term and the note: the gap
854
+ // between them must be crossable. Entering the term or the note again keeps it.
855
+ document.addEventListener("mouseover", function (e) {
856
+ if (!hovered || !tipAt || !e.target.closest) return;
857
+ if (e.target.closest("button.term, #tip")) stayTip();
858
+ else if (leaving === null) leaving = setTimeout(function () { leaving = null; if (hovered) closeTip(); }, graceMs());
859
+ });
860
+ // After a render the note follows its term's new button, or closes when the term is gone.
861
+ function reanchorTip() {
862
+ if (!tipAt) return;
863
+ var key = tipAt.getAttribute("data-tip"), wasHover = hovered;
864
+ var again = app.querySelector('button.term[data-tip="' + key + '"]');
865
+ if (again) { openTip(again); hovered = wasHover; } else closeTip();
866
+ }
867
+ // The words a card asks about, as a quote, and their source as a small line under them.
868
+ function quoteBlock(item, ctx) {
869
+ var box = el("div", "rulebox");
870
+ var bq = box.appendChild(mark(el("blockquote", "quote"), "quote"));
871
+ termed(bq.appendChild(el("q")), item.quote.text, ctx, "quote");
872
+ if (item.quote.source) termed(box.appendChild(mark(el("p", "src"), "quote-source")), item.quote.source, ctx, "source");
873
+ return box;
874
+ }
502
875
  // A reference entry: its title, then each field (a paragraph, a code block on a labelled row, or a
503
876
  // quiet note), then its link.
504
877
  function referenceBlock(r) {
@@ -512,27 +885,29 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
512
885
  if (r.url) box.appendChild(el("p")).appendChild(link(r.url_label || r.url, r.url));
513
886
  return box;
514
887
  }
515
- function folded(summary) {
516
- var d = el("details");
517
- d.appendChild(el("summary", "", summary));
888
+ // A block of a card's reference material, in place under its heading: nothing a card needs is
889
+ // folded away (#392).
890
+ function shownBlock(summary) {
891
+ var d = el("div", "rulebox");
892
+ d.appendChild(el("h3", "", summary));
518
893
  return d;
519
894
  }
520
895
  function references(x) {
521
- var d = folded(x.summary);
896
+ var d = shownBlock(x.summary);
522
897
  x.entries.forEach(function (r) { d.appendChild(row(r.label || "", referenceBlock(r))); });
523
898
  return d;
524
899
  }
525
900
  function moreList(m) {
526
- var d = folded(m.summary);
901
+ var d = shownBlock(m.summary);
527
902
  var ul = el("ul");
528
903
  m.items.forEach(function (f) { ul.appendChild(rich("li", "", f)); });
529
904
  if (m.rest) ul.appendChild(el("li", "muted", m.rest));
530
905
  d.appendChild(ul);
531
906
  return d;
532
907
  }
533
- // A folded table: its note, then each row in the order given: label, value as code, plain words, mark.
908
+ // A table: its note, then each row in the order given: label, value as code, plain words, mark.
534
909
  function table(t) {
535
- var d = folded(t.summary);
910
+ var d = shownBlock(t.summary);
536
911
  if (t.note) d.appendChild(el("p", "muted", t.note));
537
912
  t.rows.forEach(function (r) {
538
913
  var s = el("div", "setting");
@@ -544,18 +919,20 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
544
919
  });
545
920
  return d;
546
921
  }
547
- // Below the question and its answers: the references, the list of more like it and the table, each
548
- // folded, so the question sits right under the code it names.
922
+ // Below the question and its answers: the references, the list of more like it and the table, in
923
+ // place, so the question sits right under the code it names.
549
924
  function blocksBelow(card, item) {
550
925
  if (item.references) card.appendChild(references(item.references));
551
926
  if (item.more) card.appendChild(moreList(item.more));
552
927
  if (item.table) card.appendChild(table(item.table));
553
928
  }
554
929
  // What a card shows above its question, one labelled row per field (an item and a practice item alike).
555
- function evidence(card, item) {
930
+ function evidence(card, item, ctx) {
556
931
  if (item.title) card.appendChild(rich("h3", "", item.title));
557
- var body = rich("p", "body", item.body);
558
- card.appendChild(DATA.labels.body ? row(DATA.labels.body, body) : body);
932
+ if (item.body !== undefined) {
933
+ var body = rich("p", "body", item.body);
934
+ card.appendChild(DATA.labels.body ? row(DATA.labels.body, body) : body);
935
+ }
559
936
  if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(DATA.labels.repeat, item.repeat)));
560
937
  if (item.code) card.appendChild(codeBlock(item.code));
561
938
  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,
@@ -570,8 +947,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
570
947
  }
571
948
  if (item.file) card.appendChild(row("File", rich("p", "", item.file)));
572
949
  if (item.context) card.appendChild(rich("p", "muted", item.context));
573
- if (item.situation) card.appendChild(situated("p", "situation", item.situation.summary, item.situation.line));
574
- if (item.excerpt) card.appendChild(excerptBlock(item.excerpt));
950
+ if (item.situation && item.situation.summary) card.appendChild(situated("p", "situation", item.situation.summary, item.situation.line));
951
+ if (item.excerpt) card.appendChild(excerptBlock(item.excerpt, ctx));
575
952
  }
576
953
  function labelOf(q, v) {
577
954
  var o = q.options.filter(function (x) { return x.value === v; })[0];
@@ -579,9 +956,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
579
956
  }
580
957
  // One question per screen: the answered ones stay as a line (with "Change"), and only one question
581
958
  // is asked: the one being changed, else the first unanswered.
582
- function askOne(card, a, editing, onPick, onChange, off, withExamples, situation) {
959
+ // A card's quote sits at the top of its question box, or, with nothing left to ask, in the card itself.
960
+ function askOne(card, a, editing, ctx, onPick, onChange, off, withExamples, item) {
583
961
  var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
584
962
  var asked = DATA.questions.filter(function (q) { return q.id === editing; })[0] || open[0];
963
+ var situation = item.situation, notes = item.answer_notes || {};
964
+ var quote = item.quote ? quoteBlock(item, ctx) : null;
965
+ if (quote && !asked) card.appendChild(quote);
585
966
  DATA.questions.forEach(function (q) {
586
967
  if (q === asked || !valid(DATA.allowed.questions[q.id], a[q.id])) return;
587
968
  var line = el("p", "answered", (q.short || q.text) + ": " + labelOf(q, a[q.id]) + " ");
@@ -594,12 +975,15 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
594
975
  }
595
976
  card.appendChild(line);
596
977
  });
597
- if (!asked) return;
978
+ if (!asked) return null;
598
979
  var ask = el("div", "ask");
980
+ if (quote) ask.appendChild(quote);
599
981
  // A card's situation asks the page's first question in its own words; the others keep theirs.
600
- ask.appendChild(situation && asked === DATA.questions[0] ? situated("p", "q", situation.question, situation.line) : rich("p", "q", asked.text));
601
- ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples));
982
+ ask.appendChild(mark(situation && asked === DATA.questions[0] ? situated("p", "q", situation.question, situation.line) : rich("p", "q", asked.text),
983
+ "question", { question: asked.id }));
984
+ ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples, notes[asked.id], asked.id));
602
985
  card.appendChild(ask);
986
+ return ask;
603
987
  }
604
988
  // Open until the practice is done; then folded into a toggle, so the cards start near the top.
605
989
  function howTo() {
@@ -624,11 +1008,12 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
624
1008
  return sec;
625
1009
  }
626
1010
  function practiceCard(p, off) {
627
- var card = el("article", "item");
1011
+ var card = mark(el("article", "item"), "practice-item", { id: p.id });
628
1012
  card.appendChild(el("div", "id", "Practice " + p.id));
629
- evidence(card, p);
1013
+ var ctx = termsOf(p);
1014
+ evidence(card, p, ctx);
630
1015
  var a = practiceAnswers(p.id);
631
- askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p.situation);
1016
+ askOne(card, a, undefined, ctx, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p);
632
1017
  if (practiceAnswered(p)) {
633
1018
  var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
634
1019
  card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
@@ -706,14 +1091,45 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
706
1091
  // Under "Next unanswered" the save that completes an item would hide it, box and all, mid-sentence:
707
1092
  // the item being typed in stays on the page until the labeller leaves its box.
708
1093
  state.typingIn = typing ? typing.key.slice(0, typing.key.indexOf("|")) : null;
1094
+ Object.keys(state.seenSince).forEach(stopClock);
1095
+ state.gates = {};
1096
+ tips = {};
709
1097
  paint();
1098
+ reanchorTip();
1099
+ if (MIN_SECONDS) watchCards();
710
1100
  var again = typing && state.whyBoxes[typing.key];
711
1101
  if (again) { again.focus(); again.setSelectionRange(typing.start, typing.end); }
712
1102
  }
1103
+ // The answerer's choice, before anything else of the page.
1104
+ function whoBlock() {
1105
+ var sec = el("section");
1106
+ sec.appendChild(el("h2", "", "Who is answering?"));
1107
+ sec.appendChild(el("p", "muted", "Each person answers under their own name and sees only their own answers."));
1108
+ var row = el("div", "opts");
1109
+ ANSWERERS.forEach(function (name) {
1110
+ var b = el("button", "", name);
1111
+ b.type = "button";
1112
+ b.addEventListener("click", function () { choose(name); });
1113
+ row.appendChild(b);
1114
+ });
1115
+ sec.appendChild(row);
1116
+ return sec;
1117
+ }
1118
+ function whoLine() {
1119
+ var line = el("p", "who");
1120
+ line.appendChild(el("span", "", "Answering as " + state.who));
1121
+ var b = el("button", "", "Switch");
1122
+ b.type = "button";
1123
+ b.disabled = busy();
1124
+ b.addEventListener("click", function () { choose(null); });
1125
+ line.appendChild(b);
1126
+ return line;
1127
+ }
713
1128
  function paint() {
714
1129
  var off = !canWrite();
715
- var done = DATA.items.filter(function (i) { return itemDone(i.id); }).length;
716
- var total = DATA.items.length;
1130
+ var items = itemsShown(), groups = groupsShown();
1131
+ var done = items.filter(function (i) { return itemDone(i.id); }).length;
1132
+ var total = items.length;
717
1133
  app.textContent = "";
718
1134
  var top = el("header", "top");
719
1135
  app.appendChild(top);
@@ -726,6 +1142,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
726
1142
  } else if (state.store === "loading" || !(state.loaded.answers && state.loaded.groups)) {
727
1143
  top.appendChild(el("p", "muted", "Loading saved answers…"));
728
1144
  }
1145
+ if (ASKS_WHO && !state.who) { app.appendChild(whoBlock()); return; }
1146
+ if (ASKS_WHO) top.appendChild(whoLine());
729
1147
  app.appendChild(howTo());
730
1148
  app.appendChild(practiceBlock(off));
731
1149
  // Without the store nothing can be answered, practice included: show the real items read-only (rule 7).
@@ -738,7 +1156,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
738
1156
  bar.appendChild(fill);
739
1157
  progress.appendChild(bar);
740
1158
  // Done means every item AND every group's familiarity, as «Next unanswered» counts it.
741
- var rated = DATA.groups.every(function (g) { return familiarity(g.id) !== undefined; });
1159
+ var rated = groups.every(function (g) { return familiarity(g.id) !== undefined; });
742
1160
  if (total && done === total && rated && state.filter === "all") {
743
1161
  progress.appendChild(el("p", "status saved", "All " + total + " items are complete. Tell the agent you are " +
744
1162
  "done; you can still change any answer."));
@@ -760,8 +1178,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
760
1178
 
761
1179
  var onlyOpen = state.filter === "next";
762
1180
  if (onlyOpen) {
763
- var rest = DATA.items.filter(function (i) { return !itemDone(i.id); });
764
- var open = DATA.groups.filter(function (g) { return familiarity(g.id) === undefined; });
1181
+ var rest = items.filter(function (i) { return !itemDone(i.id); });
1182
+ var open = groups.filter(function (g) { return familiarity(g.id) === undefined; });
765
1183
  var note = el("div", "muted");
766
1184
  if (!rest.length && !open.length) {
767
1185
  note.appendChild(el("p", "", "Nothing left. Every item and every group is answered."));
@@ -777,7 +1195,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
777
1195
  }
778
1196
  progress.appendChild(note);
779
1197
  }
780
- DATA.groups.forEach(function (g) {
1198
+ groups.forEach(function (g) {
781
1199
  var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn || i.id === state.lastAnswered; });
782
1200
  if (onlyOpen && groupDone(g.id) && !typingHere) return;
783
1201
  app.appendChild(groupBlock(g, off, onlyOpen));
@@ -789,16 +1207,24 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
789
1207
  return function (snap) {
790
1208
  var next = {};
791
1209
  snap.docs.forEach(function (d) { next[d.id] = d.data(); });
792
- state[target] = next;
1210
+ state.raw[target] = next;
1211
+ state[target] = mineOf(next);
793
1212
  state.loaded[loadedKey] = true;
794
1213
  render();
795
1214
  };
796
1215
  }
797
1216
  function broken() { state.store = "none"; render(); }
798
1217
 
1218
+ // Hidden: every clock stops. Back: the cards are watched afresh, because a change of tab alone
1219
+ // fires no intersection, and a card drawn while the tab was hidden never started its clock.
799
1220
  document.addEventListener("visibilitychange", function () {
800
- if (document.visibilityState !== "visible") flushReasons();
1221
+ if (document.visibilityState !== "visible") {
1222
+ flushReasons();
1223
+ Object.keys(state.seenSince).forEach(stopClock);
1224
+ } else if (MIN_SECONDS) watchCards();
801
1225
  });
1226
+ window.addEventListener("resize", function () { if (MIN_SECONDS) watchCards(); });
1227
+ if (MIN_SECONDS) tick();
802
1228
  window.addEventListener("pagehide", flushReasons);
803
1229
  render();
804
1230
  if (!window.claude || typeof window.claude.use !== "function") { broken(); return; }