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 +1 -1
- package/skills/build-label-page/SKILL.md +2 -2
- package/skills/build-label-page/SKILL.md.spec.ts +1 -1
- package/skills/build-label-page/references/checklist.md +3 -3
- package/skills/build-label-page/references/closed-choice-generator.md +17 -13
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +8 -9
- package/skills/build-label-page/scripts/label-page-browser.test.ts +61 -0
- package/skills/build-label-page/scripts/label-page-html.test.ts +29 -0
- package/skills/build-label-page/scripts/page-template.html +16 -15
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
45
|
-
|
|
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
|
|
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.**
|
|
159
|
-
1. its place (`labels.place`)
|
|
160
|
-
2. the
|
|
161
|
-
3. the
|
|
162
|
-
4. the
|
|
163
|
-
5. the
|
|
164
|
-
6. the
|
|
165
|
-
7.
|
|
166
|
-
8.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
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
|
|
7
|
-
the question being asked. Answer buttons are full-width rows with space between them, so a thumb cannot
|
|
6
|
+
One column, sections apart by a hairline and wide space; each item a bordered panel with wide space after
|
|
7
|
+
it, and the only raised block inside it the question being asked. Answer buttons are full-width rows with space between them, so a thumb cannot
|
|
8
8
|
land between two of them, and the chosen one has a 2px accent border. */
|
|
9
9
|
:root {
|
|
10
10
|
color-scheme: dark;
|
|
@@ -28,8 +28,9 @@ p { margin: 0; max-width: var(--measure); }
|
|
|
28
28
|
.muted { color: var(--muted); }
|
|
29
29
|
.id { font-family: var(--mono); color: var(--muted); }
|
|
30
30
|
section { display: flex; flex-direction: column; gap: var(--gap-item); }
|
|
31
|
-
.item { display: flex; flex-direction: column; gap: 20px; padding
|
|
32
|
-
border
|
|
31
|
+
.item { display: flex; flex-direction: column; gap: 20px; padding: var(--gap-item);
|
|
32
|
+
border: 1px solid var(--line); border-radius: 14px; }
|
|
33
|
+
.item + .item { margin-top: 24px; }
|
|
33
34
|
/* The evidence of a card: one labelled row per field, the label a short word above its content. The two
|
|
34
35
|
readings sit in the same place on every card: A on a solid accent rule, B on a dashed one. */
|
|
35
36
|
.row { display: flex; flex-direction: column; gap: 4px; }
|
|
@@ -37,7 +38,6 @@ section { display: flex; flex-direction: column; gap: var(--gap-item); }
|
|
|
37
38
|
.reading { border-left: 3px solid var(--accent); padding-left: 12px; }
|
|
38
39
|
.reading.b { border-left: 3px dashed var(--text); }
|
|
39
40
|
mark { background: transparent; color: var(--mark); font-weight: 500; }
|
|
40
|
-
.item:first-of-type { border-top: 0; padding-top: 0; }
|
|
41
41
|
.ask { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
|
|
42
42
|
display: flex; flex-direction: column; gap: 12px; }
|
|
43
43
|
.q { color: var(--strong); font-weight: 500; }
|
|
@@ -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
|
|
575
|
-
s.textContent = "
|
|
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],
|
|
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
|
-
|
|
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,
|
|
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",
|