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.
- package/CLAUDE.md +6 -1
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +27 -2
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +5 -3
- package/skills/build-label-page/SKILL.md.spec.ts +4 -2
- package/skills/build-label-page/references/closed-choice-generator.md +44 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +277 -1
- package/skills/build-label-page/scripts/label-page-browser.test.ts +452 -10
- package/skills/build-label-page/scripts/label-page-core.js +105 -9
- package/skills/build-label-page/scripts/label-page-core.test.ts +25 -1
- package/skills/build-label-page/scripts/label-page-core.ts +178 -10
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/page-template.html +292 -53
|
@@ -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
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
pre.numbered
|
|
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 .
|
|
97
|
-
pre.numbered .
|
|
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
|
|
205
|
-
|
|
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)) {
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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
|
-
//
|
|
628
|
-
//
|
|
629
|
-
function
|
|
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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
var
|
|
642
|
-
if (l.
|
|
643
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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,
|
|
695
|
-
//
|
|
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
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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); }
|