paperlint 14.3.0 → 14.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "14.3.0",
3
+ "version": "14.4.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ description: Build a phone labelling page published as a claude.ai Artifact with
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Artifact, ArtifactData]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:9d7a2c35df4e6a10 compiled from skills/build-label-page/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:ed201d81c5934675 compiled from skills/build-label-page/SKILL.md.spec.ts -->
8
8
 
9
9
  # build-label-page — a page the person labels on a phone, and the labels back in the repo
10
10
 
@@ -57,7 +57,7 @@ building. In one line each:
57
57
  passing proves nothing about this.
58
58
  2. **Navigation never relies on in-page scrolling or toasts.** "Next unanswered" is a filter: it hides
59
59
  every finished item and group, and a note at the top lists what is left and what each still needs.
60
- 3. **An item is done only when every question on it is answered.** The card names the missing
60
+ 3. **An item is done only when every question on it is answered.** The card asks the missing
61
61
  question; the progress counter counts complete items only.
62
62
  4. **Blind.** Neutral ids (`G001`…), order shuffled with a fixed seed, no model answers, verdicts or
63
63
  source labels on the page. The generator refuses to build when an item carries a field outside an
@@ -68,7 +68,7 @@ building. In one line each:
68
68
  passing proves nothing about this.
69
69
  2. **Navigation never relies on in-page scrolling or toasts.** "Next unanswered" is a filter: it hides
70
70
  every finished item and group, and a note at the top lists what is left and what each still needs.
71
- 3. **An item is done only when every question on it is answered.** The card names the missing
71
+ 3. **An item is done only when every question on it is answered.** The card asks the missing
72
72
  question; the progress counter counts complete items only.
73
73
  4. **Blind.** Neutral ids (\`G001\`…), order shuffled with a fixed seed, no model answers, verdicts or
74
74
  source labels on the page. The generator refuses to build when an item carries a field outside an
@@ -41,8 +41,8 @@ filter works in any frame, because the next thing to answer is simply the first
41
41
 
42
42
  ## 3. An item counts as done only when every question on it is answered
43
43
 
44
- **Do.** Done means all questions answered. The card shows which one is missing ("Still needs:
45
- boundary"). The progress counter counts complete items only, so "7 of 12" means 7 items with every
44
+ **Do.** Done means all questions answered. The card asks the one still missing in its question
45
+ box, and "Next unanswered" lists what each item still needs. The progress counter counts complete items only, so "7 of 12" means 7 items with every
46
46
  question answered. A group is finished when all its items are and its familiarity question (rule 5)
47
47
  is answered.
48
48
 
@@ -50,7 +50,7 @@ is answered.
50
50
  and surfaces weeks later as a gap in the data. A counter that counts partial items reports progress
51
51
  that the analysis cannot use.
52
52
 
53
- **Check.** Answer one question of a two-question item: the counter stays put and the card names the
53
+ **Check.** Answer one question of a two-question item: the counter stays put and the card asks the
54
54
  other question.
55
55
 
56
56
  ## 4. Blind: the page tells the labeller nothing about the answer or the source
@@ -155,18 +155,22 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
155
155
  }
156
156
  ```
157
157
 
158
- - **Order on the card.** The card shows, in this order:
159
- 1. its place (`labels.place`);
160
- 2. the body, on a `labels.body` row when that label is set;
161
- 3. the repeat sentence;
162
- 4. the older fields (`code`, `unit`, `rules`, `readings`, `file`, `context`);
163
- 5. the situation's summary;
164
- 6. the excerpt;
165
- 7. **the question box**: the `quote` and its source, the question, and its answers with their `answer_notes`;
166
- 8. `references`, `more` and `table`, each in place under its heading. Nothing on a card is folded:
167
- a labeller who has to open a block to answer reads half a card (#392).
168
-
169
- The question therefore sits right under the code it names.
158
+ - **Order on the card.** Each card is a bordered panel, with wide space before the next. It shows, in this order:
159
+ 1. its place (`labels.place`), then its `title`;
160
+ 2. the `quote` and its source;
161
+ 3. the body, on a `labels.body` row when that label is set;
162
+ 4. the repeat sentence;
163
+ 5. the older fields (`code`, `unit`, `rules`, `readings`, `file`, `context`);
164
+ 6. the situation's summary;
165
+ 7. the excerpt;
166
+ 8. **the question box**: the question, its answers with their `answer_notes`, and the wait bar of `min_seconds`;
167
+ 9. the reason box of an answer with `ask_why`, then the status line. The status line never lists
168
+ what is missing (the question box asks it): it shows «Saved <time>», «Saving…», why a save did not happen, or nothing;
169
+ 10. `references`, `more` and `table`, each in place under its heading. Nothing on a card is folded:
170
+ a labeller who has to open a block to answer reads half a card (#392).
171
+
172
+ A card is read once, top to bottom: the words asked about, the code they are about, the question
173
+ right under that code, its answers. A card without a `quote` has the same order without step 2.
170
174
 
171
175
  - **`situation`**: `{summary?, question}`.
172
176
  - `summary` is one sentence above the excerpt; optional.
@@ -202,7 +206,7 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
202
206
  }
203
207
  ```
204
208
 
205
- - **`quote`** shows the words being asked about at the top of the question box, `source` as a small line under them. A card needs `body` or `quote`.
209
+ - **`quote`** shows the words being asked about at the top of the card, above its code, `source` as a small line under them. A card needs `body` or `quote`.
206
210
  - **`terms`**: each `term` must occur in the quote, its source or the annotation, inside one backtick span or outside all of them; the build refuses one it cannot find there, and a note entry with neither `quote` nor `code`. A term opens from its first occurrence in the quote, else the source, else the annotation. On the page it is a dotted button: a tap (or hover, where there is hover) opens one floating note anchored to it, at most 280 px wide (300 with a snippet), flipped above the term when there is no room below, kept inside the viewport. A tap outside closes it and does nothing else, so it never answers a card; so does Esc. Answer buttons carry no terms.
207
211
  - **`answer_notes`** puts a sub-line under an answer's label on this card: what the answer means here. A question or an answer the page does not have is refused.
208
212
  - Whether a note pushes the labeller toward one answer is not checked: the consumer lists each note for the study's author.
@@ -505,12 +505,12 @@ const unticked = (text: string): string => text.split("`").join("");
505
505
  const shownBy = (n: FakeNode): string => n.textContent.replace(/\n/g, "");
506
506
  /** The kinds of the card's parts, in the order they appear. */
507
507
  const KINDS = [
508
+ "quote",
509
+ "quote-source",
508
510
  "excerpt-path",
509
511
  "code-line",
510
512
  "gap",
511
513
  "annotation",
512
- "quote",
513
- "quote-source",
514
514
  "question",
515
515
  "answer",
516
516
  "answer-sub",
@@ -567,19 +567,18 @@ function fixtureFacts() {
567
567
  { question: V7_QUESTION.id, value: o.value },
568
568
  unticked(notes[o.value] ?? ""),
569
569
  ]),
570
- status: [
571
- [{ state: "missing", missing: [V7_QUESTION.id] }],
572
- [`Still needs: ${V7_QUESTION.short}`],
573
- ],
570
+ // The save button and the open question already say what is missing: the line keeps its values
571
+ // and shows nothing.
572
+ status: [[{ state: "missing", missing: [V7_QUESTION.id] }], [""]],
574
573
  order: [
574
+ "quote",
575
+ "quote-source",
575
576
  "excerpt-path",
576
577
  ...lines.flatMap((l, n) => [
577
578
  ...(l.n > (lines[n - 1]?.n ?? l.n) + 1 ? ["gap"] : []),
578
579
  "code-line",
579
580
  ...(l.mark ? ["annotation"] : []),
580
581
  ]),
581
- "quote",
582
- "quote-source",
583
582
  "question",
584
583
  ...V7_QUESTION.options.flatMap((o) =>
585
584
  o.value in notes ? ["answer", "answer-sub"] : ["answer"],
@@ -589,7 +588,7 @@ function fixtureFacts() {
589
588
  };
590
589
  }
591
590
 
592
- test("🔴 an approved card reads top to bottom: its path, the code with a gap row for what it leaves out, the marked line and the note under it, the quote, its source, the question, each answer with its sub-line, then what it still needs", async () => {
591
+ test("🔴 an approved card reads top to bottom: the quote and its source, its path, the code with a gap row for what it leaves out, the marked line and the note under it, the question, each answer with its sub-line, then a status line that shows nothing while the card is unanswered", async () => {
593
592
  const { page: html, key } = build(setup("v7-face", v7()));
594
593
  const { app } = await openLive(html, { practice: V7_DONE });
595
594
  const id = Object.entries(key.ids).find(
@@ -1357,3 +1357,64 @@ test(
1357
1357
  },
1358
1358
  BROWSER_TEST_MS,
1359
1359
  );
1360
+
1361
+ /** Where a part of a card sits, by its `data-test`: the first one of that kind. */
1362
+ const boxOf = async (card: Locator, kind: string) =>
1363
+ card.getByTestId(kind).first().boundingBox();
1364
+ const Panel = z.object({
1365
+ border: z.number(),
1366
+ radius: z.number(),
1367
+ padding: z.number(),
1368
+ });
1369
+ test(
1370
+ "🔴 a quote card reads once from top to bottom, quote, code, question, answers, wait bar, inside a panel of its own; its «still needs» line is not shown",
1371
+ async () => {
1372
+ await onV7Page(
1373
+ "browser-v7-order",
1374
+ async (page, cardOf) => {
1375
+ const card = await cardOf("card-docstring");
1376
+ const parts = ["quote", "excerpt-path", "question", "answer", "wait"];
1377
+ const boxes = await Promise.all(parts.map((k) => boxOf(card, k)));
1378
+ // Guards: each part starts below the one before it ends, so the eye never goes back up.
1379
+ assert.deepEqual(
1380
+ R.zip(boxes, boxes.slice(1)).map(
1381
+ ([a, b]) => a !== null && b !== null && b.y >= a.y + a.height,
1382
+ ),
1383
+ [true, true, true, true],
1384
+ JSON.stringify(boxes),
1385
+ );
1386
+ const status = card.getByTestId("status");
1387
+ assert.deepEqual(
1388
+ [await metaOf(status), await status.isVisible()],
1389
+ [{ state: "missing", missing: ["forbid"] }, false],
1390
+ );
1391
+ // A panel: a border all round, rounded, padded, and wide space before the next card.
1392
+ const panel = Panel.parse(
1393
+ await card.evaluate((e) => {
1394
+ const s = getComputedStyle(e);
1395
+ return {
1396
+ border: parseFloat(s.borderBottomWidth),
1397
+ radius: parseFloat(s.borderTopLeftRadius),
1398
+ padding: parseFloat(s.paddingLeft),
1399
+ };
1400
+ }),
1401
+ );
1402
+ assert.ok(
1403
+ panel.border >= 1 && panel.radius >= 8 && panel.padding >= 12,
1404
+ JSON.stringify(panel),
1405
+ );
1406
+ const cards = await page.getByTestId("item").all();
1407
+ const [first, second] = await Promise.all(
1408
+ cards.slice(0, 2).map(async (c) => c.boundingBox()),
1409
+ );
1410
+ assert.ok(first && second, "two cards drawn");
1411
+ assert.ok(
1412
+ second.y - (first.y + first.height) >= 32,
1413
+ JSON.stringify([first, second]),
1414
+ );
1415
+ },
1416
+ true,
1417
+ );
1418
+ },
1419
+ BROWSER_TEST_MS,
1420
+ );
@@ -504,3 +504,32 @@ test("🔴 «All items are complete» waits for every group's familiarity too, a
504
504
  );
505
505
  assert.deepEqual([await said({}), await said(rated)], [false, true]);
506
506
  });
507
+
508
+ test("🔴 the status line names nothing missing: empty while a card is unanswered, «Saved <time>» once part of it is saved, and its values keep what is missing", async () => {
509
+ const { page, key } = build(setup("status-missing", config()));
510
+ const [a, b] = Object.keys(key.ids);
511
+ assert.ok(a !== undefined && b !== undefined);
512
+ const savedAt = "2026-01-02T10:00:00Z";
513
+ const { app } = await openLive(page, {
514
+ answers: { [a]: { answers: { label: "yes" }, savedAt, source: "page" } },
515
+ groups: {},
516
+ practice: DONE_PRACTICE,
517
+ });
518
+ const statusOf = (id: string) => {
519
+ const card = app
520
+ .getByTestId("item")
521
+ .find((n) => z.object({ id: z.string() }).parse(n.meta()).id === id);
522
+ const line = card?.getByTestId("status")[0];
523
+ return [line?.meta(), line?.textContent];
524
+ };
525
+ assert.deepEqual(
526
+ [statusOf(a), statusOf(b)],
527
+ [
528
+ [
529
+ { state: "missing", missing: ["boundary"] },
530
+ `Saved ${new Date(savedAt).toLocaleTimeString()}`,
531
+ ],
532
+ [{ state: "missing", missing: ["label", "boundary"] }, ""],
533
+ ],
534
+ );
535
+ });
@@ -3,8 +3,8 @@
3
3
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Roboto+Mono&display=swap">
4
4
  <style>
5
5
  /* One look, dark whatever the viewer's theme (Material Ocean, no purple): every color is a token below.
6
- One column, sections apart by a hairline and wide space, items flat; the only raised block of an item is
7
- the question being asked. Answer buttons are full-width rows with space between them, so a thumb cannot
6
+ One column, sections apart by a hairline and wide space; each item a bordered panel with wide space after
7
+ it, and the only raised block inside it the question being asked. Answer buttons are full-width rows with space between them, so a thumb cannot
8
8
  land between two of them, and the chosen one has a 2px accent border. */
9
9
  :root {
10
10
  color-scheme: dark;
@@ -28,8 +28,9 @@ p { margin: 0; max-width: var(--measure); }
28
28
  .muted { color: var(--muted); }
29
29
  .id { font-family: var(--mono); color: var(--muted); }
30
30
  section { display: flex; flex-direction: column; gap: var(--gap-item); }
31
- .item { display: flex; flex-direction: column; gap: 20px; padding-top: var(--gap-item);
32
- border-top: 1px solid var(--line); }
31
+ .item { display: flex; flex-direction: column; gap: 20px; padding: var(--gap-item);
32
+ border: 1px solid var(--line); border-radius: 14px; }
33
+ .item + .item { margin-top: 24px; }
33
34
  /* The evidence of a card: one labelled row per field, the label a short word above its content. The two
34
35
  readings sit in the same place on every card: A on a solid accent rule, B on a dashed one. */
35
36
  .row { display: flex; flex-direction: column; gap: 4px; }
@@ -37,7 +38,6 @@ section { display: flex; flex-direction: column; gap: var(--gap-item); }
37
38
  .reading { border-left: 3px solid var(--accent); padding-left: 12px; }
38
39
  .reading.b { border-left: 3px dashed var(--text); }
39
40
  mark { background: transparent; color: var(--mark); font-weight: 500; }
40
- .item:first-of-type { border-top: 0; padding-top: 0; }
41
41
  .ask { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
42
42
  display: flex; flex-direction: column; gap: 12px; }
43
43
  .q { color: var(--strong); font-weight: 500; }
@@ -569,11 +569,14 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
569
569
  } else if (state.inflight[key]) {
570
570
  as("saving");
571
571
  s.textContent = "Saving…";
572
+ } else if (missing.length && !savedAt) {
573
+ // The open question and its save button already say what is missing: nothing to show.
574
+ as("missing");
575
+ s.hidden = true;
572
576
  } else if (missing.length) {
573
577
  as("missing");
574
- s.className = "status missing";
575
- s.textContent = "Still needs: " + missing.map(function (q) { return q.short || q.text; }).join("; ") +
576
- (savedAt ? ". Saved " + timeOf(savedAt) : "");
578
+ s.className = "status saved";
579
+ s.textContent = "Saved " + timeOf(savedAt);
577
580
  } else if (savedAt) {
578
581
  as("saved");
579
582
  s.className = "status saved";
@@ -591,7 +594,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
591
594
  evidence(card, item, ctx);
592
595
  var a = answersOf(item.id);
593
596
  var gate = locked(item.id);
594
- var ask = askOne(card, a, state.editing[item.id], ctx, function (q, v) {
597
+ var ask = askOne(card, a, state.editing[item.id], function (q, v) {
595
598
  delete state.editing[item.id];
596
599
  answer(item.id, q.id, v);
597
600
  }, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item);
@@ -927,8 +930,10 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
927
930
  if (item.table) card.appendChild(table(item.table));
928
931
  }
929
932
  // What a card shows above its question, one labelled row per field (an item and a practice item alike).
933
+ // A card's quote comes first, under its title: the words asked about, then the code they are about.
930
934
  function evidence(card, item, ctx) {
931
935
  if (item.title) card.appendChild(rich("h3", "", item.title));
936
+ if (item.quote) card.appendChild(quoteBlock(item, ctx));
932
937
  if (item.body !== undefined) {
933
938
  var body = rich("p", "body", item.body);
934
939
  card.appendChild(DATA.labels.body ? row(DATA.labels.body, body) : body);
@@ -956,13 +961,10 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
956
961
  }
957
962
  // One question per screen: the answered ones stay as a line (with "Change"), and only one question
958
963
  // is asked: the one being changed, else the first unanswered.
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) {
964
+ function askOne(card, a, editing, onPick, onChange, off, withExamples, item) {
961
965
  var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
962
966
  var asked = DATA.questions.filter(function (q) { return q.id === editing; })[0] || open[0];
963
967
  var situation = item.situation, notes = item.answer_notes || {};
964
- var quote = item.quote ? quoteBlock(item, ctx) : null;
965
- if (quote && !asked) card.appendChild(quote);
966
968
  DATA.questions.forEach(function (q) {
967
969
  if (q === asked || !valid(DATA.allowed.questions[q.id], a[q.id])) return;
968
970
  var line = el("p", "answered", (q.short || q.text) + ": " + labelOf(q, a[q.id]) + " ");
@@ -977,7 +979,6 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
977
979
  });
978
980
  if (!asked) return null;
979
981
  var ask = el("div", "ask");
980
- if (quote) ask.appendChild(quote);
981
982
  // A card's situation asks the page's first question in its own words; the others keep theirs.
982
983
  ask.appendChild(mark(situation && asked === DATA.questions[0] ? situated("p", "q", situation.question, situation.line) : rich("p", "q", asked.text),
983
984
  "question", { question: asked.id }));
@@ -1013,7 +1014,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
1013
1014
  var ctx = termsOf(p);
1014
1015
  evidence(card, p, ctx);
1015
1016
  var a = practiceAnswers(p.id);
1016
- askOne(card, a, undefined, ctx, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p);
1017
+ askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true, p);
1017
1018
  if (practiceAnswered(p)) {
1018
1019
  var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
1019
1020
  card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",