paperlint 14.2.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. */
@@ -201,9 +239,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
201
239
  // An item already saved (by this page or in the store) is never locked again.
202
240
  function unsaved(id) { return !state.answers[id] && state.firstSeconds[id] === undefined; }
203
241
  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
- }
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) }); }
207
244
  // The seconds stored with an item: those of its first save, kept on every later write.
208
245
  function secondsOf(id) {
209
246
  var d = state.answers[id];
@@ -266,8 +303,12 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
266
303
  function tick() {
267
304
  Object.keys(state.gates).forEach(function (id) {
268
305
  var g = state.gates[id];
269
- if (locked(id)) { g.note.textContent = waitText(id); return; }
270
- g.note.textContent = "";
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);
271
312
  g.buttons.forEach(function (b) { b.disabled = !canWrite(); });
272
313
  delete state.gates[id];
273
314
  });
@@ -458,6 +499,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
458
499
  if (text !== undefined) n.textContent = text;
459
500
  return n;
460
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
+ }
461
509
  // Text with `backticks` shown as code spans; everything stays textContent, so nothing is parsed as HTML.
462
510
  function rich(tag, cls, text) {
463
511
  var n = el(tag, cls);
@@ -484,12 +532,14 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
484
532
  });
485
533
  return pre;
486
534
  }
487
- 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) {
488
537
  var row = el("div", "opts");
489
538
  opts.forEach(function (o) {
490
- var b = el("button", "");
539
+ var b = mark(el("button", ""), "answer", { question: qid, value: o.value });
491
540
  if (o.icon) b.appendChild(el("span", "icon", o.icon));
492
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 }));
493
543
  if (withExamples && o.example) b.appendChild(rich("span", "ex", DATA.labels.practice_example + o.example));
494
544
  b.type = "button";
495
545
  b.setAttribute("aria-pressed", String(current === o.value));
@@ -504,20 +554,28 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
504
554
  return isNaN(d) ? "" : d.toLocaleTimeString();
505
555
  }
506
556
  function statusLine(key, missing, savedAt) {
507
- 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
+ };
508
561
  if (state.pending[key] || state.refusedWhy[key]) {
562
+ as("needs-reason");
509
563
  s.className = "status missing";
510
564
  s.textContent = "Not saved yet: this answer needs a reason. Write it above, or pick another answer.";
511
565
  } else if (state.failed[key]) {
566
+ as("failed");
512
567
  s.className = "status failed";
513
568
  s.textContent = "Not saved (" + state.failed[key] + "). Tap the answer again.";
514
569
  } else if (state.inflight[key]) {
570
+ as("saving");
515
571
  s.textContent = "Saving…";
516
572
  } else if (missing.length) {
573
+ as("missing");
517
574
  s.className = "status missing";
518
575
  s.textContent = "Still needs: " + missing.map(function (q) { return q.short || q.text; }).join("; ") +
519
576
  (savedAt ? ". Saved " + timeOf(savedAt) : "");
520
577
  } else if (savedAt) {
578
+ as("saved");
521
579
  s.className = "status saved";
522
580
  s.textContent = "Saved " + timeOf(savedAt);
523
581
  }
@@ -527,17 +585,19 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
527
585
  var card = el("article", "item");
528
586
  var shown = itemsShown();
529
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 });
530
589
  card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: shown.indexOf(item) + 1, total: shown.length })));
531
- evidence(card, item);
590
+ var ctx = termsOf(item);
591
+ evidence(card, item, ctx);
532
592
  var a = answersOf(item.id);
533
593
  var gate = locked(item.id);
534
- var ask = askOne(card, a, state.editing[item.id], function (q, v) {
594
+ var ask = askOne(card, a, state.editing[item.id], ctx, function (q, v) {
535
595
  delete state.editing[item.id];
536
596
  answer(item.id, q.id, v);
537
- }, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item.situation);
597
+ }, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item);
538
598
  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")) };
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")) };
541
601
  }
542
602
  DATA.questions.forEach(function (q) {
543
603
  var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
@@ -624,28 +684,194 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
624
684
  function fill(text, vars) {
625
685
  return String(text).replace(/\{(\w+)\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
626
686
  }
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) {
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) {
630
699
  var box = el("div", "rulebox");
631
700
  if (ex.file) {
632
- var name = el("p");
701
+ var name = el("p", "path");
702
+ mark(name, "excerpt-path", { file: ex.file, lang: ex.lang || "" });
633
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));
634
705
  box.appendChild(name);
635
706
  }
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);
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"));
644
730
  });
645
731
  box.appendChild(pre);
646
732
  if (ex.caption) box.appendChild(rich("p", "said", ex.caption));
647
733
  return box;
648
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
+ }
649
875
  // A reference entry: its title, then each field (a paragraph, a code block on a labelled row, or a
650
876
  // quiet note), then its link.
651
877
  function referenceBlock(r) {
@@ -659,27 +885,29 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
659
885
  if (r.url) box.appendChild(el("p")).appendChild(link(r.url_label || r.url, r.url));
660
886
  return box;
661
887
  }
662
- function folded(summary) {
663
- var d = el("details");
664
- 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));
665
893
  return d;
666
894
  }
667
895
  function references(x) {
668
- var d = folded(x.summary);
896
+ var d = shownBlock(x.summary);
669
897
  x.entries.forEach(function (r) { d.appendChild(row(r.label || "", referenceBlock(r))); });
670
898
  return d;
671
899
  }
672
900
  function moreList(m) {
673
- var d = folded(m.summary);
901
+ var d = shownBlock(m.summary);
674
902
  var ul = el("ul");
675
903
  m.items.forEach(function (f) { ul.appendChild(rich("li", "", f)); });
676
904
  if (m.rest) ul.appendChild(el("li", "muted", m.rest));
677
905
  d.appendChild(ul);
678
906
  return d;
679
907
  }
680
- // 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.
681
909
  function table(t) {
682
- var d = folded(t.summary);
910
+ var d = shownBlock(t.summary);
683
911
  if (t.note) d.appendChild(el("p", "muted", t.note));
684
912
  t.rows.forEach(function (r) {
685
913
  var s = el("div", "setting");
@@ -691,18 +919,20 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
691
919
  });
692
920
  return d;
693
921
  }
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.
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.
696
924
  function blocksBelow(card, item) {
697
925
  if (item.references) card.appendChild(references(item.references));
698
926
  if (item.more) card.appendChild(moreList(item.more));
699
927
  if (item.table) card.appendChild(table(item.table));
700
928
  }
701
929
  // What a card shows above its question, one labelled row per field (an item and a practice item alike).
702
- function evidence(card, item) {
930
+ function evidence(card, item, ctx) {
703
931
  if (item.title) card.appendChild(rich("h3", "", item.title));
704
- var body = rich("p", "body", item.body);
705
- 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
+ }
706
936
  if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(DATA.labels.repeat, item.repeat)));
707
937
  if (item.code) card.appendChild(codeBlock(item.code));
708
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,
@@ -717,8 +947,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
717
947
  }
718
948
  if (item.file) card.appendChild(row("File", rich("p", "", item.file)));
719
949
  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));
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));
722
952
  }
723
953
  function labelOf(q, v) {
724
954
  var o = q.options.filter(function (x) { return x.value === v; })[0];
@@ -726,9 +956,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
726
956
  }
727
957
  // One question per screen: the answered ones stay as a line (with "Change"), and only one question
728
958
  // is asked: the one being changed, else the first unanswered.
729
- 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) {
730
961
  var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
731
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);
732
966
  DATA.questions.forEach(function (q) {
733
967
  if (q === asked || !valid(DATA.allowed.questions[q.id], a[q.id])) return;
734
968
  var line = el("p", "answered", (q.short || q.text) + ": " + labelOf(q, a[q.id]) + " ");
@@ -743,9 +977,11 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
743
977
  });
744
978
  if (!asked) return null;
745
979
  var ask = el("div", "ask");
980
+ if (quote) ask.appendChild(quote);
746
981
  // 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));
748
- 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));
749
985
  card.appendChild(ask);
750
986
  return ask;
751
987
  }
@@ -772,11 +1008,12 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
772
1008
  return sec;
773
1009
  }
774
1010
  function practiceCard(p, off) {
775
- var card = el("article", "item");
1011
+ var card = mark(el("article", "item"), "practice-item", { id: p.id });
776
1012
  card.appendChild(el("div", "id", "Practice " + p.id));
777
- evidence(card, p);
1013
+ var ctx = termsOf(p);
1014
+ evidence(card, p, ctx);
778
1015
  var a = practiceAnswers(p.id);
779
- 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);
780
1017
  if (practiceAnswered(p)) {
781
1018
  var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
782
1019
  card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
@@ -856,7 +1093,9 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
856
1093
  state.typingIn = typing ? typing.key.slice(0, typing.key.indexOf("|")) : null;
857
1094
  Object.keys(state.seenSince).forEach(stopClock);
858
1095
  state.gates = {};
1096
+ tips = {};
859
1097
  paint();
1098
+ reanchorTip();
860
1099
  if (MIN_SECONDS) watchCards();
861
1100
  var again = typing && state.whyBoxes[typing.key];
862
1101
  if (again) { again.focus(); again.setSelectionRange(typing.start, typing.end); }