paperlint 14.2.0 → 14.4.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.
@@ -3,8 +3,8 @@
3
3
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Roboto+Mono&display=swap">
4
4
  <style>
5
5
  /* One look, dark whatever the viewer's theme (Material Ocean, no purple): every color is a token below.
6
- One column, sections apart by a hairline and wide space, items flat; the only raised block of an item is
7
- the question being asked. Answer buttons are full-width rows with space between them, so a thumb cannot
6
+ One column, sections apart by a hairline and wide space; each item a bordered panel with wide space after
7
+ it, and the only raised block inside it the question being asked. Answer buttons are full-width rows with space between them, so a thumb cannot
8
8
  land between two of them, and the chosen one has a 2px accent border. */
9
9
  :root {
10
10
  color-scheme: dark;
@@ -28,8 +28,9 @@ p { margin: 0; max-width: var(--measure); }
28
28
  .muted { color: var(--muted); }
29
29
  .id { font-family: var(--mono); color: var(--muted); }
30
30
  section { display: flex; flex-direction: column; gap: var(--gap-item); }
31
- .item { display: flex; flex-direction: column; gap: 20px; padding-top: var(--gap-item);
32
- border-top: 1px solid var(--line); }
31
+ .item { display: flex; flex-direction: column; gap: 20px; padding: var(--gap-item);
32
+ border: 1px solid var(--line); border-radius: 14px; }
33
+ .item + .item { margin-top: 24px; }
33
34
  /* The evidence of a card: one labelled row per field, the label a short word above its content. The two
34
35
  readings sit in the same place on every card: A on a solid accent rule, B on a dashed one. */
35
36
  .row { display: flex; flex-direction: column; gap: 4px; }
@@ -37,7 +38,6 @@ section { display: flex; flex-direction: column; gap: var(--gap-item); }
37
38
  .reading { border-left: 3px solid var(--accent); padding-left: 12px; }
38
39
  .reading.b { border-left: 3px dashed var(--text); }
39
40
  mark { background: transparent; color: var(--mark); font-weight: 500; }
40
- .item:first-of-type { border-top: 0; padding-top: 0; }
41
41
  .ask { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
42
42
  display: flex; flex-direction: column; gap: 12px; }
43
43
  .q { color: var(--strong); font-weight: 500; }
@@ -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,31 @@ 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…";
572
+ } else if (missing.length && !savedAt) {
573
+ // The open question and its save button already say what is missing: nothing to show.
574
+ as("missing");
575
+ s.hidden = true;
516
576
  } else if (missing.length) {
517
- s.className = "status missing";
518
- s.textContent = "Still needs: " + missing.map(function (q) { return q.short || q.text; }).join("; ") +
519
- (savedAt ? ". Saved " + timeOf(savedAt) : "");
577
+ as("missing");
578
+ s.className = "status saved";
579
+ s.textContent = "Saved " + timeOf(savedAt);
520
580
  } else if (savedAt) {
581
+ as("saved");
521
582
  s.className = "status saved";
522
583
  s.textContent = "Saved " + timeOf(savedAt);
523
584
  }
@@ -527,17 +588,19 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
527
588
  var card = el("article", "item");
528
589
  var shown = itemsShown();
529
590
  if (MIN_SECONDS && unsaved(item.id)) card.setAttribute("data-id", item.id);
591
+ mark(card, "item", { id: item.id, index: shown.indexOf(item) + 1, total: shown.length });
530
592
  card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: shown.indexOf(item) + 1, total: shown.length })));
531
- evidence(card, item);
593
+ var ctx = termsOf(item);
594
+ evidence(card, item, ctx);
532
595
  var a = answersOf(item.id);
533
596
  var gate = locked(item.id);
534
597
  var ask = askOne(card, a, state.editing[item.id], function (q, v) {
535
598
  delete state.editing[item.id];
536
599
  answer(item.id, q.id, v);
537
- }, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item.situation);
600
+ }, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item);
538
601
  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")) };
602
+ var note = ask.appendChild(mark(el("p", "gate", waitText(item.id)), "wait", { id: item.id, seconds: waitSeconds(item.id) }));
603
+ state.gates[item.id] = { note: note, buttons: Array.prototype.slice.call(ask.querySelectorAll(".opts button")) };
541
604
  }
542
605
  DATA.questions.forEach(function (q) {
543
606
  var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
@@ -624,28 +687,194 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
624
687
  function fill(text, vars) {
625
688
  return String(text).replace(/\{(\w+)\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
626
689
  }
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) {
690
+ // One row of a numbered excerpt: the number (a marker before the marked one), the gutter value when
691
+ // the excerpt has that column, and the code.
692
+ function codeRow(cls, n, gutter, withGutter, text) {
693
+ var line = el("div", "line" + cls);
694
+ line.appendChild(el("span", "n", n));
695
+ if (withGutter) line.appendChild(mark(el("span", "c", gutter || ""), "gutter"));
696
+ return { row: line, code: line.appendChild(el("span", "t", text)) };
697
+ }
698
+ // A code excerpt: its file (a link when there is one) and language, its lines numbered, a ⋮ row for
699
+ // every range it leaves out, the extra gutter column under its header, the marked line lit with the
700
+ // annotation under it (or a label after its code), and the caption under the code.
701
+ function excerptBlock(ex, ctx) {
630
702
  var box = el("div", "rulebox");
631
703
  if (ex.file) {
632
- var name = el("p");
704
+ var name = el("p", "path");
705
+ mark(name, "excerpt-path", { file: ex.file, lang: ex.lang || "" });
633
706
  name.appendChild(ex.url ? link(ex.file, ex.url) : el("code", "", ex.file));
707
+ if (ex.lang) name.appendChild(el("span", "", " \u00b7 " + ex.lang));
634
708
  box.appendChild(name);
635
709
  }
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);
710
+ var withGutter = !!ex.gutter_header || ex.lines.some(function (l) { return l.gutter; });
711
+ var pre = el("pre", "numbered" + (withGutter ? " gutter" : ""));
712
+ if (ex.gutter_header) pre.appendChild(mark(codeRow(" hdr", "", ex.gutter_header, true, "").row, "code-header", { gutter: ex.gutter_header }));
713
+ var annotated = false;
714
+ ex.lines.forEach(function (l, i) {
715
+ var prev = ex.lines[i - 1];
716
+ if (prev && l.n > prev.n + 1) {
717
+ var from = prev.n + 1, to = l.n - 1;
718
+ pre.appendChild(mark(codeRow(" gap", "\u22ee", "", withGutter,
719
+ fill(from === to ? DATA.labels.hidden_one : DATA.labels.hidden, { from: from, to: to })).row, "gap", { from: from, to: to }));
720
+ }
721
+ var r = codeRow((l.mark ? " hit" : "") + (l.context ? " ctx" : ""), (l.mark ? "\u25b6" : "") + l.n,
722
+ l.gutter, withGutter, l.text);
723
+ mark(r.row, "code-line", { line: l.n, marked: !!l.mark, gutter: l.gutter || "", context: !!l.context });
724
+ pre.appendChild(r.row);
725
+ if (!l.mark) return;
726
+ if (ex.annotation && !annotated) {
727
+ annotated = true;
728
+ var anno = pre.appendChild(mark(el("div", "anno"), "annotation", { line: l.n }));
729
+ anno.setAttribute("role", "note");
730
+ anno.appendChild(el("span", "anno-arrow"));
731
+ termed(anno, ex.annotation, ctx, "annotation");
732
+ } else if (!ex.annotation) r.code.appendChild(el("span", "this", ex.mark_label || "\u25c0 this line"));
644
733
  });
645
734
  box.appendChild(pre);
646
735
  if (ex.caption) box.appendChild(rich("p", "said", ex.caption));
647
736
  return box;
648
737
  }
738
+ // ── terms with a note (#392): a dotted term opens ONE floating note, anchored to it ─────────────
739
+ // `ctx` is the card's terms, the text each opens from, and which are drawn already. A term opens from
740
+ // its first occurrence in the quote, else its source line, else the annotation: the words asked
741
+ // about come first. A text is split at backticks before it is searched, as the build searches it.
742
+ var tips = {}; // "<card id>:<k>" -> the note entries of the term drawn under that key
743
+ function termsOf(card) {
744
+ var texts = [["quote", card.quote && card.quote.text], ["source", card.quote && card.quote.source],
745
+ ["annotation", card.excerpt && card.excerpt.annotation]];
746
+ var home = (card.terms || []).map(function (t) {
747
+ var at = texts.filter(function (x) {
748
+ return x[1] && String(x[1]).split("`").some(function (part) { return part.indexOf(t.term) !== -1; });
749
+ })[0];
750
+ return at ? at[0] : null;
751
+ });
752
+ return { card: card.id, terms: card.terms || [], home: home, used: {} };
753
+ }
754
+ function pieces(text, ctx, where) {
755
+ var best = null;
756
+ ctx.terms.forEach(function (t, k) {
757
+ var at = ctx.used[k] || ctx.home[k] !== where ? -1 : text.indexOf(t.term);
758
+ if (at !== -1 && (!best || at < best.at)) best = { at: at, k: k };
759
+ });
760
+ if (!best) return text ? [{ text: text }] : [];
761
+ ctx.used[best.k] = true;
762
+ var end = best.at + ctx.terms[best.k].term.length;
763
+ return (best.at ? [{ text: text.slice(0, best.at) }] : [])
764
+ .concat([{ text: text.slice(best.at, end), k: best.k }], pieces(text.slice(end), ctx, where));
765
+ }
766
+ // `text` into `node`, `backticks` as code, each term of `ctx` whose home is `where` a button that
767
+ // opens its note.
768
+ function termed(node, text, ctx, where) {
769
+ String(text).split("`").forEach(function (part, i) {
770
+ pieces(part, ctx, where).forEach(function (p) {
771
+ var inner = el(i % 2 ? "code" : "span", "", p.text);
772
+ if (p.k === undefined) { node.appendChild(inner); return; }
773
+ var key = ctx.card + ":" + p.k;
774
+ tips[key] = ctx.terms[p.k].note;
775
+ var b = mark(el("button", "term"), "term", { term: ctx.terms[p.k].term });
776
+ b.type = "button";
777
+ b.setAttribute("data-tip", key);
778
+ b.setAttribute("aria-expanded", "false");
779
+ b.setAttribute("aria-describedby", "tip");
780
+ b.appendChild(inner);
781
+ b.addEventListener("click", function () { tapTerm(b); });
782
+ b.addEventListener("mouseenter", function () { if (HOVER && b !== tipAt) { openTip(b); hovered = true; } });
783
+ node.appendChild(b);
784
+ });
785
+ });
786
+ return node;
787
+ }
788
+ var HOVER = !!(window.matchMedia && window.matchMedia("(hover: hover)").matches);
789
+ var tip = null, tipAt = null, hovered = false, tipTerm = "", leaving = null;
790
+ // The grace delay; a test sets `window.__tipGraceMs` to slow or shorten it.
791
+ function graceMs() { return window.__tipGraceMs || 250; }
792
+ function stayTip() { if (leaving !== null) { clearTimeout(leaving); leaving = null; } }
793
+ function tipMeta(open) { mark(tip, "tip", { term: tipTerm, open: open }); }
794
+ function closeTip() {
795
+ stayTip();
796
+ if (tipAt) tipAt.setAttribute("aria-expanded", "false");
797
+ tipAt = null;
798
+ hovered = false;
799
+ if (tip) { tip.hidden = true; tipMeta(false); }
800
+ }
801
+ // One entry: its title, its words, the quote as a quote, the example, then the link to the source;
802
+ // a snippet above its link. Everything is textContent.
803
+ function tipEntry(e) {
804
+ var out = [];
805
+ if (e.code) out.push(plainCode(e.code));
806
+ var p = el("p");
807
+ if (e.title) p.appendChild(el("b", "", e.title + ": "));
808
+ if (e.says) p.appendChild(mark(rich("span", "", e.says + " "), "tip-says"));
809
+ if (e.quote) p.appendChild(mark(el("q", "", e.quote), "tip-quote"));
810
+ if (e.example) p.appendChild(rich("span", "", " " + e.example));
811
+ p.appendChild(el("span", "", " "));
812
+ p.appendChild(mark(link(e.url_label, e.url), "tip-link", { url: e.url }));
813
+ return out.concat([p]);
814
+ }
815
+ // Below the term if it fits, else above; inside the viewport sideways; the arrow points at the term.
816
+ function openTip(b) {
817
+ closeTip();
818
+ if (!tip) {
819
+ tip = document.body.appendChild(el("div"));
820
+ tip.id = "tip";
821
+ tip.setAttribute("role", "tooltip");
822
+ }
823
+ tipAt = b;
824
+ tipTerm = JSON.parse(b.getAttribute("data-test-meta")).term;
825
+ b.setAttribute("aria-expanded", "true");
826
+ var note = tips[b.getAttribute("data-tip")];
827
+ tip.textContent = "";
828
+ note.forEach(function (e) { tipEntry(e).forEach(function (n) { tip.appendChild(n); }); });
829
+ var arrow = tip.appendChild(el("span", "tip-arrow"));
830
+ tip.className = note.some(function (e) { return e.code; }) ? "snip" : "";
831
+ tip.hidden = false;
832
+ tipMeta(true);
833
+ var r = b.getBoundingClientRect(), vw = document.documentElement.clientWidth;
834
+ var w = tip.offsetWidth, h = tip.offsetHeight;
835
+ var x = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), vw - w - 8);
836
+ var below = r.bottom + h + 12 < window.innerHeight || r.top < h + 12;
837
+ tip.className += below ? " below" : " above";
838
+ tip.style.left = (x + window.scrollX) + "px";
839
+ tip.style.top = ((below ? r.bottom + 8 : r.top - h - 8) + window.scrollY) + "px";
840
+ arrow.style.left = Math.min(Math.max(10, r.left + r.width / 2 - x - 5), w - 20) + "px";
841
+ }
842
+ // A tap opens the term's note, or closes it when it is open; a tap on a note opened by hover keeps it.
843
+ function tapTerm(b) {
844
+ if (b !== tipAt) openTip(b);
845
+ else if (hovered) hovered = false;
846
+ else closeTip();
847
+ }
848
+ // A tap outside the note and its term only closes the note: it never reaches the answer under it.
849
+ document.addEventListener("click", function (e) {
850
+ if (!tipAt || !e.target.closest || e.target.closest("button.term, #tip")) return;
851
+ closeTip();
852
+ e.preventDefault();
853
+ e.stopPropagation();
854
+ }, true);
855
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeTip(); });
856
+ // A note opened by hover closes a moment after the pointer leaves its term and the note: the gap
857
+ // between them must be crossable. Entering the term or the note again keeps it.
858
+ document.addEventListener("mouseover", function (e) {
859
+ if (!hovered || !tipAt || !e.target.closest) return;
860
+ if (e.target.closest("button.term, #tip")) stayTip();
861
+ else if (leaving === null) leaving = setTimeout(function () { leaving = null; if (hovered) closeTip(); }, graceMs());
862
+ });
863
+ // After a render the note follows its term's new button, or closes when the term is gone.
864
+ function reanchorTip() {
865
+ if (!tipAt) return;
866
+ var key = tipAt.getAttribute("data-tip"), wasHover = hovered;
867
+ var again = app.querySelector('button.term[data-tip="' + key + '"]');
868
+ if (again) { openTip(again); hovered = wasHover; } else closeTip();
869
+ }
870
+ // The words a card asks about, as a quote, and their source as a small line under them.
871
+ function quoteBlock(item, ctx) {
872
+ var box = el("div", "rulebox");
873
+ var bq = box.appendChild(mark(el("blockquote", "quote"), "quote"));
874
+ termed(bq.appendChild(el("q")), item.quote.text, ctx, "quote");
875
+ if (item.quote.source) termed(box.appendChild(mark(el("p", "src"), "quote-source")), item.quote.source, ctx, "source");
876
+ return box;
877
+ }
649
878
  // A reference entry: its title, then each field (a paragraph, a code block on a labelled row, or a
650
879
  // quiet note), then its link.
651
880
  function referenceBlock(r) {
@@ -659,27 +888,29 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
659
888
  if (r.url) box.appendChild(el("p")).appendChild(link(r.url_label || r.url, r.url));
660
889
  return box;
661
890
  }
662
- function folded(summary) {
663
- var d = el("details");
664
- d.appendChild(el("summary", "", summary));
891
+ // A block of a card's reference material, in place under its heading: nothing a card needs is
892
+ // folded away (#392).
893
+ function shownBlock(summary) {
894
+ var d = el("div", "rulebox");
895
+ d.appendChild(el("h3", "", summary));
665
896
  return d;
666
897
  }
667
898
  function references(x) {
668
- var d = folded(x.summary);
899
+ var d = shownBlock(x.summary);
669
900
  x.entries.forEach(function (r) { d.appendChild(row(r.label || "", referenceBlock(r))); });
670
901
  return d;
671
902
  }
672
903
  function moreList(m) {
673
- var d = folded(m.summary);
904
+ var d = shownBlock(m.summary);
674
905
  var ul = el("ul");
675
906
  m.items.forEach(function (f) { ul.appendChild(rich("li", "", f)); });
676
907
  if (m.rest) ul.appendChild(el("li", "muted", m.rest));
677
908
  d.appendChild(ul);
678
909
  return d;
679
910
  }
680
- // A folded table: its note, then each row in the order given: label, value as code, plain words, mark.
911
+ // A table: its note, then each row in the order given: label, value as code, plain words, mark.
681
912
  function table(t) {
682
- var d = folded(t.summary);
913
+ var d = shownBlock(t.summary);
683
914
  if (t.note) d.appendChild(el("p", "muted", t.note));
684
915
  t.rows.forEach(function (r) {
685
916
  var s = el("div", "setting");
@@ -691,18 +922,22 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
691
922
  });
692
923
  return d;
693
924
  }
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.
925
+ // Below the question and its answers: the references, the list of more like it and the table, in
926
+ // place, so the question sits right under the code it names.
696
927
  function blocksBelow(card, item) {
697
928
  if (item.references) card.appendChild(references(item.references));
698
929
  if (item.more) card.appendChild(moreList(item.more));
699
930
  if (item.table) card.appendChild(table(item.table));
700
931
  }
701
932
  // What a card shows above its question, one labelled row per field (an item and a practice item alike).
702
- function evidence(card, item) {
933
+ // A card's quote comes first, under its title: the words asked about, then the code they are about.
934
+ function evidence(card, item, ctx) {
703
935
  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);
936
+ if (item.quote) card.appendChild(quoteBlock(item, ctx));
937
+ if (item.body !== undefined) {
938
+ var body = rich("p", "body", item.body);
939
+ card.appendChild(DATA.labels.body ? row(DATA.labels.body, body) : body);
940
+ }
706
941
  if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(DATA.labels.repeat, item.repeat)));
707
942
  if (item.code) card.appendChild(codeBlock(item.code));
708
943
  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 +952,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
717
952
  }
718
953
  if (item.file) card.appendChild(row("File", rich("p", "", item.file)));
719
954
  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));
955
+ if (item.situation && item.situation.summary) card.appendChild(situated("p", "situation", item.situation.summary, item.situation.line));
956
+ if (item.excerpt) card.appendChild(excerptBlock(item.excerpt, ctx));
722
957
  }
723
958
  function labelOf(q, v) {
724
959
  var o = q.options.filter(function (x) { return x.value === v; })[0];
@@ -726,9 +961,10 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
726
961
  }
727
962
  // One question per screen: the answered ones stay as a line (with "Change"), and only one question
728
963
  // is asked: the one being changed, else the first unanswered.
729
- function askOne(card, a, editing, onPick, onChange, off, withExamples, situation) {
964
+ function askOne(card, a, editing, onPick, onChange, off, withExamples, item) {
730
965
  var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
731
966
  var asked = DATA.questions.filter(function (q) { return q.id === editing; })[0] || open[0];
967
+ var situation = item.situation, notes = item.answer_notes || {};
732
968
  DATA.questions.forEach(function (q) {
733
969
  if (q === asked || !valid(DATA.allowed.questions[q.id], a[q.id])) return;
734
970
  var line = el("p", "answered", (q.short || q.text) + ": " + labelOf(q, a[q.id]) + " ");
@@ -744,8 +980,9 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
744
980
  if (!asked) return null;
745
981
  var ask = el("div", "ask");
746
982
  // 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));
983
+ ask.appendChild(mark(situation && asked === DATA.questions[0] ? situated("p", "q", situation.question, situation.line) : rich("p", "q", asked.text),
984
+ "question", { question: asked.id }));
985
+ ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples, notes[asked.id], asked.id));
749
986
  card.appendChild(ask);
750
987
  return ask;
751
988
  }
@@ -772,11 +1009,12 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
772
1009
  return sec;
773
1010
  }
774
1011
  function practiceCard(p, off) {
775
- var card = el("article", "item");
1012
+ var card = mark(el("article", "item"), "practice-item", { id: p.id });
776
1013
  card.appendChild(el("div", "id", "Practice " + p.id));
777
- evidence(card, p);
1014
+ var ctx = termsOf(p);
1015
+ evidence(card, p, ctx);
778
1016
  var a = practiceAnswers(p.id);
779
- askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p.situation);
1017
+ askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p);
780
1018
  if (practiceAnswered(p)) {
781
1019
  var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
782
1020
  card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
@@ -856,7 +1094,9 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
856
1094
  state.typingIn = typing ? typing.key.slice(0, typing.key.indexOf("|")) : null;
857
1095
  Object.keys(state.seenSince).forEach(stopClock);
858
1096
  state.gates = {};
1097
+ tips = {};
859
1098
  paint();
1099
+ reanchorTip();
860
1100
  if (MIN_SECONDS) watchCards();
861
1101
  var again = typing && state.whyBoxes[typing.key];
862
1102
  if (again) { again.focus(); again.setSelectionRange(typing.start, typing.end); }