paperlint 14.1.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 +70 -32
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +19 -3
- package/skills/build-label-page/SKILL.md.spec.ts +18 -2
- package/skills/build-label-page/references/closed-choice-generator.md +86 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -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 +761 -10
- package/skills/build-label-page/scripts/label-page-core.js +311 -67
- package/skills/build-label-page/scripts/label-page-core.test.ts +49 -10
- package/skills/build-label-page/scripts/label-page-core.ts +511 -87
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/label-page.js +5 -0
- package/skills/build-label-page/scripts/label-page.ts +5 -0
- package/skills/build-label-page/scripts/page-template.html +488 -62
|
@@ -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. */
|
|
@@ -103,6 +141,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
103
141
|
.setting { display: flex; flex-direction: column; gap: 2px; padding-top: 12px; border-top: 1px solid var(--line); }
|
|
104
142
|
.match.yes { color: var(--good); }
|
|
105
143
|
.match.no { color: var(--bad); }
|
|
144
|
+
.who { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); }
|
|
145
|
+
.who button { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; }
|
|
106
146
|
</style>
|
|
107
147
|
<main id="app"><p class="muted">Loading…</p></main>
|
|
108
148
|
<script type="application/json" id="data">__DATA__</script>
|
|
@@ -129,13 +169,151 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
129
169
|
failed: {}, // item/group key -> error code of the last refused write
|
|
130
170
|
loaded: { answers: false, groups: false, practice: false },
|
|
131
171
|
store: "loading", // loading | ok | none
|
|
132
|
-
filter: "all"
|
|
172
|
+
filter: "all",
|
|
173
|
+
who: null, // the answerer picked, on a page with DATA.answerers; null until one is
|
|
174
|
+
raw: { answers: {}, groups: {}, practice: {} }, // every document of the store, every answerer's
|
|
175
|
+
dwell: {}, // item id -> ms its card was on screen, clocks stopped (DATA.min_seconds)
|
|
176
|
+
seenSince: {}, // item id -> when its card came on screen, while it is
|
|
177
|
+
firstSeconds: {}, // item id -> the seconds this page stored with the item's first save
|
|
178
|
+
gates: {} // item id -> the locked buttons and the countdown of the last render
|
|
133
179
|
};
|
|
134
180
|
var db = null;
|
|
135
181
|
var chains = {};
|
|
136
182
|
var WHY_PAUSE_MS = 800;
|
|
137
183
|
|
|
138
184
|
try { if (localStorage.getItem("label-filter") === "next") state.filter = "next"; } catch (e) {}
|
|
185
|
+
// ── who is answering (DATA.answerers): each answerer's documents are "<answerer>~<id>" ─────
|
|
186
|
+
// "~" is outside the build's name pattern, so no answerer, item, group or practice id holds it.
|
|
187
|
+
var SEP = "~";
|
|
188
|
+
var ANSWERERS = DATA.answerers || [];
|
|
189
|
+
var ASKS_WHO = ANSWERERS.length > 0;
|
|
190
|
+
try {
|
|
191
|
+
var remembered = localStorage.getItem("label-answerer");
|
|
192
|
+
if (ANSWERERS.indexOf(remembered) !== -1) state.who = remembered;
|
|
193
|
+
} catch (e) {}
|
|
194
|
+
// The store's id of one of this page's documents: as before on a page without answerers.
|
|
195
|
+
function docName(id) { return ASKS_WHO ? state.who + SEP + id : id; }
|
|
196
|
+
// The documents of the current answerer, by their names without the answerer.
|
|
197
|
+
function mineOf(docs) {
|
|
198
|
+
if (!ASKS_WHO) return docs;
|
|
199
|
+
var out = {}, prefix = state.who + SEP;
|
|
200
|
+
if (state.who) Object.keys(docs).forEach(function (k) { if (k.indexOf(prefix) === 0) out[k.slice(prefix.length)] = docs[k]; });
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
// The items shown to the current answerer: an item's own `answerers`, else everyone's.
|
|
204
|
+
function itemsShown() {
|
|
205
|
+
return DATA.items.filter(function (i) { return !i.answerers || i.answerers.indexOf(state.who) !== -1; });
|
|
206
|
+
}
|
|
207
|
+
function groupsShown() { return DATA.groups.filter(function (g) { return groupItems(g.id).length > 0; }); }
|
|
208
|
+
function busy() {
|
|
209
|
+
return Object.keys(state.inflight).some(function (k) { return state.inflight[k] > 0; }) || Object.keys(state.whyTimers).length > 0;
|
|
210
|
+
}
|
|
211
|
+
// Picking (or switching) the answerer: everything this page holds of the last one is dropped.
|
|
212
|
+
function choose(name) {
|
|
213
|
+
if (busy()) return;
|
|
214
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
215
|
+
state.who = name;
|
|
216
|
+
try {
|
|
217
|
+
if (name) localStorage.setItem("label-answerer", name);
|
|
218
|
+
else localStorage.removeItem("label-answerer");
|
|
219
|
+
} catch (e) {}
|
|
220
|
+
["editing", "mineWhy", "draftWhy", "pending", "refusedWhy", "mine", "failed", "dwell", "seenSince", "firstSeconds"]
|
|
221
|
+
.forEach(function (k) { state[k] = {}; });
|
|
222
|
+
state.lastAnswered = null;
|
|
223
|
+
["answers", "groups", "practice"].forEach(function (c) { state[c] = mineOf(state.raw[c]); });
|
|
224
|
+
render();
|
|
225
|
+
}
|
|
226
|
+
// ── how long a card was on screen (DATA.min_seconds): its answer saves only after that long ──
|
|
227
|
+
// On screen: half the card in view, or half the viewport filled by a card taller than it.
|
|
228
|
+
var MIN_SECONDS = DATA.min_seconds || 0;
|
|
229
|
+
function dwellMs(id) {
|
|
230
|
+
var ms = state.dwell[id] || 0;
|
|
231
|
+
return state.seenSince[id] !== undefined ? ms + Date.now() - state.seenSince[id] : ms;
|
|
232
|
+
}
|
|
233
|
+
function stopClock(id) {
|
|
234
|
+
if (state.seenSince[id] === undefined) return;
|
|
235
|
+
state.dwell[id] = dwellMs(id);
|
|
236
|
+
delete state.seenSince[id];
|
|
237
|
+
}
|
|
238
|
+
function startClock(id) { if (state.seenSince[id] === undefined) state.seenSince[id] = Date.now(); }
|
|
239
|
+
// An item already saved (by this page or in the store) is never locked again.
|
|
240
|
+
function unsaved(id) { return !state.answers[id] && state.firstSeconds[id] === undefined; }
|
|
241
|
+
function locked(id) { return MIN_SECONDS > 0 && unsaved(id) && dwellMs(id) < MIN_SECONDS * 1000; }
|
|
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) }); }
|
|
244
|
+
// The seconds stored with an item: those of its first save, kept on every later write.
|
|
245
|
+
function secondsOf(id) {
|
|
246
|
+
var d = state.answers[id];
|
|
247
|
+
if (d && typeof d.seconds === "number") return d.seconds;
|
|
248
|
+
if (state.firstSeconds[id] === undefined) state.firstSeconds[id] = Math.floor(dwellMs(id) / 1000);
|
|
249
|
+
return state.firstSeconds[id];
|
|
250
|
+
}
|
|
251
|
+
// An observer reports only when a card crosses one of its thresholds, so each card gets its own
|
|
252
|
+
// observer whose threshold is that card's share: half the card, or half the viewport for a card
|
|
253
|
+
// taller than it. The share follows the card's height and the viewport's: a card that changes
|
|
254
|
+
// size, or a resized window, is watched again with its new share.
|
|
255
|
+
var observers = [];
|
|
256
|
+
var sizes = null;
|
|
257
|
+
function share(h) { return h > 0 ? Math.min(h, window.innerHeight) / 2 / h : 0.5; }
|
|
258
|
+
function onScreen(entry) {
|
|
259
|
+
var h = entry.boundingClientRect.height;
|
|
260
|
+
return entry.isIntersecting && h > 0 &&
|
|
261
|
+
entry.intersectionRect.height >= Math.min(h, window.innerHeight) / 2 - 0.5;
|
|
262
|
+
}
|
|
263
|
+
function watchCard(card) {
|
|
264
|
+
var id = card.getAttribute("data-id");
|
|
265
|
+
var o = new IntersectionObserver(function (entries) {
|
|
266
|
+
entries.forEach(function (e) {
|
|
267
|
+
if (onScreen(e) && document.visibilityState !== "hidden") startClock(id);
|
|
268
|
+
else stopClock(id);
|
|
269
|
+
});
|
|
270
|
+
}, { threshold: [0, share(card.getBoundingClientRect().height)] });
|
|
271
|
+
o.observe(card);
|
|
272
|
+
return o;
|
|
273
|
+
}
|
|
274
|
+
// Every render draws new cards: the clocks stop, and the new cards are watched from scratch.
|
|
275
|
+
function watchCards() {
|
|
276
|
+
observers.forEach(function (o) { o.disconnect(); });
|
|
277
|
+
observers = [];
|
|
278
|
+
if (sizes) sizes.disconnect();
|
|
279
|
+
sizes = null;
|
|
280
|
+
var cards = Array.prototype.slice.call(document.querySelectorAll("article.item[data-id]"));
|
|
281
|
+
if (typeof IntersectionObserver !== "function") {
|
|
282
|
+
// No way to tell what is in view: a card counts as on screen from the moment it is drawn.
|
|
283
|
+
cards.forEach(function (c) { startClock(c.getAttribute("data-id")); });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
observers = cards.map(watchCard);
|
|
287
|
+
if (typeof ResizeObserver !== "function") return;
|
|
288
|
+
var heights = cards.map(function (c) { return c.getBoundingClientRect().height; });
|
|
289
|
+
sizes = new ResizeObserver(function (entries) {
|
|
290
|
+
entries.forEach(function (e) {
|
|
291
|
+
var i = cards.indexOf(e.target);
|
|
292
|
+
var h = e.target.getBoundingClientRect().height;
|
|
293
|
+
if (i === -1 || Math.abs(h - heights[i]) < 1) return;
|
|
294
|
+
heights[i] = h;
|
|
295
|
+
observers[i].disconnect();
|
|
296
|
+
observers[i] = watchCard(cards[i]);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
cards.forEach(function (c) { sizes.observe(c); });
|
|
300
|
+
}
|
|
301
|
+
// The countdown on each locked card, and its buttons let go when its time is up; no render, so
|
|
302
|
+
// a section the labeller opened stays open.
|
|
303
|
+
function tick() {
|
|
304
|
+
Object.keys(state.gates).forEach(function (id) {
|
|
305
|
+
var g = state.gates[id];
|
|
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);
|
|
312
|
+
g.buttons.forEach(function (b) { b.disabled = !canWrite(); });
|
|
313
|
+
delete state.gates[id];
|
|
314
|
+
});
|
|
315
|
+
setTimeout(tick, 250);
|
|
316
|
+
}
|
|
139
317
|
|
|
140
318
|
// ── what "done" means ────────────────────────────────────────────────────────────────────
|
|
141
319
|
function answersOf(id) {
|
|
@@ -184,7 +362,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
184
362
|
var d = state.mine["group:" + g] || state.groups[g];
|
|
185
363
|
return d && valid(DATA.allowed.familiarity, d.familiarity) ? d.familiarity : undefined;
|
|
186
364
|
}
|
|
187
|
-
function groupItems(g) { return
|
|
365
|
+
function groupItems(g) { return itemsShown().filter(function (i) { return i.group === g; }); }
|
|
188
366
|
// ── the practice (rule 8): known answers, before the real items ──────────────────────────
|
|
189
367
|
function practiceDoc(pid) { return "r" + DATA.practice.round + "-" + pid; }
|
|
190
368
|
function practiceAnswers(pid) {
|
|
@@ -209,7 +387,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
209
387
|
function write(key, coll, docId, body) {
|
|
210
388
|
state.inflight[key] = (state.inflight[key] || 0) + 1;
|
|
211
389
|
delete state.failed[key];
|
|
212
|
-
var step = function () { return db.collection(coll).doc(docId).set(body); };
|
|
390
|
+
var step = function () { return db.collection(coll).doc(docName(docId)).set(body); };
|
|
213
391
|
var run = (chains[key] || Promise.resolve()).then(step, step);
|
|
214
392
|
chains[key] = run.then(function () {
|
|
215
393
|
state.inflight[key] -= 1;
|
|
@@ -223,7 +401,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
223
401
|
});
|
|
224
402
|
}
|
|
225
403
|
function canWrite() {
|
|
226
|
-
return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok"
|
|
404
|
+
return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok" &&
|
|
405
|
+
(!ASKS_WHO || state.who !== null);
|
|
227
406
|
}
|
|
228
407
|
// An answer that asks why is not written while its reason is empty (#380): it waits on the page, and
|
|
229
408
|
// the store keeps what it had, so an export never meets an answer without the reason it needs. Every
|
|
@@ -256,11 +435,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
256
435
|
else delete state.pending[id];
|
|
257
436
|
delete state.refusedWhy[id];
|
|
258
437
|
state.mine[id] = ready;
|
|
259
|
-
|
|
438
|
+
var body = { answers: ready, reasons: reasons, savedAt: new Date().toISOString(), source: "page" };
|
|
439
|
+
if (MIN_SECONDS) body.seconds = secondsOf(id);
|
|
440
|
+
write(id, "answers", id, body);
|
|
260
441
|
render();
|
|
261
442
|
}
|
|
262
443
|
function answer(id, qid, value) {
|
|
263
|
-
if (!canWrite()) return;
|
|
444
|
+
if (!canWrite() || locked(id)) return;
|
|
264
445
|
var next = Object.assign({}, answersOf(id));
|
|
265
446
|
next[qid] = value;
|
|
266
447
|
state.lastAnswered = id;
|
|
@@ -318,6 +499,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
318
499
|
if (text !== undefined) n.textContent = text;
|
|
319
500
|
return n;
|
|
320
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
|
+
}
|
|
321
509
|
// Text with `backticks` shown as code spans; everything stays textContent, so nothing is parsed as HTML.
|
|
322
510
|
function rich(tag, cls, text) {
|
|
323
511
|
var n = el(tag, cls);
|
|
@@ -344,12 +532,14 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
344
532
|
});
|
|
345
533
|
return pre;
|
|
346
534
|
}
|
|
347
|
-
|
|
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) {
|
|
348
537
|
var row = el("div", "opts");
|
|
349
538
|
opts.forEach(function (o) {
|
|
350
|
-
var b = el("button", "");
|
|
539
|
+
var b = mark(el("button", ""), "answer", { question: qid, value: o.value });
|
|
351
540
|
if (o.icon) b.appendChild(el("span", "icon", o.icon));
|
|
352
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 }));
|
|
353
543
|
if (withExamples && o.example) b.appendChild(rich("span", "ex", DATA.labels.practice_example + o.example));
|
|
354
544
|
b.type = "button";
|
|
355
545
|
b.setAttribute("aria-pressed", String(current === o.value));
|
|
@@ -364,20 +554,28 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
364
554
|
return isNaN(d) ? "" : d.toLocaleTimeString();
|
|
365
555
|
}
|
|
366
556
|
function statusLine(key, missing, savedAt) {
|
|
367
|
-
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
|
+
};
|
|
368
561
|
if (state.pending[key] || state.refusedWhy[key]) {
|
|
562
|
+
as("needs-reason");
|
|
369
563
|
s.className = "status missing";
|
|
370
564
|
s.textContent = "Not saved yet: this answer needs a reason. Write it above, or pick another answer.";
|
|
371
565
|
} else if (state.failed[key]) {
|
|
566
|
+
as("failed");
|
|
372
567
|
s.className = "status failed";
|
|
373
568
|
s.textContent = "Not saved (" + state.failed[key] + "). Tap the answer again.";
|
|
374
569
|
} else if (state.inflight[key]) {
|
|
570
|
+
as("saving");
|
|
375
571
|
s.textContent = "Saving…";
|
|
376
572
|
} else if (missing.length) {
|
|
573
|
+
as("missing");
|
|
377
574
|
s.className = "status missing";
|
|
378
575
|
s.textContent = "Still needs: " + missing.map(function (q) { return q.short || q.text; }).join("; ") +
|
|
379
576
|
(savedAt ? ". Saved " + timeOf(savedAt) : "");
|
|
380
577
|
} else if (savedAt) {
|
|
578
|
+
as("saved");
|
|
381
579
|
s.className = "status saved";
|
|
382
580
|
s.textContent = "Saved " + timeOf(savedAt);
|
|
383
581
|
}
|
|
@@ -385,13 +583,22 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
385
583
|
}
|
|
386
584
|
function itemCard(item, off) {
|
|
387
585
|
var card = el("article", "item");
|
|
388
|
-
|
|
389
|
-
|
|
586
|
+
var shown = itemsShown();
|
|
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 });
|
|
589
|
+
card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: shown.indexOf(item) + 1, total: shown.length })));
|
|
590
|
+
var ctx = termsOf(item);
|
|
591
|
+
evidence(card, item, ctx);
|
|
390
592
|
var a = answersOf(item.id);
|
|
391
|
-
|
|
593
|
+
var gate = locked(item.id);
|
|
594
|
+
var ask = askOne(card, a, state.editing[item.id], ctx, function (q, v) {
|
|
392
595
|
delete state.editing[item.id];
|
|
393
596
|
answer(item.id, q.id, v);
|
|
394
|
-
}, function (q) { state.editing[item.id] = q.id; render(); }, off, false, item
|
|
597
|
+
}, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item);
|
|
598
|
+
if (gate && ask) {
|
|
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")) };
|
|
601
|
+
}
|
|
395
602
|
DATA.questions.forEach(function (q) {
|
|
396
603
|
var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
|
|
397
604
|
if (!o) return;
|
|
@@ -477,28 +684,194 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
477
684
|
function fill(text, vars) {
|
|
478
685
|
return String(text).replace(/\{(\w+)\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
|
|
479
686
|
}
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
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) {
|
|
483
699
|
var box = el("div", "rulebox");
|
|
484
700
|
if (ex.file) {
|
|
485
|
-
var name = el("p");
|
|
701
|
+
var name = el("p", "path");
|
|
702
|
+
mark(name, "excerpt-path", { file: ex.file, lang: ex.lang || "" });
|
|
486
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));
|
|
487
705
|
box.appendChild(name);
|
|
488
706
|
}
|
|
489
|
-
var
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
var
|
|
495
|
-
if (l.
|
|
496
|
-
|
|
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"));
|
|
497
730
|
});
|
|
498
731
|
box.appendChild(pre);
|
|
499
732
|
if (ex.caption) box.appendChild(rich("p", "said", ex.caption));
|
|
500
733
|
return box;
|
|
501
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
|
+
}
|
|
502
875
|
// A reference entry: its title, then each field (a paragraph, a code block on a labelled row, or a
|
|
503
876
|
// quiet note), then its link.
|
|
504
877
|
function referenceBlock(r) {
|
|
@@ -512,27 +885,29 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
512
885
|
if (r.url) box.appendChild(el("p")).appendChild(link(r.url_label || r.url, r.url));
|
|
513
886
|
return box;
|
|
514
887
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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));
|
|
518
893
|
return d;
|
|
519
894
|
}
|
|
520
895
|
function references(x) {
|
|
521
|
-
var d =
|
|
896
|
+
var d = shownBlock(x.summary);
|
|
522
897
|
x.entries.forEach(function (r) { d.appendChild(row(r.label || "", referenceBlock(r))); });
|
|
523
898
|
return d;
|
|
524
899
|
}
|
|
525
900
|
function moreList(m) {
|
|
526
|
-
var d =
|
|
901
|
+
var d = shownBlock(m.summary);
|
|
527
902
|
var ul = el("ul");
|
|
528
903
|
m.items.forEach(function (f) { ul.appendChild(rich("li", "", f)); });
|
|
529
904
|
if (m.rest) ul.appendChild(el("li", "muted", m.rest));
|
|
530
905
|
d.appendChild(ul);
|
|
531
906
|
return d;
|
|
532
907
|
}
|
|
533
|
-
// A
|
|
908
|
+
// A table: its note, then each row in the order given: label, value as code, plain words, mark.
|
|
534
909
|
function table(t) {
|
|
535
|
-
var d =
|
|
910
|
+
var d = shownBlock(t.summary);
|
|
536
911
|
if (t.note) d.appendChild(el("p", "muted", t.note));
|
|
537
912
|
t.rows.forEach(function (r) {
|
|
538
913
|
var s = el("div", "setting");
|
|
@@ -544,18 +919,20 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
544
919
|
});
|
|
545
920
|
return d;
|
|
546
921
|
}
|
|
547
|
-
// Below the question and its answers: the references, the list of more like it and the table,
|
|
548
|
-
//
|
|
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.
|
|
549
924
|
function blocksBelow(card, item) {
|
|
550
925
|
if (item.references) card.appendChild(references(item.references));
|
|
551
926
|
if (item.more) card.appendChild(moreList(item.more));
|
|
552
927
|
if (item.table) card.appendChild(table(item.table));
|
|
553
928
|
}
|
|
554
929
|
// What a card shows above its question, one labelled row per field (an item and a practice item alike).
|
|
555
|
-
function evidence(card, item) {
|
|
930
|
+
function evidence(card, item, ctx) {
|
|
556
931
|
if (item.title) card.appendChild(rich("h3", "", item.title));
|
|
557
|
-
|
|
558
|
-
|
|
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
|
+
}
|
|
559
936
|
if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(DATA.labels.repeat, item.repeat)));
|
|
560
937
|
if (item.code) card.appendChild(codeBlock(item.code));
|
|
561
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,
|
|
@@ -570,8 +947,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
570
947
|
}
|
|
571
948
|
if (item.file) card.appendChild(row("File", rich("p", "", item.file)));
|
|
572
949
|
if (item.context) card.appendChild(rich("p", "muted", item.context));
|
|
573
|
-
if (item.situation) card.appendChild(situated("p", "situation", item.situation.summary, item.situation.line));
|
|
574
|
-
if (item.excerpt) card.appendChild(excerptBlock(item.excerpt));
|
|
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));
|
|
575
952
|
}
|
|
576
953
|
function labelOf(q, v) {
|
|
577
954
|
var o = q.options.filter(function (x) { return x.value === v; })[0];
|
|
@@ -579,9 +956,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
579
956
|
}
|
|
580
957
|
// One question per screen: the answered ones stay as a line (with "Change"), and only one question
|
|
581
958
|
// is asked: the one being changed, else the first unanswered.
|
|
582
|
-
|
|
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) {
|
|
583
961
|
var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
|
|
584
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);
|
|
585
966
|
DATA.questions.forEach(function (q) {
|
|
586
967
|
if (q === asked || !valid(DATA.allowed.questions[q.id], a[q.id])) return;
|
|
587
968
|
var line = el("p", "answered", (q.short || q.text) + ": " + labelOf(q, a[q.id]) + " ");
|
|
@@ -594,12 +975,15 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
594
975
|
}
|
|
595
976
|
card.appendChild(line);
|
|
596
977
|
});
|
|
597
|
-
if (!asked) return;
|
|
978
|
+
if (!asked) return null;
|
|
598
979
|
var ask = el("div", "ask");
|
|
980
|
+
if (quote) ask.appendChild(quote);
|
|
599
981
|
// A card's situation asks the page's first question in its own words; the others keep theirs.
|
|
600
|
-
ask.appendChild(situation && asked === DATA.questions[0] ? situated("p", "q", situation.question, situation.line) : rich("p", "q", asked.text)
|
|
601
|
-
|
|
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));
|
|
602
985
|
card.appendChild(ask);
|
|
986
|
+
return ask;
|
|
603
987
|
}
|
|
604
988
|
// Open until the practice is done; then folded into a toggle, so the cards start near the top.
|
|
605
989
|
function howTo() {
|
|
@@ -624,11 +1008,12 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
624
1008
|
return sec;
|
|
625
1009
|
}
|
|
626
1010
|
function practiceCard(p, off) {
|
|
627
|
-
var card = el("article", "item");
|
|
1011
|
+
var card = mark(el("article", "item"), "practice-item", { id: p.id });
|
|
628
1012
|
card.appendChild(el("div", "id", "Practice " + p.id));
|
|
629
|
-
|
|
1013
|
+
var ctx = termsOf(p);
|
|
1014
|
+
evidence(card, p, ctx);
|
|
630
1015
|
var a = practiceAnswers(p.id);
|
|
631
|
-
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);
|
|
632
1017
|
if (practiceAnswered(p)) {
|
|
633
1018
|
var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
|
|
634
1019
|
card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
|
|
@@ -706,14 +1091,45 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
706
1091
|
// Under "Next unanswered" the save that completes an item would hide it, box and all, mid-sentence:
|
|
707
1092
|
// the item being typed in stays on the page until the labeller leaves its box.
|
|
708
1093
|
state.typingIn = typing ? typing.key.slice(0, typing.key.indexOf("|")) : null;
|
|
1094
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
1095
|
+
state.gates = {};
|
|
1096
|
+
tips = {};
|
|
709
1097
|
paint();
|
|
1098
|
+
reanchorTip();
|
|
1099
|
+
if (MIN_SECONDS) watchCards();
|
|
710
1100
|
var again = typing && state.whyBoxes[typing.key];
|
|
711
1101
|
if (again) { again.focus(); again.setSelectionRange(typing.start, typing.end); }
|
|
712
1102
|
}
|
|
1103
|
+
// The answerer's choice, before anything else of the page.
|
|
1104
|
+
function whoBlock() {
|
|
1105
|
+
var sec = el("section");
|
|
1106
|
+
sec.appendChild(el("h2", "", "Who is answering?"));
|
|
1107
|
+
sec.appendChild(el("p", "muted", "Each person answers under their own name and sees only their own answers."));
|
|
1108
|
+
var row = el("div", "opts");
|
|
1109
|
+
ANSWERERS.forEach(function (name) {
|
|
1110
|
+
var b = el("button", "", name);
|
|
1111
|
+
b.type = "button";
|
|
1112
|
+
b.addEventListener("click", function () { choose(name); });
|
|
1113
|
+
row.appendChild(b);
|
|
1114
|
+
});
|
|
1115
|
+
sec.appendChild(row);
|
|
1116
|
+
return sec;
|
|
1117
|
+
}
|
|
1118
|
+
function whoLine() {
|
|
1119
|
+
var line = el("p", "who");
|
|
1120
|
+
line.appendChild(el("span", "", "Answering as " + state.who));
|
|
1121
|
+
var b = el("button", "", "Switch");
|
|
1122
|
+
b.type = "button";
|
|
1123
|
+
b.disabled = busy();
|
|
1124
|
+
b.addEventListener("click", function () { choose(null); });
|
|
1125
|
+
line.appendChild(b);
|
|
1126
|
+
return line;
|
|
1127
|
+
}
|
|
713
1128
|
function paint() {
|
|
714
1129
|
var off = !canWrite();
|
|
715
|
-
var
|
|
716
|
-
var
|
|
1130
|
+
var items = itemsShown(), groups = groupsShown();
|
|
1131
|
+
var done = items.filter(function (i) { return itemDone(i.id); }).length;
|
|
1132
|
+
var total = items.length;
|
|
717
1133
|
app.textContent = "";
|
|
718
1134
|
var top = el("header", "top");
|
|
719
1135
|
app.appendChild(top);
|
|
@@ -726,6 +1142,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
726
1142
|
} else if (state.store === "loading" || !(state.loaded.answers && state.loaded.groups)) {
|
|
727
1143
|
top.appendChild(el("p", "muted", "Loading saved answers…"));
|
|
728
1144
|
}
|
|
1145
|
+
if (ASKS_WHO && !state.who) { app.appendChild(whoBlock()); return; }
|
|
1146
|
+
if (ASKS_WHO) top.appendChild(whoLine());
|
|
729
1147
|
app.appendChild(howTo());
|
|
730
1148
|
app.appendChild(practiceBlock(off));
|
|
731
1149
|
// Without the store nothing can be answered, practice included: show the real items read-only (rule 7).
|
|
@@ -738,7 +1156,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
738
1156
|
bar.appendChild(fill);
|
|
739
1157
|
progress.appendChild(bar);
|
|
740
1158
|
// Done means every item AND every group's familiarity, as «Next unanswered» counts it.
|
|
741
|
-
var rated =
|
|
1159
|
+
var rated = groups.every(function (g) { return familiarity(g.id) !== undefined; });
|
|
742
1160
|
if (total && done === total && rated && state.filter === "all") {
|
|
743
1161
|
progress.appendChild(el("p", "status saved", "All " + total + " items are complete. Tell the agent you are " +
|
|
744
1162
|
"done; you can still change any answer."));
|
|
@@ -760,8 +1178,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
760
1178
|
|
|
761
1179
|
var onlyOpen = state.filter === "next";
|
|
762
1180
|
if (onlyOpen) {
|
|
763
|
-
var rest =
|
|
764
|
-
var open =
|
|
1181
|
+
var rest = items.filter(function (i) { return !itemDone(i.id); });
|
|
1182
|
+
var open = groups.filter(function (g) { return familiarity(g.id) === undefined; });
|
|
765
1183
|
var note = el("div", "muted");
|
|
766
1184
|
if (!rest.length && !open.length) {
|
|
767
1185
|
note.appendChild(el("p", "", "Nothing left. Every item and every group is answered."));
|
|
@@ -777,7 +1195,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
777
1195
|
}
|
|
778
1196
|
progress.appendChild(note);
|
|
779
1197
|
}
|
|
780
|
-
|
|
1198
|
+
groups.forEach(function (g) {
|
|
781
1199
|
var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn || i.id === state.lastAnswered; });
|
|
782
1200
|
if (onlyOpen && groupDone(g.id) && !typingHere) return;
|
|
783
1201
|
app.appendChild(groupBlock(g, off, onlyOpen));
|
|
@@ -789,16 +1207,24 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
789
1207
|
return function (snap) {
|
|
790
1208
|
var next = {};
|
|
791
1209
|
snap.docs.forEach(function (d) { next[d.id] = d.data(); });
|
|
792
|
-
state[target] = next;
|
|
1210
|
+
state.raw[target] = next;
|
|
1211
|
+
state[target] = mineOf(next);
|
|
793
1212
|
state.loaded[loadedKey] = true;
|
|
794
1213
|
render();
|
|
795
1214
|
};
|
|
796
1215
|
}
|
|
797
1216
|
function broken() { state.store = "none"; render(); }
|
|
798
1217
|
|
|
1218
|
+
// Hidden: every clock stops. Back: the cards are watched afresh, because a change of tab alone
|
|
1219
|
+
// fires no intersection, and a card drawn while the tab was hidden never started its clock.
|
|
799
1220
|
document.addEventListener("visibilitychange", function () {
|
|
800
|
-
if (document.visibilityState !== "visible")
|
|
1221
|
+
if (document.visibilityState !== "visible") {
|
|
1222
|
+
flushReasons();
|
|
1223
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
1224
|
+
} else if (MIN_SECONDS) watchCards();
|
|
801
1225
|
});
|
|
1226
|
+
window.addEventListener("resize", function () { if (MIN_SECONDS) watchCards(); });
|
|
1227
|
+
if (MIN_SECONDS) tick();
|
|
802
1228
|
window.addEventListener("pagehide", flushReasons);
|
|
803
1229
|
render();
|
|
804
1230
|
if (!window.claude || typeof window.claude.use !== "function") { broken(); return; }
|