paperlint 14.0.0 → 14.1.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,27 @@ 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); }
85
106
  </style>
86
107
  <main id="app"><p class="muted">Loading…</p></main>
87
108
  <script type="application/json" id="data">__DATA__</script>
@@ -100,6 +121,9 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
100
121
  whyTimers: {}, // "<item id>|<question id>" -> the pending save of a reason being typed
101
122
  whyBoxes: {}, // "<item id>|<question id>" -> the reason box of the last render
102
123
  typingIn: null, // the item whose reason box has the keyboard: shown even when complete
124
+ lastAnswered: null, // the item answered last: shown under "Next unanswered" until the next answer
125
+ pending: {}, // item id -> answers chosen whose reason is still empty: NOT in the store
126
+ refusedWhy: {}, // item id -> true when an empty reason was refused for its answer
103
127
  mine: {}, // item/group key -> the answers this page has just written (ahead of the store)
104
128
  inflight: {}, // item/group key -> writes not yet confirmed
105
129
  failed: {}, // item/group key -> error code of the last refused write
@@ -115,9 +139,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
115
139
 
116
140
  // ── what "done" means ────────────────────────────────────────────────────────────────────
117
141
  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 : {};
142
+ return state.pending[id] || savedOf(id);
121
143
  }
122
144
  // A stored value is an answer only if the build offers it (DATA.allowed, the same lists the
123
145
  // export reads from key.json): after a rebuild that removed an option, or a typo merged from chat,
@@ -141,6 +163,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
141
163
  return d[qid];
142
164
  }
143
165
  function needsWhy(q, v) { return (DATA.allowed.needs_why[q.id] || []).indexOf(v) !== -1; }
166
+ function questionOf(qid) { return DATA.questions.filter(function (q) { return q.id === qid; })[0]; }
144
167
  // What an item still needs: an answer, or the reason its answer asks for (named "<short> (why)").
145
168
  function missingFor(id) {
146
169
  var a = answersOf(id), r = reasonsOf(id);
@@ -202,25 +225,71 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
202
225
  function canWrite() {
203
226
  return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok";
204
227
  }
228
+ // An answer that asks why is not written while its reason is empty (#380): it waits on the page, and
229
+ // the store keeps what it had, so an export never meets an answer without the reason it needs. Every
230
+ // write leaves out each such answer, not only the one just picked: on a page of several questions the
231
+ // next answer would otherwise carry the waiting one into the store without its reason.
232
+ // A waiting answer that replaces a saved one leaves the saved one in the write until its reason comes.
233
+ // A stored answer to a question this page no longer asks (removed or renamed between builds) is kept
234
+ // as it was and never checked: the page has no question to check it against.
235
+ function resolved(answers, reasons, saved) {
236
+ var out = {}, waiting = false;
237
+ Object.keys(answers).forEach(function (q) {
238
+ var asked = questionOf(q);
239
+ if (!(asked && needsWhy(asked, answers[q]) && !(reasons[q] || "").trim())) out[q] = answers[q];
240
+ else {
241
+ waiting = true;
242
+ if (Object.prototype.hasOwnProperty.call(saved, q)) out[q] = saved[q];
243
+ }
244
+ });
245
+ return { ready: out, waiting: waiting };
246
+ }
247
+ // What the store holds for an item, or what this page last wrote to it ahead of the store.
248
+ function savedOf(id) {
249
+ if (state.mine[id]) return state.mine[id];
250
+ var d = state.answers[id];
251
+ return d && d.answers ? d.answers : {};
252
+ }
253
+ function keep(id, next, reasons) {
254
+ var r = resolved(next, reasons, savedOf(id)), ready = r.ready;
255
+ if (r.waiting) state.pending[id] = next;
256
+ else delete state.pending[id];
257
+ delete state.refusedWhy[id];
258
+ state.mine[id] = ready;
259
+ write(id, "answers", id, { answers: ready, reasons: reasons, savedAt: new Date().toISOString(), source: "page" });
260
+ render();
261
+ }
205
262
  function answer(id, qid, value) {
206
263
  if (!canWrite()) return;
207
264
  var next = Object.assign({}, answersOf(id));
208
265
  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();
266
+ state.lastAnswered = id;
267
+ var draft = draftOf(id, qid);
268
+ var why = draft !== undefined ? draft : reasonsOf(id)[qid] || "";
269
+ if (needsWhy(questionOf(qid), value) && !why.trim()) {
270
+ state.pending[id] = next;
271
+ render();
272
+ return;
273
+ }
274
+ var reasons = Object.assign({}, reasonsOf(id));
275
+ if (why.trim()) reasons[qid] = why;
276
+ keep(id, next, reasons);
212
277
  }
213
- // A reason saves when the labeller leaves the box (the text equivalent of a tap).
278
+ // A reason saves when the labeller leaves the box (the text equivalent of a tap); an empty one is refused.
214
279
  function setReason(id, qid, text) {
215
280
  clearTimeout(state.whyTimers[id + "|" + qid]);
216
281
  delete state.whyTimers[id + "|" + qid];
217
282
  if (!canWrite()) return;
283
+ var a = answersOf(id);
284
+ if (needsWhy(questionOf(qid), a[qid]) && !text.trim()) {
285
+ state.refusedWhy[id] = true;
286
+ render();
287
+ return;
288
+ }
218
289
  var why = Object.assign({}, reasonsOf(id));
219
290
  why[qid] = text;
220
291
  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();
292
+ keep(id, Object.assign({}, a), why);
224
293
  }
225
294
  // The first answer to a practice question is the one scored: it cannot be changed afterwards.
226
295
  function answerPractice(pid, qid, value) {
@@ -257,8 +326,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
257
326
  });
258
327
  return n;
259
328
  }
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.
329
+ // `withExamples`: the practice shows each answer's example on its button, after `labels.practice_example`;
330
+ // the real items keep the buttons short, the examples stay on the first screen.
262
331
  // A code block shows what a labeller would otherwise count by eye: leading and trailing whitespace as
263
332
  // faint marks (a space as a middle dot, a tab as an arrow) and each line's length in characters.
264
333
  function visible(ws) { return ws.replace(/ /g, "\u00b7").replace(/\t/g, "\u2192"); }
@@ -281,7 +350,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
281
350
  var b = el("button", "");
282
351
  if (o.icon) b.appendChild(el("span", "icon", o.icon));
283
352
  b.appendChild(el("span", "label", o.label));
284
- if (withExamples && o.example) b.appendChild(rich("span", "ex", o.example));
353
+ if (withExamples && o.example) b.appendChild(rich("span", "ex", DATA.labels.practice_example + o.example));
285
354
  b.type = "button";
286
355
  b.setAttribute("aria-pressed", String(current === o.value));
287
356
  b.disabled = disabled;
@@ -296,7 +365,10 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
296
365
  }
297
366
  function statusLine(key, missing, savedAt) {
298
367
  var s = el("p", "status");
299
- if (state.failed[key]) {
368
+ if (state.pending[key] || state.refusedWhy[key]) {
369
+ s.className = "status missing";
370
+ s.textContent = "Not saved yet: this answer needs a reason. Write it above, or pick another answer.";
371
+ } else if (state.failed[key]) {
300
372
  s.className = "status failed";
301
373
  s.textContent = "Not saved (" + state.failed[key] + "). Tap the answer again.";
302
374
  } else if (state.inflight[key]) {
@@ -312,15 +384,14 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
312
384
  return s;
313
385
  }
314
386
  function itemCard(item, off) {
315
- var done = itemDone(item.id);
316
387
  var card = el("article", "item");
317
- card.appendChild(el("div", "id", item.id));
388
+ card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: DATA.items.indexOf(item) + 1, total: DATA.items.length })));
318
389
  evidence(card, item);
319
390
  var a = answersOf(item.id);
320
391
  askOne(card, a, state.editing[item.id], function (q, v) {
321
392
  delete state.editing[item.id];
322
393
  answer(item.id, q.id, v);
323
- }, function (q) { state.editing[item.id] = q.id; render(); }, off);
394
+ }, function (q) { state.editing[item.id] = q.id; render(); }, off, false, item.situation);
324
395
  DATA.questions.forEach(function (q) {
325
396
  var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
326
397
  if (!o) return;
@@ -344,6 +415,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
344
415
  });
345
416
  var saved = state.answers[item.id] && state.answers[item.id].savedAt;
346
417
  card.appendChild(statusLine(item.id, missingFor(item.id), saved));
418
+ blocksBelow(card, item);
347
419
  return card;
348
420
  }
349
421
  // A labelled row: a short word, then what it labels.
@@ -361,17 +433,136 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
361
433
  p.appendChild(rich("span", "", u.text.slice(at + u.mark.length)));
362
434
  return p;
363
435
  }
436
+ // A situation's text with each `{line}` written out: «line N: » and the marked line's code as code
437
+ // (the build passes `line` whenever the text holds the token).
438
+ function situated(tag, cls, text, line) {
439
+ var n = el(tag, cls);
440
+ String(text).split("{line}").forEach(function (part, i) {
441
+ if (i) {
442
+ n.appendChild(el("span", "", "line " + line.n + ": "));
443
+ n.appendChild(el("code", "", line.text));
444
+ }
445
+ if (part) n.appendChild(rich("span", "", part));
446
+ });
447
+ return n;
448
+ }
449
+ // A long text shows its first three lines and a «more» that opens the rest.
450
+ function clamp(node, text) {
451
+ if (String(text).length < 200 && String(text).split("\n").length <= 3) return node;
452
+ var wrap = el("div", "rulebox");
453
+ node.className += " clamped";
454
+ var more = el("button", "more", "more");
455
+ more.type = "button";
456
+ more.addEventListener("click", function () {
457
+ node.className = node.className.replace(" clamped", "");
458
+ wrap.removeChild(more);
459
+ });
460
+ wrap.appendChild(node);
461
+ wrap.appendChild(more);
462
+ return wrap;
463
+ }
464
+ function link(text, url) {
465
+ var a = el("a", "", text);
466
+ a.href = url;
467
+ a.target = "_blank";
468
+ a.rel = "noopener";
469
+ return a;
470
+ }
471
+ function plainCode(text, cls) {
472
+ var pre = el("pre", cls || "");
473
+ pre.textContent = text;
474
+ return pre;
475
+ }
476
+ // `{name}` in a page label, from `vars`.
477
+ function fill(text, vars) {
478
+ return String(text).replace(/\{(\w+)\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
479
+ }
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) {
483
+ var box = el("div", "rulebox");
484
+ if (ex.file) {
485
+ var name = el("p");
486
+ name.appendChild(ex.url ? link(ex.file, ex.url) : el("code", "", ex.file));
487
+ box.appendChild(name);
488
+ }
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);
497
+ });
498
+ box.appendChild(pre);
499
+ if (ex.caption) box.appendChild(rich("p", "said", ex.caption));
500
+ return box;
501
+ }
502
+ // A reference entry: its title, then each field (a paragraph, a code block on a labelled row, or a
503
+ // quiet note), then its link.
504
+ function referenceBlock(r) {
505
+ var box = el("div", "rulebox");
506
+ box.appendChild(rich("p", "", r.title));
507
+ r.fields.forEach(function (f) {
508
+ if (f.style === "note") box.appendChild(el("p", "muted", f.text));
509
+ else if (f.style === "code") box.appendChild(row(f.label || "", clamp(plainCode(f.text), f.text)));
510
+ else box.appendChild(clamp(rich("p", "", (f.label ? f.label + ": " : "") + f.text), f.text));
511
+ });
512
+ if (r.url) box.appendChild(el("p")).appendChild(link(r.url_label || r.url, r.url));
513
+ return box;
514
+ }
515
+ function folded(summary) {
516
+ var d = el("details");
517
+ d.appendChild(el("summary", "", summary));
518
+ return d;
519
+ }
520
+ function references(x) {
521
+ var d = folded(x.summary);
522
+ x.entries.forEach(function (r) { d.appendChild(row(r.label || "", referenceBlock(r))); });
523
+ return d;
524
+ }
525
+ function moreList(m) {
526
+ var d = folded(m.summary);
527
+ var ul = el("ul");
528
+ m.items.forEach(function (f) { ul.appendChild(rich("li", "", f)); });
529
+ if (m.rest) ul.appendChild(el("li", "muted", m.rest));
530
+ d.appendChild(ul);
531
+ return d;
532
+ }
533
+ // A folded table: its note, then each row in the order given: label, value as code, plain words, mark.
534
+ function table(t) {
535
+ var d = folded(t.summary);
536
+ if (t.note) d.appendChild(el("p", "muted", t.note));
537
+ t.rows.forEach(function (r) {
538
+ var s = el("div", "setting");
539
+ s.appendChild(el("span", "lbl", r.label));
540
+ s.appendChild(el("p")).appendChild(el("code", "", r.value));
541
+ if (r.plain) s.appendChild(rich("p", "", r.plain));
542
+ if (r.mark) s.appendChild(el("p", "match " + r.mark.tone, r.mark.text));
543
+ d.appendChild(s);
544
+ });
545
+ return d;
546
+ }
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.
549
+ function blocksBelow(card, item) {
550
+ if (item.references) card.appendChild(references(item.references));
551
+ if (item.more) card.appendChild(moreList(item.more));
552
+ if (item.table) card.appendChild(table(item.table));
553
+ }
364
554
  // What a card shows above its question, one labelled row per field (an item and a practice item alike).
365
555
  function evidence(card, item) {
366
556
  if (item.title) card.appendChild(rich("h3", "", item.title));
367
- card.appendChild(rich("p", "body", item.body));
557
+ var body = rich("p", "body", item.body);
558
+ card.appendChild(DATA.labels.body ? row(DATA.labels.body, body) : body);
559
+ if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(DATA.labels.repeat, item.repeat)));
368
560
  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",
561
+ 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
562
  markedLine(item.unit)));
371
563
  // A rule the item names, with what it does in the linter's own words (looked up at build time).
372
564
  (item.rules || []).forEach(function (r) {
373
- card.appendChild(row("Rule", rich("p", "", r.linter + " `" + r.id + "`" + (r.family ? " (" + r.family + ")" : "") +
374
- ": " + r.description)));
565
+ card.appendChild(row("Rule", rich("p", "", r.linter + " `" + r.id + "`" + (r.family ? " (" + r.family + ")" : "") + ": " + r.description)));
375
566
  });
376
567
  if (item.readings) {
377
568
  card.appendChild(row("Reading A", rich("p", "", item.readings.a), "reading a"));
@@ -379,6 +570,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
379
570
  }
380
571
  if (item.file) card.appendChild(row("File", rich("p", "", item.file)));
381
572
  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));
382
575
  }
383
576
  function labelOf(q, v) {
384
577
  var o = q.options.filter(function (x) { return x.value === v; })[0];
@@ -386,7 +579,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
386
579
  }
387
580
  // One question per screen: the answered ones stay as a line (with "Change"), and only one question
388
581
  // is asked: the one being changed, else the first unanswered.
389
- function askOne(card, a, editing, onPick, onChange, off, withExamples) {
582
+ function askOne(card, a, editing, onPick, onChange, off, withExamples, situation) {
390
583
  var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
391
584
  var asked = DATA.questions.filter(function (q) { return q.id === editing; })[0] || open[0];
392
585
  DATA.questions.forEach(function (q) {
@@ -403,15 +596,22 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
403
596
  });
404
597
  if (!asked) return;
405
598
  var ask = el("div", "ask");
406
- ask.appendChild(el("p", "q", asked.text));
599
+ // 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));
407
601
  ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples));
408
602
  card.appendChild(ask);
409
603
  }
604
+ // Open until the practice is done; then folded into a toggle, so the cards start near the top.
410
605
  function howTo() {
411
606
  var sec = el("section");
412
- sec.appendChild(el("h2", "", "📖 How to answer"));
607
+ var body = sec;
608
+ if (practiceOpen()) sec.appendChild(el("h2", "", "📖 How to answer"));
609
+ else {
610
+ body = sec.appendChild(el("details"));
611
+ body.appendChild(el("summary", "", "📖 How to answer"));
612
+ }
413
613
  DATA.questions.forEach(function (q) {
414
- sec.appendChild(el("p", "q", q.text));
614
+ body.appendChild(el("p", "q", q.text));
415
615
  var list = el("div", "examples");
416
616
  q.options.forEach(function (o) {
417
617
  var row = el("div", "example");
@@ -419,7 +619,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
419
619
  row.appendChild(rich("span", "muted", "For example: " + o.example));
420
620
  list.appendChild(row);
421
621
  });
422
- sec.appendChild(list);
622
+ body.appendChild(list);
423
623
  });
424
624
  return sec;
425
625
  }
@@ -428,13 +628,14 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
428
628
  card.appendChild(el("div", "id", "Practice " + p.id));
429
629
  evidence(card, p);
430
630
  var a = practiceAnswers(p.id);
431
- askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true);
631
+ askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p.situation);
432
632
  if (practiceAnswered(p)) {
433
633
  var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
434
634
  card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
435
- (practiceMatched(p) ? "Matched. " : "Expected: " + expected + ". ") + p.why));
635
+ (practiceMatched(p) ? "Matched. " : "Not matched. Expected: " + expected + ". ") + p.why));
436
636
  }
437
637
  card.appendChild(statusLine("practice:" + p.id, [], null));
638
+ blocksBelow(card, p);
438
639
  return card;
439
640
  }
440
641
  function practiceBlock(off) {
@@ -470,7 +671,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
470
671
  sec.appendChild(fam);
471
672
  }
472
673
  groupItems(g.id).forEach(function (i) {
473
- if (onlyOpen && itemDone(i.id) && i.id !== state.typingIn) return;
674
+ if (onlyOpen && itemDone(i.id) && i.id !== state.typingIn && i.id !== state.lastAnswered) return;
474
675
  sec.appendChild(itemCard(i, off));
475
676
  });
476
677
  return sec;
@@ -536,6 +737,12 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
536
737
  fill.style.width = (total ? (100 * done / total) : 0) + "%";
537
738
  bar.appendChild(fill);
538
739
  progress.appendChild(bar);
740
+ // 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; });
742
+ if (total && done === total && rated && state.filter === "all") {
743
+ progress.appendChild(el("p", "status saved", "All " + total + " items are complete. Tell the agent you are " +
744
+ "done; you can still change any answer."));
745
+ }
539
746
 
540
747
  var filters = el("div", "tabs");
541
748
  [["all", "All items"], ["next", "Next unanswered"]].forEach(function (f) {
@@ -571,7 +778,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
571
778
  progress.appendChild(note);
572
779
  }
573
780
  DATA.groups.forEach(function (g) {
574
- var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn; });
781
+ var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn || i.id === state.lastAnswered; });
575
782
  if (onlyOpen && groupDone(g.id) && !typingHere) return;
576
783
  app.appendChild(groupBlock(g, off, onlyOpen));
577
784
  });