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.
- 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 +6 -4
- package/skills/build-label-page/SKILL.md.spec.ts +5 -3
- package/skills/build-label-page/references/checklist.md +3 -3
- package/skills/build-label-page/references/closed-choice-generator.md +58 -18
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +276 -1
- package/skills/build-label-page/scripts/label-page-browser.test.ts +513 -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-html.test.ts +29 -0
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/page-template.html +300 -60
|
@@ -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
|
|
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
|
|
32
|
-
border
|
|
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
|
|
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,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
|
-
|
|
518
|
-
s.
|
|
519
|
-
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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
|
-
//
|
|
628
|
-
//
|
|
629
|
-
function
|
|
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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
var
|
|
642
|
-
if (l.
|
|
643
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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,
|
|
695
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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); }
|