paperlint 14.0.0 → 14.2.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/dist/skills/build-label-page/scripts/label-page-core.d.ts +105 -25
- package/dist/skills/build-label-page/scripts/label-page-presets.d.ts +10 -0
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +20 -2
- package/skills/build-label-page/SKILL.md.spec.ts +19 -1
- package/skills/build-label-page/references/closed-choice-generator.md +171 -5
- package/skills/build-label-page/scripts/fixtures/blocks-items.json +214 -0
- package/skills/build-label-page/scripts/fixtures/linter-config-witness-items.json +448 -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 +335 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +637 -2
- package/skills/build-label-page/scripts/label-page-core.js +360 -70
- package/skills/build-label-page/scripts/label-page-core.test.ts +78 -11
- package/skills/build-label-page/scripts/label-page-core.ts +585 -107
- package/skills/build-label-page/scripts/label-page-html.test.ts +22 -0
- package/skills/build-label-page/scripts/label-page-presets.js +222 -0
- package/skills/build-label-page/scripts/label-page-presets.test.ts +175 -0
- package/skills/build-label-page/scripts/label-page-presets.ts +257 -0
- package/skills/build-label-page/scripts/label-page.js +13 -2
- package/skills/build-label-page/scripts/label-page.ts +13 -2
- package/skills/build-label-page/scripts/page-template.html +438 -44
|
@@ -82,6 +82,29 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
82
82
|
background: var(--good); margin-right: 8px; vertical-align: middle; }
|
|
83
83
|
.status.missing { color: var(--mark); }
|
|
84
84
|
.status.failed { color: var(--bad); }
|
|
85
|
+
/* The content blocks (#380): a code excerpt with its line numbers and the marked line lit, references,
|
|
86
|
+
lists and tables folded away. A long text shows three lines and a «more». */
|
|
87
|
+
a { color: var(--accent); }
|
|
88
|
+
summary { cursor: pointer; color: var(--accent); min-height: 44px; display: flex; align-items: center; gap: 8px; }
|
|
89
|
+
.situation { color: var(--strong); font-weight: 500; }
|
|
90
|
+
pre.numbered { white-space: normal; padding: 8px 0; }
|
|
91
|
+
pre.numbered .line { display: grid; grid-template-columns: 2ch 5ch 1fr; white-space: pre-wrap; padding: 0 10px 0 0; }
|
|
92
|
+
/* The marked line reads as marked without its colour: a marker in the gutter and a label after the code. */
|
|
93
|
+
pre.numbered .g { color: var(--mark); text-align: center; user-select: none; }
|
|
94
|
+
pre.numbered .this { color: var(--mark); font-weight: 700; margin-left: 1ch; white-space: nowrap; user-select: none; }
|
|
95
|
+
pre.numbered .n { color: var(--muted); text-align: right; padding-right: 1.5ch; user-select: none; }
|
|
96
|
+
pre.numbered .hit { background: var(--mark-bg); }
|
|
97
|
+
pre.numbered .hit .n { color: var(--mark); font-weight: 700; }
|
|
98
|
+
.rulebox { display: flex; flex-direction: column; gap: 8px; }
|
|
99
|
+
.clamped { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
100
|
+
/* The clamp cuts at the padding box, so a code block's bottom padding is a border: no fourth line peeks in. */
|
|
101
|
+
pre.clamped { padding-bottom: 0; border-bottom: 10px solid var(--surface); box-shadow: 0 1px 0 var(--line); }
|
|
102
|
+
button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; align-self: flex-start; }
|
|
103
|
+
.setting { display: flex; flex-direction: column; gap: 2px; padding-top: 12px; border-top: 1px solid var(--line); }
|
|
104
|
+
.match.yes { color: var(--good); }
|
|
105
|
+
.match.no { color: var(--bad); }
|
|
106
|
+
.who { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); }
|
|
107
|
+
.who button { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; }
|
|
85
108
|
</style>
|
|
86
109
|
<main id="app"><p class="muted">Loading…</p></main>
|
|
87
110
|
<script type="application/json" id="data">__DATA__</script>
|
|
@@ -100,24 +123,160 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
100
123
|
whyTimers: {}, // "<item id>|<question id>" -> the pending save of a reason being typed
|
|
101
124
|
whyBoxes: {}, // "<item id>|<question id>" -> the reason box of the last render
|
|
102
125
|
typingIn: null, // the item whose reason box has the keyboard: shown even when complete
|
|
126
|
+
lastAnswered: null, // the item answered last: shown under "Next unanswered" until the next answer
|
|
127
|
+
pending: {}, // item id -> answers chosen whose reason is still empty: NOT in the store
|
|
128
|
+
refusedWhy: {}, // item id -> true when an empty reason was refused for its answer
|
|
103
129
|
mine: {}, // item/group key -> the answers this page has just written (ahead of the store)
|
|
104
130
|
inflight: {}, // item/group key -> writes not yet confirmed
|
|
105
131
|
failed: {}, // item/group key -> error code of the last refused write
|
|
106
132
|
loaded: { answers: false, groups: false, practice: false },
|
|
107
133
|
store: "loading", // loading | ok | none
|
|
108
|
-
filter: "all"
|
|
134
|
+
filter: "all",
|
|
135
|
+
who: null, // the answerer picked, on a page with DATA.answerers; null until one is
|
|
136
|
+
raw: { answers: {}, groups: {}, practice: {} }, // every document of the store, every answerer's
|
|
137
|
+
dwell: {}, // item id -> ms its card was on screen, clocks stopped (DATA.min_seconds)
|
|
138
|
+
seenSince: {}, // item id -> when its card came on screen, while it is
|
|
139
|
+
firstSeconds: {}, // item id -> the seconds this page stored with the item's first save
|
|
140
|
+
gates: {} // item id -> the locked buttons and the countdown of the last render
|
|
109
141
|
};
|
|
110
142
|
var db = null;
|
|
111
143
|
var chains = {};
|
|
112
144
|
var WHY_PAUSE_MS = 800;
|
|
113
145
|
|
|
114
146
|
try { if (localStorage.getItem("label-filter") === "next") state.filter = "next"; } catch (e) {}
|
|
147
|
+
// ── who is answering (DATA.answerers): each answerer's documents are "<answerer>~<id>" ─────
|
|
148
|
+
// "~" is outside the build's name pattern, so no answerer, item, group or practice id holds it.
|
|
149
|
+
var SEP = "~";
|
|
150
|
+
var ANSWERERS = DATA.answerers || [];
|
|
151
|
+
var ASKS_WHO = ANSWERERS.length > 0;
|
|
152
|
+
try {
|
|
153
|
+
var remembered = localStorage.getItem("label-answerer");
|
|
154
|
+
if (ANSWERERS.indexOf(remembered) !== -1) state.who = remembered;
|
|
155
|
+
} catch (e) {}
|
|
156
|
+
// The store's id of one of this page's documents: as before on a page without answerers.
|
|
157
|
+
function docName(id) { return ASKS_WHO ? state.who + SEP + id : id; }
|
|
158
|
+
// The documents of the current answerer, by their names without the answerer.
|
|
159
|
+
function mineOf(docs) {
|
|
160
|
+
if (!ASKS_WHO) return docs;
|
|
161
|
+
var out = {}, prefix = state.who + SEP;
|
|
162
|
+
if (state.who) Object.keys(docs).forEach(function (k) { if (k.indexOf(prefix) === 0) out[k.slice(prefix.length)] = docs[k]; });
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
// The items shown to the current answerer: an item's own `answerers`, else everyone's.
|
|
166
|
+
function itemsShown() {
|
|
167
|
+
return DATA.items.filter(function (i) { return !i.answerers || i.answerers.indexOf(state.who) !== -1; });
|
|
168
|
+
}
|
|
169
|
+
function groupsShown() { return DATA.groups.filter(function (g) { return groupItems(g.id).length > 0; }); }
|
|
170
|
+
function busy() {
|
|
171
|
+
return Object.keys(state.inflight).some(function (k) { return state.inflight[k] > 0; }) || Object.keys(state.whyTimers).length > 0;
|
|
172
|
+
}
|
|
173
|
+
// Picking (or switching) the answerer: everything this page holds of the last one is dropped.
|
|
174
|
+
function choose(name) {
|
|
175
|
+
if (busy()) return;
|
|
176
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
177
|
+
state.who = name;
|
|
178
|
+
try {
|
|
179
|
+
if (name) localStorage.setItem("label-answerer", name);
|
|
180
|
+
else localStorage.removeItem("label-answerer");
|
|
181
|
+
} catch (e) {}
|
|
182
|
+
["editing", "mineWhy", "draftWhy", "pending", "refusedWhy", "mine", "failed", "dwell", "seenSince", "firstSeconds"]
|
|
183
|
+
.forEach(function (k) { state[k] = {}; });
|
|
184
|
+
state.lastAnswered = null;
|
|
185
|
+
["answers", "groups", "practice"].forEach(function (c) { state[c] = mineOf(state.raw[c]); });
|
|
186
|
+
render();
|
|
187
|
+
}
|
|
188
|
+
// ── how long a card was on screen (DATA.min_seconds): its answer saves only after that long ──
|
|
189
|
+
// On screen: half the card in view, or half the viewport filled by a card taller than it.
|
|
190
|
+
var MIN_SECONDS = DATA.min_seconds || 0;
|
|
191
|
+
function dwellMs(id) {
|
|
192
|
+
var ms = state.dwell[id] || 0;
|
|
193
|
+
return state.seenSince[id] !== undefined ? ms + Date.now() - state.seenSince[id] : ms;
|
|
194
|
+
}
|
|
195
|
+
function stopClock(id) {
|
|
196
|
+
if (state.seenSince[id] === undefined) return;
|
|
197
|
+
state.dwell[id] = dwellMs(id);
|
|
198
|
+
delete state.seenSince[id];
|
|
199
|
+
}
|
|
200
|
+
function startClock(id) { if (state.seenSince[id] === undefined) state.seenSince[id] = Date.now(); }
|
|
201
|
+
// An item already saved (by this page or in the store) is never locked again.
|
|
202
|
+
function unsaved(id) { return !state.answers[id] && state.firstSeconds[id] === undefined; }
|
|
203
|
+
function locked(id) { return MIN_SECONDS > 0 && unsaved(id) && dwellMs(id) < MIN_SECONDS * 1000; }
|
|
204
|
+
function waitText(id) {
|
|
205
|
+
return "\u23f3 You can answer in " + Math.ceil((MIN_SECONDS * 1000 - dwellMs(id)) / 1000) + " s";
|
|
206
|
+
}
|
|
207
|
+
// The seconds stored with an item: those of its first save, kept on every later write.
|
|
208
|
+
function secondsOf(id) {
|
|
209
|
+
var d = state.answers[id];
|
|
210
|
+
if (d && typeof d.seconds === "number") return d.seconds;
|
|
211
|
+
if (state.firstSeconds[id] === undefined) state.firstSeconds[id] = Math.floor(dwellMs(id) / 1000);
|
|
212
|
+
return state.firstSeconds[id];
|
|
213
|
+
}
|
|
214
|
+
// An observer reports only when a card crosses one of its thresholds, so each card gets its own
|
|
215
|
+
// observer whose threshold is that card's share: half the card, or half the viewport for a card
|
|
216
|
+
// taller than it. The share follows the card's height and the viewport's: a card that changes
|
|
217
|
+
// size, or a resized window, is watched again with its new share.
|
|
218
|
+
var observers = [];
|
|
219
|
+
var sizes = null;
|
|
220
|
+
function share(h) { return h > 0 ? Math.min(h, window.innerHeight) / 2 / h : 0.5; }
|
|
221
|
+
function onScreen(entry) {
|
|
222
|
+
var h = entry.boundingClientRect.height;
|
|
223
|
+
return entry.isIntersecting && h > 0 &&
|
|
224
|
+
entry.intersectionRect.height >= Math.min(h, window.innerHeight) / 2 - 0.5;
|
|
225
|
+
}
|
|
226
|
+
function watchCard(card) {
|
|
227
|
+
var id = card.getAttribute("data-id");
|
|
228
|
+
var o = new IntersectionObserver(function (entries) {
|
|
229
|
+
entries.forEach(function (e) {
|
|
230
|
+
if (onScreen(e) && document.visibilityState !== "hidden") startClock(id);
|
|
231
|
+
else stopClock(id);
|
|
232
|
+
});
|
|
233
|
+
}, { threshold: [0, share(card.getBoundingClientRect().height)] });
|
|
234
|
+
o.observe(card);
|
|
235
|
+
return o;
|
|
236
|
+
}
|
|
237
|
+
// Every render draws new cards: the clocks stop, and the new cards are watched from scratch.
|
|
238
|
+
function watchCards() {
|
|
239
|
+
observers.forEach(function (o) { o.disconnect(); });
|
|
240
|
+
observers = [];
|
|
241
|
+
if (sizes) sizes.disconnect();
|
|
242
|
+
sizes = null;
|
|
243
|
+
var cards = Array.prototype.slice.call(document.querySelectorAll("article.item[data-id]"));
|
|
244
|
+
if (typeof IntersectionObserver !== "function") {
|
|
245
|
+
// No way to tell what is in view: a card counts as on screen from the moment it is drawn.
|
|
246
|
+
cards.forEach(function (c) { startClock(c.getAttribute("data-id")); });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
observers = cards.map(watchCard);
|
|
250
|
+
if (typeof ResizeObserver !== "function") return;
|
|
251
|
+
var heights = cards.map(function (c) { return c.getBoundingClientRect().height; });
|
|
252
|
+
sizes = new ResizeObserver(function (entries) {
|
|
253
|
+
entries.forEach(function (e) {
|
|
254
|
+
var i = cards.indexOf(e.target);
|
|
255
|
+
var h = e.target.getBoundingClientRect().height;
|
|
256
|
+
if (i === -1 || Math.abs(h - heights[i]) < 1) return;
|
|
257
|
+
heights[i] = h;
|
|
258
|
+
observers[i].disconnect();
|
|
259
|
+
observers[i] = watchCard(cards[i]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
cards.forEach(function (c) { sizes.observe(c); });
|
|
263
|
+
}
|
|
264
|
+
// The countdown on each locked card, and its buttons let go when its time is up; no render, so
|
|
265
|
+
// a section the labeller opened stays open.
|
|
266
|
+
function tick() {
|
|
267
|
+
Object.keys(state.gates).forEach(function (id) {
|
|
268
|
+
var g = state.gates[id];
|
|
269
|
+
if (locked(id)) { g.note.textContent = waitText(id); return; }
|
|
270
|
+
g.note.textContent = "";
|
|
271
|
+
g.buttons.forEach(function (b) { b.disabled = !canWrite(); });
|
|
272
|
+
delete state.gates[id];
|
|
273
|
+
});
|
|
274
|
+
setTimeout(tick, 250);
|
|
275
|
+
}
|
|
115
276
|
|
|
116
277
|
// ── what "done" means ────────────────────────────────────────────────────────────────────
|
|
117
278
|
function answersOf(id) {
|
|
118
|
-
|
|
119
|
-
var d = state.answers[id];
|
|
120
|
-
return d && d.answers ? d.answers : {};
|
|
279
|
+
return state.pending[id] || savedOf(id);
|
|
121
280
|
}
|
|
122
281
|
// A stored value is an answer only if the build offers it (DATA.allowed, the same lists the
|
|
123
282
|
// export reads from key.json): after a rebuild that removed an option, or a typo merged from chat,
|
|
@@ -141,6 +300,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
141
300
|
return d[qid];
|
|
142
301
|
}
|
|
143
302
|
function needsWhy(q, v) { return (DATA.allowed.needs_why[q.id] || []).indexOf(v) !== -1; }
|
|
303
|
+
function questionOf(qid) { return DATA.questions.filter(function (q) { return q.id === qid; })[0]; }
|
|
144
304
|
// What an item still needs: an answer, or the reason its answer asks for (named "<short> (why)").
|
|
145
305
|
function missingFor(id) {
|
|
146
306
|
var a = answersOf(id), r = reasonsOf(id);
|
|
@@ -161,7 +321,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
161
321
|
var d = state.mine["group:" + g] || state.groups[g];
|
|
162
322
|
return d && valid(DATA.allowed.familiarity, d.familiarity) ? d.familiarity : undefined;
|
|
163
323
|
}
|
|
164
|
-
function groupItems(g) { return
|
|
324
|
+
function groupItems(g) { return itemsShown().filter(function (i) { return i.group === g; }); }
|
|
165
325
|
// ── the practice (rule 8): known answers, before the real items ──────────────────────────
|
|
166
326
|
function practiceDoc(pid) { return "r" + DATA.practice.round + "-" + pid; }
|
|
167
327
|
function practiceAnswers(pid) {
|
|
@@ -186,7 +346,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
186
346
|
function write(key, coll, docId, body) {
|
|
187
347
|
state.inflight[key] = (state.inflight[key] || 0) + 1;
|
|
188
348
|
delete state.failed[key];
|
|
189
|
-
var step = function () { return db.collection(coll).doc(docId).set(body); };
|
|
349
|
+
var step = function () { return db.collection(coll).doc(docName(docId)).set(body); };
|
|
190
350
|
var run = (chains[key] || Promise.resolve()).then(step, step);
|
|
191
351
|
chains[key] = run.then(function () {
|
|
192
352
|
state.inflight[key] -= 1;
|
|
@@ -200,27 +360,76 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
200
360
|
});
|
|
201
361
|
}
|
|
202
362
|
function canWrite() {
|
|
203
|
-
return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok"
|
|
363
|
+
return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok" &&
|
|
364
|
+
(!ASKS_WHO || state.who !== null);
|
|
365
|
+
}
|
|
366
|
+
// An answer that asks why is not written while its reason is empty (#380): it waits on the page, and
|
|
367
|
+
// the store keeps what it had, so an export never meets an answer without the reason it needs. Every
|
|
368
|
+
// write leaves out each such answer, not only the one just picked: on a page of several questions the
|
|
369
|
+
// next answer would otherwise carry the waiting one into the store without its reason.
|
|
370
|
+
// A waiting answer that replaces a saved one leaves the saved one in the write until its reason comes.
|
|
371
|
+
// A stored answer to a question this page no longer asks (removed or renamed between builds) is kept
|
|
372
|
+
// as it was and never checked: the page has no question to check it against.
|
|
373
|
+
function resolved(answers, reasons, saved) {
|
|
374
|
+
var out = {}, waiting = false;
|
|
375
|
+
Object.keys(answers).forEach(function (q) {
|
|
376
|
+
var asked = questionOf(q);
|
|
377
|
+
if (!(asked && needsWhy(asked, answers[q]) && !(reasons[q] || "").trim())) out[q] = answers[q];
|
|
378
|
+
else {
|
|
379
|
+
waiting = true;
|
|
380
|
+
if (Object.prototype.hasOwnProperty.call(saved, q)) out[q] = saved[q];
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
return { ready: out, waiting: waiting };
|
|
384
|
+
}
|
|
385
|
+
// What the store holds for an item, or what this page last wrote to it ahead of the store.
|
|
386
|
+
function savedOf(id) {
|
|
387
|
+
if (state.mine[id]) return state.mine[id];
|
|
388
|
+
var d = state.answers[id];
|
|
389
|
+
return d && d.answers ? d.answers : {};
|
|
390
|
+
}
|
|
391
|
+
function keep(id, next, reasons) {
|
|
392
|
+
var r = resolved(next, reasons, savedOf(id)), ready = r.ready;
|
|
393
|
+
if (r.waiting) state.pending[id] = next;
|
|
394
|
+
else delete state.pending[id];
|
|
395
|
+
delete state.refusedWhy[id];
|
|
396
|
+
state.mine[id] = ready;
|
|
397
|
+
var body = { answers: ready, reasons: reasons, savedAt: new Date().toISOString(), source: "page" };
|
|
398
|
+
if (MIN_SECONDS) body.seconds = secondsOf(id);
|
|
399
|
+
write(id, "answers", id, body);
|
|
400
|
+
render();
|
|
204
401
|
}
|
|
205
402
|
function answer(id, qid, value) {
|
|
206
|
-
if (!canWrite()) return;
|
|
403
|
+
if (!canWrite() || locked(id)) return;
|
|
207
404
|
var next = Object.assign({}, answersOf(id));
|
|
208
405
|
next[qid] = value;
|
|
209
|
-
state.
|
|
210
|
-
|
|
211
|
-
|
|
406
|
+
state.lastAnswered = id;
|
|
407
|
+
var draft = draftOf(id, qid);
|
|
408
|
+
var why = draft !== undefined ? draft : reasonsOf(id)[qid] || "";
|
|
409
|
+
if (needsWhy(questionOf(qid), value) && !why.trim()) {
|
|
410
|
+
state.pending[id] = next;
|
|
411
|
+
render();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
var reasons = Object.assign({}, reasonsOf(id));
|
|
415
|
+
if (why.trim()) reasons[qid] = why;
|
|
416
|
+
keep(id, next, reasons);
|
|
212
417
|
}
|
|
213
|
-
// A reason saves when the labeller leaves the box (the text equivalent of a tap).
|
|
418
|
+
// A reason saves when the labeller leaves the box (the text equivalent of a tap); an empty one is refused.
|
|
214
419
|
function setReason(id, qid, text) {
|
|
215
420
|
clearTimeout(state.whyTimers[id + "|" + qid]);
|
|
216
421
|
delete state.whyTimers[id + "|" + qid];
|
|
217
422
|
if (!canWrite()) return;
|
|
423
|
+
var a = answersOf(id);
|
|
424
|
+
if (needsWhy(questionOf(qid), a[qid]) && !text.trim()) {
|
|
425
|
+
state.refusedWhy[id] = true;
|
|
426
|
+
render();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
218
429
|
var why = Object.assign({}, reasonsOf(id));
|
|
219
430
|
why[qid] = text;
|
|
220
431
|
state.mineWhy[id] = why;
|
|
221
|
-
|
|
222
|
-
write(id, "answers", id, { answers: answersOf(id), reasons: why, savedAt: new Date().toISOString(), source: "page" });
|
|
223
|
-
render();
|
|
432
|
+
keep(id, Object.assign({}, a), why);
|
|
224
433
|
}
|
|
225
434
|
// The first answer to a practice question is the one scored: it cannot be changed afterwards.
|
|
226
435
|
function answerPractice(pid, qid, value) {
|
|
@@ -257,8 +466,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
257
466
|
});
|
|
258
467
|
return n;
|
|
259
468
|
}
|
|
260
|
-
// `withExamples`: the practice shows each answer's example on its button
|
|
261
|
-
// short, the examples stay on the first screen.
|
|
469
|
+
// `withExamples`: the practice shows each answer's example on its button, after `labels.practice_example`;
|
|
470
|
+
// the real items keep the buttons short, the examples stay on the first screen.
|
|
262
471
|
// A code block shows what a labeller would otherwise count by eye: leading and trailing whitespace as
|
|
263
472
|
// faint marks (a space as a middle dot, a tab as an arrow) and each line's length in characters.
|
|
264
473
|
function visible(ws) { return ws.replace(/ /g, "\u00b7").replace(/\t/g, "\u2192"); }
|
|
@@ -281,7 +490,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
281
490
|
var b = el("button", "");
|
|
282
491
|
if (o.icon) b.appendChild(el("span", "icon", o.icon));
|
|
283
492
|
b.appendChild(el("span", "label", o.label));
|
|
284
|
-
if (withExamples && o.example) b.appendChild(rich("span", "ex", o.example));
|
|
493
|
+
if (withExamples && o.example) b.appendChild(rich("span", "ex", DATA.labels.practice_example + o.example));
|
|
285
494
|
b.type = "button";
|
|
286
495
|
b.setAttribute("aria-pressed", String(current === o.value));
|
|
287
496
|
b.disabled = disabled;
|
|
@@ -296,7 +505,10 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
296
505
|
}
|
|
297
506
|
function statusLine(key, missing, savedAt) {
|
|
298
507
|
var s = el("p", "status");
|
|
299
|
-
if (state.
|
|
508
|
+
if (state.pending[key] || state.refusedWhy[key]) {
|
|
509
|
+
s.className = "status missing";
|
|
510
|
+
s.textContent = "Not saved yet: this answer needs a reason. Write it above, or pick another answer.";
|
|
511
|
+
} else if (state.failed[key]) {
|
|
300
512
|
s.className = "status failed";
|
|
301
513
|
s.textContent = "Not saved (" + state.failed[key] + "). Tap the answer again.";
|
|
302
514
|
} else if (state.inflight[key]) {
|
|
@@ -312,15 +524,21 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
312
524
|
return s;
|
|
313
525
|
}
|
|
314
526
|
function itemCard(item, off) {
|
|
315
|
-
var done = itemDone(item.id);
|
|
316
527
|
var card = el("article", "item");
|
|
317
|
-
|
|
528
|
+
var shown = itemsShown();
|
|
529
|
+
if (MIN_SECONDS && unsaved(item.id)) card.setAttribute("data-id", item.id);
|
|
530
|
+
card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: shown.indexOf(item) + 1, total: shown.length })));
|
|
318
531
|
evidence(card, item);
|
|
319
532
|
var a = answersOf(item.id);
|
|
320
|
-
|
|
533
|
+
var gate = locked(item.id);
|
|
534
|
+
var ask = askOne(card, a, state.editing[item.id], function (q, v) {
|
|
321
535
|
delete state.editing[item.id];
|
|
322
536
|
answer(item.id, q.id, v);
|
|
323
|
-
}, function (q) { state.editing[item.id] = q.id; render(); }, off);
|
|
537
|
+
}, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item.situation);
|
|
538
|
+
if (gate && ask) {
|
|
539
|
+
var note = ask.appendChild(el("p", "status missing", waitText(item.id)));
|
|
540
|
+
state.gates[item.id] = { note: note, buttons: Array.prototype.slice.call(ask.querySelectorAll("button")) };
|
|
541
|
+
}
|
|
324
542
|
DATA.questions.forEach(function (q) {
|
|
325
543
|
var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
|
|
326
544
|
if (!o) return;
|
|
@@ -344,6 +562,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
344
562
|
});
|
|
345
563
|
var saved = state.answers[item.id] && state.answers[item.id].savedAt;
|
|
346
564
|
card.appendChild(statusLine(item.id, missingFor(item.id), saved));
|
|
565
|
+
blocksBelow(card, item);
|
|
347
566
|
return card;
|
|
348
567
|
}
|
|
349
568
|
// A labelled row: a short word, then what it labels.
|
|
@@ -361,17 +580,136 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
361
580
|
p.appendChild(rich("span", "", u.text.slice(at + u.mark.length)));
|
|
362
581
|
return p;
|
|
363
582
|
}
|
|
583
|
+
// A situation's text with each `{line}` written out: «line N: » and the marked line's code as code
|
|
584
|
+
// (the build passes `line` whenever the text holds the token).
|
|
585
|
+
function situated(tag, cls, text, line) {
|
|
586
|
+
var n = el(tag, cls);
|
|
587
|
+
String(text).split("{line}").forEach(function (part, i) {
|
|
588
|
+
if (i) {
|
|
589
|
+
n.appendChild(el("span", "", "line " + line.n + ": "));
|
|
590
|
+
n.appendChild(el("code", "", line.text));
|
|
591
|
+
}
|
|
592
|
+
if (part) n.appendChild(rich("span", "", part));
|
|
593
|
+
});
|
|
594
|
+
return n;
|
|
595
|
+
}
|
|
596
|
+
// A long text shows its first three lines and a «more» that opens the rest.
|
|
597
|
+
function clamp(node, text) {
|
|
598
|
+
if (String(text).length < 200 && String(text).split("\n").length <= 3) return node;
|
|
599
|
+
var wrap = el("div", "rulebox");
|
|
600
|
+
node.className += " clamped";
|
|
601
|
+
var more = el("button", "more", "more");
|
|
602
|
+
more.type = "button";
|
|
603
|
+
more.addEventListener("click", function () {
|
|
604
|
+
node.className = node.className.replace(" clamped", "");
|
|
605
|
+
wrap.removeChild(more);
|
|
606
|
+
});
|
|
607
|
+
wrap.appendChild(node);
|
|
608
|
+
wrap.appendChild(more);
|
|
609
|
+
return wrap;
|
|
610
|
+
}
|
|
611
|
+
function link(text, url) {
|
|
612
|
+
var a = el("a", "", text);
|
|
613
|
+
a.href = url;
|
|
614
|
+
a.target = "_blank";
|
|
615
|
+
a.rel = "noopener";
|
|
616
|
+
return a;
|
|
617
|
+
}
|
|
618
|
+
function plainCode(text, cls) {
|
|
619
|
+
var pre = el("pre", cls || "");
|
|
620
|
+
pre.textContent = text;
|
|
621
|
+
return pre;
|
|
622
|
+
}
|
|
623
|
+
// `{name}` in a page label, from `vars`.
|
|
624
|
+
function fill(text, vars) {
|
|
625
|
+
return String(text).replace(/\{(\w+)\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
|
|
626
|
+
}
|
|
627
|
+
// A code excerpt: its file (a link when there is one), its lines numbered, the marked line with a
|
|
628
|
+
// gutter marker and a label besides its background, and the caption under it.
|
|
629
|
+
function excerptBlock(ex) {
|
|
630
|
+
var box = el("div", "rulebox");
|
|
631
|
+
if (ex.file) {
|
|
632
|
+
var name = el("p");
|
|
633
|
+
name.appendChild(ex.url ? link(ex.file, ex.url) : el("code", "", ex.file));
|
|
634
|
+
box.appendChild(name);
|
|
635
|
+
}
|
|
636
|
+
var pre = el("pre", "numbered");
|
|
637
|
+
ex.lines.forEach(function (l) {
|
|
638
|
+
var line = el("div", "line" + (l.mark ? " hit" : ""));
|
|
639
|
+
line.appendChild(el("span", "g", l.mark ? "\u25b6" : ""));
|
|
640
|
+
line.appendChild(el("span", "n", String(l.n)));
|
|
641
|
+
var code = line.appendChild(el("span", "", l.text));
|
|
642
|
+
if (l.mark) code.appendChild(el("span", "this", ex.mark_label || "\u25c0 this line"));
|
|
643
|
+
pre.appendChild(line);
|
|
644
|
+
});
|
|
645
|
+
box.appendChild(pre);
|
|
646
|
+
if (ex.caption) box.appendChild(rich("p", "said", ex.caption));
|
|
647
|
+
return box;
|
|
648
|
+
}
|
|
649
|
+
// A reference entry: its title, then each field (a paragraph, a code block on a labelled row, or a
|
|
650
|
+
// quiet note), then its link.
|
|
651
|
+
function referenceBlock(r) {
|
|
652
|
+
var box = el("div", "rulebox");
|
|
653
|
+
box.appendChild(rich("p", "", r.title));
|
|
654
|
+
r.fields.forEach(function (f) {
|
|
655
|
+
if (f.style === "note") box.appendChild(el("p", "muted", f.text));
|
|
656
|
+
else if (f.style === "code") box.appendChild(row(f.label || "", clamp(plainCode(f.text), f.text)));
|
|
657
|
+
else box.appendChild(clamp(rich("p", "", (f.label ? f.label + ": " : "") + f.text), f.text));
|
|
658
|
+
});
|
|
659
|
+
if (r.url) box.appendChild(el("p")).appendChild(link(r.url_label || r.url, r.url));
|
|
660
|
+
return box;
|
|
661
|
+
}
|
|
662
|
+
function folded(summary) {
|
|
663
|
+
var d = el("details");
|
|
664
|
+
d.appendChild(el("summary", "", summary));
|
|
665
|
+
return d;
|
|
666
|
+
}
|
|
667
|
+
function references(x) {
|
|
668
|
+
var d = folded(x.summary);
|
|
669
|
+
x.entries.forEach(function (r) { d.appendChild(row(r.label || "", referenceBlock(r))); });
|
|
670
|
+
return d;
|
|
671
|
+
}
|
|
672
|
+
function moreList(m) {
|
|
673
|
+
var d = folded(m.summary);
|
|
674
|
+
var ul = el("ul");
|
|
675
|
+
m.items.forEach(function (f) { ul.appendChild(rich("li", "", f)); });
|
|
676
|
+
if (m.rest) ul.appendChild(el("li", "muted", m.rest));
|
|
677
|
+
d.appendChild(ul);
|
|
678
|
+
return d;
|
|
679
|
+
}
|
|
680
|
+
// A folded table: its note, then each row in the order given: label, value as code, plain words, mark.
|
|
681
|
+
function table(t) {
|
|
682
|
+
var d = folded(t.summary);
|
|
683
|
+
if (t.note) d.appendChild(el("p", "muted", t.note));
|
|
684
|
+
t.rows.forEach(function (r) {
|
|
685
|
+
var s = el("div", "setting");
|
|
686
|
+
s.appendChild(el("span", "lbl", r.label));
|
|
687
|
+
s.appendChild(el("p")).appendChild(el("code", "", r.value));
|
|
688
|
+
if (r.plain) s.appendChild(rich("p", "", r.plain));
|
|
689
|
+
if (r.mark) s.appendChild(el("p", "match " + r.mark.tone, r.mark.text));
|
|
690
|
+
d.appendChild(s);
|
|
691
|
+
});
|
|
692
|
+
return d;
|
|
693
|
+
}
|
|
694
|
+
// Below the question and its answers: the references, the list of more like it and the table, each
|
|
695
|
+
// folded, so the question sits right under the code it names.
|
|
696
|
+
function blocksBelow(card, item) {
|
|
697
|
+
if (item.references) card.appendChild(references(item.references));
|
|
698
|
+
if (item.more) card.appendChild(moreList(item.more));
|
|
699
|
+
if (item.table) card.appendChild(table(item.table));
|
|
700
|
+
}
|
|
364
701
|
// What a card shows above its question, one labelled row per field (an item and a practice item alike).
|
|
365
702
|
function evidence(card, item) {
|
|
366
703
|
if (item.title) card.appendChild(rich("h3", "", item.title));
|
|
367
|
-
|
|
704
|
+
var body = rich("p", "body", item.body);
|
|
705
|
+
card.appendChild(DATA.labels.body ? row(DATA.labels.body, body) : body);
|
|
706
|
+
if (item.repeat && item.repeat.of > 1) card.appendChild(el("p", "muted", fill(DATA.labels.repeat, item.repeat)));
|
|
368
707
|
if (item.code) card.appendChild(codeBlock(item.code));
|
|
369
|
-
if (item.unit) card.appendChild(row(item.unit.of > 1 ?
|
|
708
|
+
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,
|
|
370
709
|
markedLine(item.unit)));
|
|
371
710
|
// A rule the item names, with what it does in the linter's own words (looked up at build time).
|
|
372
711
|
(item.rules || []).forEach(function (r) {
|
|
373
|
-
card.appendChild(row("Rule", rich("p", "", r.linter + " `" + r.id + "`" + (r.family ? " (" + r.family + ")" : "") +
|
|
374
|
-
": " + r.description)));
|
|
712
|
+
card.appendChild(row("Rule", rich("p", "", r.linter + " `" + r.id + "`" + (r.family ? " (" + r.family + ")" : "") + ": " + r.description)));
|
|
375
713
|
});
|
|
376
714
|
if (item.readings) {
|
|
377
715
|
card.appendChild(row("Reading A", rich("p", "", item.readings.a), "reading a"));
|
|
@@ -379,6 +717,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
379
717
|
}
|
|
380
718
|
if (item.file) card.appendChild(row("File", rich("p", "", item.file)));
|
|
381
719
|
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));
|
|
382
722
|
}
|
|
383
723
|
function labelOf(q, v) {
|
|
384
724
|
var o = q.options.filter(function (x) { return x.value === v; })[0];
|
|
@@ -386,7 +726,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
386
726
|
}
|
|
387
727
|
// One question per screen: the answered ones stay as a line (with "Change"), and only one question
|
|
388
728
|
// is asked: the one being changed, else the first unanswered.
|
|
389
|
-
function askOne(card, a, editing, onPick, onChange, off, withExamples) {
|
|
729
|
+
function askOne(card, a, editing, onPick, onChange, off, withExamples, situation) {
|
|
390
730
|
var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
|
|
391
731
|
var asked = DATA.questions.filter(function (q) { return q.id === editing; })[0] || open[0];
|
|
392
732
|
DATA.questions.forEach(function (q) {
|
|
@@ -401,17 +741,25 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
401
741
|
}
|
|
402
742
|
card.appendChild(line);
|
|
403
743
|
});
|
|
404
|
-
if (!asked) return;
|
|
744
|
+
if (!asked) return null;
|
|
405
745
|
var ask = el("div", "ask");
|
|
406
|
-
|
|
746
|
+
// 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));
|
|
407
748
|
ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples));
|
|
408
749
|
card.appendChild(ask);
|
|
750
|
+
return ask;
|
|
409
751
|
}
|
|
752
|
+
// Open until the practice is done; then folded into a toggle, so the cards start near the top.
|
|
410
753
|
function howTo() {
|
|
411
754
|
var sec = el("section");
|
|
412
|
-
|
|
755
|
+
var body = sec;
|
|
756
|
+
if (practiceOpen()) sec.appendChild(el("h2", "", "📖 How to answer"));
|
|
757
|
+
else {
|
|
758
|
+
body = sec.appendChild(el("details"));
|
|
759
|
+
body.appendChild(el("summary", "", "📖 How to answer"));
|
|
760
|
+
}
|
|
413
761
|
DATA.questions.forEach(function (q) {
|
|
414
|
-
|
|
762
|
+
body.appendChild(el("p", "q", q.text));
|
|
415
763
|
var list = el("div", "examples");
|
|
416
764
|
q.options.forEach(function (o) {
|
|
417
765
|
var row = el("div", "example");
|
|
@@ -419,7 +767,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
419
767
|
row.appendChild(rich("span", "muted", "For example: " + o.example));
|
|
420
768
|
list.appendChild(row);
|
|
421
769
|
});
|
|
422
|
-
|
|
770
|
+
body.appendChild(list);
|
|
423
771
|
});
|
|
424
772
|
return sec;
|
|
425
773
|
}
|
|
@@ -428,13 +776,14 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
428
776
|
card.appendChild(el("div", "id", "Practice " + p.id));
|
|
429
777
|
evidence(card, p);
|
|
430
778
|
var a = practiceAnswers(p.id);
|
|
431
|
-
askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true);
|
|
779
|
+
askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p.situation);
|
|
432
780
|
if (practiceAnswered(p)) {
|
|
433
781
|
var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
|
|
434
782
|
card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
|
|
435
|
-
(practiceMatched(p) ? "Matched. " : "Expected: " + expected + ". ") + p.why));
|
|
783
|
+
(practiceMatched(p) ? "Matched. " : "Not matched. Expected: " + expected + ". ") + p.why));
|
|
436
784
|
}
|
|
437
785
|
card.appendChild(statusLine("practice:" + p.id, [], null));
|
|
786
|
+
blocksBelow(card, p);
|
|
438
787
|
return card;
|
|
439
788
|
}
|
|
440
789
|
function practiceBlock(off) {
|
|
@@ -470,7 +819,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
470
819
|
sec.appendChild(fam);
|
|
471
820
|
}
|
|
472
821
|
groupItems(g.id).forEach(function (i) {
|
|
473
|
-
if (onlyOpen && itemDone(i.id) && i.id !== state.typingIn) return;
|
|
822
|
+
if (onlyOpen && itemDone(i.id) && i.id !== state.typingIn && i.id !== state.lastAnswered) return;
|
|
474
823
|
sec.appendChild(itemCard(i, off));
|
|
475
824
|
});
|
|
476
825
|
return sec;
|
|
@@ -505,14 +854,43 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
505
854
|
// Under "Next unanswered" the save that completes an item would hide it, box and all, mid-sentence:
|
|
506
855
|
// the item being typed in stays on the page until the labeller leaves its box.
|
|
507
856
|
state.typingIn = typing ? typing.key.slice(0, typing.key.indexOf("|")) : null;
|
|
857
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
858
|
+
state.gates = {};
|
|
508
859
|
paint();
|
|
860
|
+
if (MIN_SECONDS) watchCards();
|
|
509
861
|
var again = typing && state.whyBoxes[typing.key];
|
|
510
862
|
if (again) { again.focus(); again.setSelectionRange(typing.start, typing.end); }
|
|
511
863
|
}
|
|
864
|
+
// The answerer's choice, before anything else of the page.
|
|
865
|
+
function whoBlock() {
|
|
866
|
+
var sec = el("section");
|
|
867
|
+
sec.appendChild(el("h2", "", "Who is answering?"));
|
|
868
|
+
sec.appendChild(el("p", "muted", "Each person answers under their own name and sees only their own answers."));
|
|
869
|
+
var row = el("div", "opts");
|
|
870
|
+
ANSWERERS.forEach(function (name) {
|
|
871
|
+
var b = el("button", "", name);
|
|
872
|
+
b.type = "button";
|
|
873
|
+
b.addEventListener("click", function () { choose(name); });
|
|
874
|
+
row.appendChild(b);
|
|
875
|
+
});
|
|
876
|
+
sec.appendChild(row);
|
|
877
|
+
return sec;
|
|
878
|
+
}
|
|
879
|
+
function whoLine() {
|
|
880
|
+
var line = el("p", "who");
|
|
881
|
+
line.appendChild(el("span", "", "Answering as " + state.who));
|
|
882
|
+
var b = el("button", "", "Switch");
|
|
883
|
+
b.type = "button";
|
|
884
|
+
b.disabled = busy();
|
|
885
|
+
b.addEventListener("click", function () { choose(null); });
|
|
886
|
+
line.appendChild(b);
|
|
887
|
+
return line;
|
|
888
|
+
}
|
|
512
889
|
function paint() {
|
|
513
890
|
var off = !canWrite();
|
|
514
|
-
var
|
|
515
|
-
var
|
|
891
|
+
var items = itemsShown(), groups = groupsShown();
|
|
892
|
+
var done = items.filter(function (i) { return itemDone(i.id); }).length;
|
|
893
|
+
var total = items.length;
|
|
516
894
|
app.textContent = "";
|
|
517
895
|
var top = el("header", "top");
|
|
518
896
|
app.appendChild(top);
|
|
@@ -525,6 +903,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
525
903
|
} else if (state.store === "loading" || !(state.loaded.answers && state.loaded.groups)) {
|
|
526
904
|
top.appendChild(el("p", "muted", "Loading saved answers…"));
|
|
527
905
|
}
|
|
906
|
+
if (ASKS_WHO && !state.who) { app.appendChild(whoBlock()); return; }
|
|
907
|
+
if (ASKS_WHO) top.appendChild(whoLine());
|
|
528
908
|
app.appendChild(howTo());
|
|
529
909
|
app.appendChild(practiceBlock(off));
|
|
530
910
|
// Without the store nothing can be answered, practice included: show the real items read-only (rule 7).
|
|
@@ -536,6 +916,12 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
536
916
|
fill.style.width = (total ? (100 * done / total) : 0) + "%";
|
|
537
917
|
bar.appendChild(fill);
|
|
538
918
|
progress.appendChild(bar);
|
|
919
|
+
// Done means every item AND every group's familiarity, as «Next unanswered» counts it.
|
|
920
|
+
var rated = groups.every(function (g) { return familiarity(g.id) !== undefined; });
|
|
921
|
+
if (total && done === total && rated && state.filter === "all") {
|
|
922
|
+
progress.appendChild(el("p", "status saved", "All " + total + " items are complete. Tell the agent you are " +
|
|
923
|
+
"done; you can still change any answer."));
|
|
924
|
+
}
|
|
539
925
|
|
|
540
926
|
var filters = el("div", "tabs");
|
|
541
927
|
[["all", "All items"], ["next", "Next unanswered"]].forEach(function (f) {
|
|
@@ -553,8 +939,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
553
939
|
|
|
554
940
|
var onlyOpen = state.filter === "next";
|
|
555
941
|
if (onlyOpen) {
|
|
556
|
-
var rest =
|
|
557
|
-
var open =
|
|
942
|
+
var rest = items.filter(function (i) { return !itemDone(i.id); });
|
|
943
|
+
var open = groups.filter(function (g) { return familiarity(g.id) === undefined; });
|
|
558
944
|
var note = el("div", "muted");
|
|
559
945
|
if (!rest.length && !open.length) {
|
|
560
946
|
note.appendChild(el("p", "", "Nothing left. Every item and every group is answered."));
|
|
@@ -570,8 +956,8 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
570
956
|
}
|
|
571
957
|
progress.appendChild(note);
|
|
572
958
|
}
|
|
573
|
-
|
|
574
|
-
var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn; });
|
|
959
|
+
groups.forEach(function (g) {
|
|
960
|
+
var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn || i.id === state.lastAnswered; });
|
|
575
961
|
if (onlyOpen && groupDone(g.id) && !typingHere) return;
|
|
576
962
|
app.appendChild(groupBlock(g, off, onlyOpen));
|
|
577
963
|
});
|
|
@@ -582,16 +968,24 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
582
968
|
return function (snap) {
|
|
583
969
|
var next = {};
|
|
584
970
|
snap.docs.forEach(function (d) { next[d.id] = d.data(); });
|
|
585
|
-
state[target] = next;
|
|
971
|
+
state.raw[target] = next;
|
|
972
|
+
state[target] = mineOf(next);
|
|
586
973
|
state.loaded[loadedKey] = true;
|
|
587
974
|
render();
|
|
588
975
|
};
|
|
589
976
|
}
|
|
590
977
|
function broken() { state.store = "none"; render(); }
|
|
591
978
|
|
|
979
|
+
// Hidden: every clock stops. Back: the cards are watched afresh, because a change of tab alone
|
|
980
|
+
// fires no intersection, and a card drawn while the tab was hidden never started its clock.
|
|
592
981
|
document.addEventListener("visibilitychange", function () {
|
|
593
|
-
if (document.visibilityState !== "visible")
|
|
982
|
+
if (document.visibilityState !== "visible") {
|
|
983
|
+
flushReasons();
|
|
984
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
985
|
+
} else if (MIN_SECONDS) watchCards();
|
|
594
986
|
});
|
|
987
|
+
window.addEventListener("resize", function () { if (MIN_SECONDS) watchCards(); });
|
|
988
|
+
if (MIN_SECONDS) tick();
|
|
595
989
|
window.addEventListener("pagehide", flushReasons);
|
|
596
990
|
render();
|
|
597
991
|
if (!window.claude || typeof window.claude.use !== "function") { broken(); return; }
|