paperlint 14.1.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.
@@ -41,6 +41,11 @@
41
41
  * "seed": 7, fixes the order; change it and every id changes
42
42
  * "groups": {"python": "Python"},
43
43
  * "labeller_knows": ["python"], optional: items of other groups are not put on the page
44
+ * "answerers": ["author", "second"], optional, 2 or more: each answers under their own name;
45
+ * documents become `<answerer>~<id>`; an item's own
46
+ * `answerers` (a subset) shows it to those only
47
+ * "min_seconds": 30, optional: an answer saves only after its card was on
48
+ * screen that long; stored as `seconds`
44
49
  * "familiarity": true, optional: a "how well do you know" question per group;
45
50
  * left out, none is asked (an approval page)
46
51
  * "familiarity_question": "How well do you know {group}?", optional
@@ -41,6 +41,11 @@
41
41
  * "seed": 7, fixes the order; change it and every id changes
42
42
  * "groups": {"python": "Python"},
43
43
  * "labeller_knows": ["python"], optional: items of other groups are not put on the page
44
+ * "answerers": ["author", "second"], optional, 2 or more: each answers under their own name;
45
+ * documents become `<answerer>~<id>`; an item's own
46
+ * `answerers` (a subset) shows it to those only
47
+ * "min_seconds": 30, optional: an answer saves only after its card was on
48
+ * screen that long; stored as `seconds`
44
49
  * "familiarity": true, optional: a "how well do you know" question per group;
45
50
  * left out, none is asked (an approval page)
46
51
  * "familiarity_question": "How well do you know {group}?", optional
@@ -103,6 +103,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
103
103
  .setting { display: flex; flex-direction: column; gap: 2px; padding-top: 12px; border-top: 1px solid var(--line); }
104
104
  .match.yes { color: var(--good); }
105
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; }
106
108
  </style>
107
109
  <main id="app"><p class="muted">Loading…</p></main>
108
110
  <script type="application/json" id="data">__DATA__</script>
@@ -129,13 +131,148 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
129
131
  failed: {}, // item/group key -> error code of the last refused write
130
132
  loaded: { answers: false, groups: false, practice: false },
131
133
  store: "loading", // loading | ok | none
132
- 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
133
141
  };
134
142
  var db = null;
135
143
  var chains = {};
136
144
  var WHY_PAUSE_MS = 800;
137
145
 
138
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
+ }
139
276
 
140
277
  // ── what "done" means ────────────────────────────────────────────────────────────────────
141
278
  function answersOf(id) {
@@ -184,7 +321,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
184
321
  var d = state.mine["group:" + g] || state.groups[g];
185
322
  return d && valid(DATA.allowed.familiarity, d.familiarity) ? d.familiarity : undefined;
186
323
  }
187
- function groupItems(g) { return DATA.items.filter(function (i) { return i.group === g; }); }
324
+ function groupItems(g) { return itemsShown().filter(function (i) { return i.group === g; }); }
188
325
  // ── the practice (rule 8): known answers, before the real items ──────────────────────────
189
326
  function practiceDoc(pid) { return "r" + DATA.practice.round + "-" + pid; }
190
327
  function practiceAnswers(pid) {
@@ -209,7 +346,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
209
346
  function write(key, coll, docId, body) {
210
347
  state.inflight[key] = (state.inflight[key] || 0) + 1;
211
348
  delete state.failed[key];
212
- var step = function () { return db.collection(coll).doc(docId).set(body); };
349
+ var step = function () { return db.collection(coll).doc(docName(docId)).set(body); };
213
350
  var run = (chains[key] || Promise.resolve()).then(step, step);
214
351
  chains[key] = run.then(function () {
215
352
  state.inflight[key] -= 1;
@@ -223,7 +360,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
223
360
  });
224
361
  }
225
362
  function canWrite() {
226
- 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);
227
365
  }
228
366
  // An answer that asks why is not written while its reason is empty (#380): it waits on the page, and
229
367
  // the store keeps what it had, so an export never meets an answer without the reason it needs. Every
@@ -256,11 +394,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
256
394
  else delete state.pending[id];
257
395
  delete state.refusedWhy[id];
258
396
  state.mine[id] = ready;
259
- write(id, "answers", id, { answers: ready, reasons: reasons, savedAt: new Date().toISOString(), source: "page" });
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);
260
400
  render();
261
401
  }
262
402
  function answer(id, qid, value) {
263
- if (!canWrite()) return;
403
+ if (!canWrite() || locked(id)) return;
264
404
  var next = Object.assign({}, answersOf(id));
265
405
  next[qid] = value;
266
406
  state.lastAnswered = id;
@@ -385,13 +525,20 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
385
525
  }
386
526
  function itemCard(item, off) {
387
527
  var card = el("article", "item");
388
- card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: DATA.items.indexOf(item) + 1, total: DATA.items.length })));
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 })));
389
531
  evidence(card, item);
390
532
  var a = answersOf(item.id);
391
- askOne(card, a, state.editing[item.id], function (q, v) {
533
+ var gate = locked(item.id);
534
+ var ask = askOne(card, a, state.editing[item.id], function (q, v) {
392
535
  delete state.editing[item.id];
393
536
  answer(item.id, q.id, v);
394
- }, function (q) { state.editing[item.id] = q.id; render(); }, off, false, item.situation);
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
+ }
395
542
  DATA.questions.forEach(function (q) {
396
543
  var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
397
544
  if (!o) return;
@@ -594,12 +741,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
594
741
  }
595
742
  card.appendChild(line);
596
743
  });
597
- if (!asked) return;
744
+ if (!asked) return null;
598
745
  var ask = el("div", "ask");
599
746
  // A card's situation asks the page's first question in its own words; the others keep theirs.
600
747
  ask.appendChild(situation && asked === DATA.questions[0] ? situated("p", "q", situation.question, situation.line) : rich("p", "q", asked.text));
601
748
  ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples));
602
749
  card.appendChild(ask);
750
+ return ask;
603
751
  }
604
752
  // Open until the practice is done; then folded into a toggle, so the cards start near the top.
605
753
  function howTo() {
@@ -706,14 +854,43 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
706
854
  // Under "Next unanswered" the save that completes an item would hide it, box and all, mid-sentence:
707
855
  // the item being typed in stays on the page until the labeller leaves its box.
708
856
  state.typingIn = typing ? typing.key.slice(0, typing.key.indexOf("|")) : null;
857
+ Object.keys(state.seenSince).forEach(stopClock);
858
+ state.gates = {};
709
859
  paint();
860
+ if (MIN_SECONDS) watchCards();
710
861
  var again = typing && state.whyBoxes[typing.key];
711
862
  if (again) { again.focus(); again.setSelectionRange(typing.start, typing.end); }
712
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
+ }
713
889
  function paint() {
714
890
  var off = !canWrite();
715
- var done = DATA.items.filter(function (i) { return itemDone(i.id); }).length;
716
- var total = DATA.items.length;
891
+ var items = itemsShown(), groups = groupsShown();
892
+ var done = items.filter(function (i) { return itemDone(i.id); }).length;
893
+ var total = items.length;
717
894
  app.textContent = "";
718
895
  var top = el("header", "top");
719
896
  app.appendChild(top);
@@ -726,6 +903,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
726
903
  } else if (state.store === "loading" || !(state.loaded.answers && state.loaded.groups)) {
727
904
  top.appendChild(el("p", "muted", "Loading saved answers…"));
728
905
  }
906
+ if (ASKS_WHO && !state.who) { app.appendChild(whoBlock()); return; }
907
+ if (ASKS_WHO) top.appendChild(whoLine());
729
908
  app.appendChild(howTo());
730
909
  app.appendChild(practiceBlock(off));
731
910
  // Without the store nothing can be answered, practice included: show the real items read-only (rule 7).
@@ -738,7 +917,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
738
917
  bar.appendChild(fill);
739
918
  progress.appendChild(bar);
740
919
  // Done means every item AND every group's familiarity, as «Next unanswered» counts it.
741
- var rated = DATA.groups.every(function (g) { return familiarity(g.id) !== undefined; });
920
+ var rated = groups.every(function (g) { return familiarity(g.id) !== undefined; });
742
921
  if (total && done === total && rated && state.filter === "all") {
743
922
  progress.appendChild(el("p", "status saved", "All " + total + " items are complete. Tell the agent you are " +
744
923
  "done; you can still change any answer."));
@@ -760,8 +939,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
760
939
 
761
940
  var onlyOpen = state.filter === "next";
762
941
  if (onlyOpen) {
763
- var rest = DATA.items.filter(function (i) { return !itemDone(i.id); });
764
- var open = DATA.groups.filter(function (g) { return familiarity(g.id) === undefined; });
942
+ var rest = items.filter(function (i) { return !itemDone(i.id); });
943
+ var open = groups.filter(function (g) { return familiarity(g.id) === undefined; });
765
944
  var note = el("div", "muted");
766
945
  if (!rest.length && !open.length) {
767
946
  note.appendChild(el("p", "", "Nothing left. Every item and every group is answered."));
@@ -777,7 +956,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
777
956
  }
778
957
  progress.appendChild(note);
779
958
  }
780
- DATA.groups.forEach(function (g) {
959
+ groups.forEach(function (g) {
781
960
  var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn || i.id === state.lastAnswered; });
782
961
  if (onlyOpen && groupDone(g.id) && !typingHere) return;
783
962
  app.appendChild(groupBlock(g, off, onlyOpen));
@@ -789,16 +968,24 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
789
968
  return function (snap) {
790
969
  var next = {};
791
970
  snap.docs.forEach(function (d) { next[d.id] = d.data(); });
792
- state[target] = next;
971
+ state.raw[target] = next;
972
+ state[target] = mineOf(next);
793
973
  state.loaded[loadedKey] = true;
794
974
  render();
795
975
  };
796
976
  }
797
977
  function broken() { state.store = "none"; render(); }
798
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.
799
981
  document.addEventListener("visibilitychange", function () {
800
- if (document.visibilityState !== "visible") flushReasons();
982
+ if (document.visibilityState !== "visible") {
983
+ flushReasons();
984
+ Object.keys(state.seenSince).forEach(stopClock);
985
+ } else if (MIN_SECONDS) watchCards();
801
986
  });
987
+ window.addEventListener("resize", function () { if (MIN_SECONDS) watchCards(); });
988
+ if (MIN_SECONDS) tick();
802
989
  window.addEventListener("pagehide", flushReasons);
803
990
  render();
804
991
  if (!window.claude || typeof window.claude.use !== "function") { broken(); return; }