paperlint 14.1.0 → 14.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -1
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +70 -32
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +19 -3
- package/skills/build-label-page/SKILL.md.spec.ts +18 -2
- package/skills/build-label-page/references/closed-choice-generator.md +86 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +277 -1
- package/skills/build-label-page/scripts/label-page-browser.test.ts +761 -10
- package/skills/build-label-page/scripts/label-page-core.js +311 -67
- package/skills/build-label-page/scripts/label-page-core.test.ts +49 -10
- package/skills/build-label-page/scripts/label-page-core.ts +511 -87
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/label-page.js +5 -0
- package/skills/build-label-page/scripts/label-page.ts +5 -0
- package/skills/build-label-page/scripts/page-template.html +488 -62
package/CLAUDE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- vigiles:sha256:
|
|
1
|
+
<!-- vigiles:sha256:d595fb538eb0a81e compiled from CLAUDE.md.spec.ts -->
|
|
2
2
|
|
|
3
3
|
# CLAUDE.md
|
|
4
4
|
|
|
@@ -106,6 +106,11 @@ Before writing machinery, **run the thing you are about to replace and paste its
|
|
|
106
106
|
|
|
107
107
|
**Guidance only** — Before adding a CLI command or changing a delivery channel (skill links, hook wiring, the composite action, the npm package), read how comparable tools did it. "We need another command for X" has, in every tool examined, been the CONFIG not declaring something. [Details](docs/prior-art/README.md)
|
|
108
108
|
|
|
109
|
+
### Browser Tests Find By Data Test
|
|
110
|
+
|
|
111
|
+
**Enforced by:** `eslint/playwright/no-raw-locators`
|
|
112
|
+
**Why:** A browser test finds an element only by `getByTestId` on `data-test`, and reads its values from the JSON in `data-test-meta`, never from CSS classes or visible text; content text is compared with the fixture's value. The older browser tests sit in `eslint-suppressions.json`, which may only shrink. [Details](docs/testing.md)
|
|
113
|
+
|
|
109
114
|
### New Code Is Functional
|
|
110
115
|
|
|
111
116
|
**Enforced by:** `eslint/functional/no-let`
|
|
@@ -83,20 +83,45 @@ interface CodeLine {
|
|
|
83
83
|
readonly n: number;
|
|
84
84
|
readonly text: string;
|
|
85
85
|
readonly mark: boolean;
|
|
86
|
+
readonly gutter?: string;
|
|
87
|
+
readonly context?: boolean;
|
|
88
|
+
}
|
|
89
|
+
/** One entry of a term's note: a verbatim quote or snippet, and the link to where it is from. */
|
|
90
|
+
interface NoteEntry {
|
|
91
|
+
readonly title?: string;
|
|
92
|
+
readonly says?: string;
|
|
93
|
+
readonly quote?: string;
|
|
94
|
+
readonly code?: string;
|
|
95
|
+
readonly example?: string;
|
|
96
|
+
readonly url: string;
|
|
97
|
+
readonly url_label: string;
|
|
98
|
+
}
|
|
99
|
+
interface Term {
|
|
100
|
+
readonly term: string;
|
|
101
|
+
readonly note: readonly NoteEntry[];
|
|
86
102
|
}
|
|
87
103
|
/** The content blocks (#380), each optional on any kind. */
|
|
88
104
|
interface Blocks {
|
|
89
105
|
readonly situation?: {
|
|
90
|
-
readonly summary
|
|
106
|
+
readonly summary?: string;
|
|
91
107
|
readonly question: string;
|
|
92
108
|
};
|
|
93
109
|
readonly excerpt?: {
|
|
94
110
|
readonly file?: string;
|
|
111
|
+
readonly lang?: string;
|
|
95
112
|
readonly url?: string;
|
|
113
|
+
readonly gutter_header?: string;
|
|
96
114
|
readonly lines: readonly CodeLine[];
|
|
115
|
+
readonly annotation?: string;
|
|
97
116
|
readonly caption?: string;
|
|
98
117
|
readonly mark_label?: string;
|
|
99
118
|
};
|
|
119
|
+
readonly quote?: {
|
|
120
|
+
readonly text: string;
|
|
121
|
+
readonly source?: string;
|
|
122
|
+
};
|
|
123
|
+
readonly terms?: readonly Term[];
|
|
124
|
+
readonly answer_notes?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
100
125
|
readonly references?: {
|
|
101
126
|
readonly summary: string;
|
|
102
127
|
readonly entries: readonly {
|
|
@@ -135,7 +160,7 @@ interface Blocks {
|
|
|
135
160
|
type Card = {
|
|
136
161
|
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
137
162
|
} & Blocks & {
|
|
138
|
-
readonly body
|
|
163
|
+
readonly body?: string;
|
|
139
164
|
readonly rules?: readonly RuleRef[];
|
|
140
165
|
readonly unit?: Unit;
|
|
141
166
|
readonly readings?: {
|
|
@@ -146,6 +171,7 @@ type Card = {
|
|
|
146
171
|
type Item = Card & {
|
|
147
172
|
readonly source_id: string;
|
|
148
173
|
readonly group: string;
|
|
174
|
+
readonly answerers?: readonly string[];
|
|
149
175
|
};
|
|
150
176
|
type Practice = Card & {
|
|
151
177
|
readonly expected: Readonly<Record<string, string>>;
|
|
@@ -159,6 +185,8 @@ interface Config {
|
|
|
159
185
|
readonly seed: number;
|
|
160
186
|
readonly groups: Readonly<Record<string, string>>;
|
|
161
187
|
readonly labeller_knows?: readonly string[];
|
|
188
|
+
readonly answerers?: readonly string[];
|
|
189
|
+
readonly min_seconds?: number;
|
|
162
190
|
readonly familiarity?: boolean;
|
|
163
191
|
readonly familiarity_question?: string;
|
|
164
192
|
readonly questions: readonly Question[];
|
|
@@ -171,6 +199,8 @@ interface KeyEntry {
|
|
|
171
199
|
readonly source_id: string;
|
|
172
200
|
readonly group: string;
|
|
173
201
|
readonly on_page: boolean;
|
|
202
|
+
/** Who the item is shown to, when not every answerer of the page. */
|
|
203
|
+
readonly answerers?: readonly string[];
|
|
174
204
|
}
|
|
175
205
|
/** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
|
|
176
206
|
type Described = ReadonlyMap<string, RuleText>;
|
|
@@ -183,21 +213,6 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
|
|
|
183
213
|
};
|
|
184
214
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
185
215
|
declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
|
|
186
|
-
title: string;
|
|
187
|
-
intro: string;
|
|
188
|
-
labels: {
|
|
189
|
-
[x: string]: string;
|
|
190
|
-
};
|
|
191
|
-
groups: {
|
|
192
|
-
id: string;
|
|
193
|
-
label: string;
|
|
194
|
-
}[];
|
|
195
|
-
familiarity_question: string;
|
|
196
|
-
familiarity: {
|
|
197
|
-
value: string;
|
|
198
|
-
label: string;
|
|
199
|
-
}[];
|
|
200
|
-
allowed: JsonObject;
|
|
201
216
|
questions: readonly Question[];
|
|
202
217
|
practice: {
|
|
203
218
|
round: number;
|
|
@@ -219,9 +234,11 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
|
|
|
219
234
|
})[];
|
|
220
235
|
};
|
|
221
236
|
items: ({
|
|
237
|
+
answerers?: readonly string[];
|
|
222
238
|
rules?: never;
|
|
223
239
|
id: string;
|
|
224
240
|
} | {
|
|
241
|
+
answerers?: readonly string[];
|
|
225
242
|
rules: {
|
|
226
243
|
description?: string;
|
|
227
244
|
family?: string;
|
|
@@ -230,6 +247,23 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
|
|
|
230
247
|
}[];
|
|
231
248
|
id: string;
|
|
232
249
|
})[];
|
|
250
|
+
answerers?: readonly string[];
|
|
251
|
+
min_seconds?: number;
|
|
252
|
+
title: string;
|
|
253
|
+
intro: string;
|
|
254
|
+
labels: {
|
|
255
|
+
[x: string]: string;
|
|
256
|
+
};
|
|
257
|
+
groups: {
|
|
258
|
+
id: string;
|
|
259
|
+
label: string;
|
|
260
|
+
}[];
|
|
261
|
+
familiarity_question: string;
|
|
262
|
+
familiarity: {
|
|
263
|
+
value: string;
|
|
264
|
+
label: string;
|
|
265
|
+
}[];
|
|
266
|
+
allowed: JsonObject;
|
|
233
267
|
};
|
|
234
268
|
export type Payload = ReturnType<typeof payloadOf>;
|
|
235
269
|
/**
|
|
@@ -239,21 +273,6 @@ export type Payload = ReturnType<typeof payloadOf>;
|
|
|
239
273
|
*/
|
|
240
274
|
export declare function prepareBuild(configPath: string, outDir: string): {
|
|
241
275
|
payload: {
|
|
242
|
-
title: string;
|
|
243
|
-
intro: string;
|
|
244
|
-
labels: {
|
|
245
|
-
[x: string]: string;
|
|
246
|
-
};
|
|
247
|
-
groups: {
|
|
248
|
-
id: string;
|
|
249
|
-
label: string;
|
|
250
|
-
}[];
|
|
251
|
-
familiarity_question: string;
|
|
252
|
-
familiarity: {
|
|
253
|
-
value: string;
|
|
254
|
-
label: string;
|
|
255
|
-
}[];
|
|
256
|
-
allowed: JsonObject;
|
|
257
276
|
questions: readonly Question[];
|
|
258
277
|
practice: {
|
|
259
278
|
round: number;
|
|
@@ -275,9 +294,11 @@ export declare function prepareBuild(configPath: string, outDir: string): {
|
|
|
275
294
|
})[];
|
|
276
295
|
};
|
|
277
296
|
items: ({
|
|
297
|
+
answerers?: readonly string[];
|
|
278
298
|
rules?: never;
|
|
279
299
|
id: string;
|
|
280
300
|
} | {
|
|
301
|
+
answerers?: readonly string[];
|
|
281
302
|
rules: {
|
|
282
303
|
description?: string;
|
|
283
304
|
family?: string;
|
|
@@ -286,6 +307,23 @@ export declare function prepareBuild(configPath: string, outDir: string): {
|
|
|
286
307
|
}[];
|
|
287
308
|
id: string;
|
|
288
309
|
})[];
|
|
310
|
+
answerers?: readonly string[];
|
|
311
|
+
min_seconds?: number;
|
|
312
|
+
title: string;
|
|
313
|
+
intro: string;
|
|
314
|
+
labels: {
|
|
315
|
+
[x: string]: string;
|
|
316
|
+
};
|
|
317
|
+
groups: {
|
|
318
|
+
id: string;
|
|
319
|
+
label: string;
|
|
320
|
+
}[];
|
|
321
|
+
familiarity_question: string;
|
|
322
|
+
familiarity: {
|
|
323
|
+
value: string;
|
|
324
|
+
label: string;
|
|
325
|
+
}[];
|
|
326
|
+
allowed: JsonObject;
|
|
289
327
|
};
|
|
290
328
|
dropped: number;
|
|
291
329
|
};
|
package/docs/testing.md
CHANGED
|
@@ -149,3 +149,15 @@ file matches, and it transpiles without type-checking, so `npm run check` runs
|
|
|
149
149
|
really occur stays, with the test that reaches it. `scripts/coverage-config.test.ts` fails on any
|
|
150
150
|
coverage-ignore comment and pins `.c8rc.json`'s `exclude` list.
|
|
151
151
|
7. **Say what an assertion guards** in a comment directly above it (`// Guards: …`).
|
|
152
|
+
8. **A browser test finds an element by `data-test` and reads its values from `data-test-meta`.**
|
|
153
|
+
The page marks every element a test touches with `data-test="<kind>"` and puts the element's
|
|
154
|
+
values in `data-test-meta` as JSON (`{"line":279,"marked":true}`). The test sets
|
|
155
|
+
`selectors.setTestIdAttribute("data-test")`, locates only with `getByTestId`, parses the meta and
|
|
156
|
+
compares it with one `deepEqual`. CSS classes stay for styling and visible text stays for people,
|
|
157
|
+
so a restyle or a reworded label does not turn a test red. Text that IS the content (an
|
|
158
|
+
annotation, a quote) is compared with the fixture's value, never with a literal typed into the
|
|
159
|
+
test. Accessibility state (`aria-expanded`) may be asserted on purpose, but it never locates
|
|
160
|
+
anything. `playwright/no-raw-locators`, `playwright/prefer-locator`, `playwright/no-wait-for-selector`
|
|
161
|
+
and `playwright/no-eval` hold the locating half; the older browser tests sit in
|
|
162
|
+
`eslint-suppressions.json`, which may only shrink. Example: `annotatedFacts` and
|
|
163
|
+
`annotatedExpected` in `skills/build-label-page/scripts/label-page-browser.test.ts`.
|
package/eslint.config.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import jsReasonRules from "#eslint-rules/js-needs-reason";
|
|
|
19
19
|
|
|
20
20
|
import portRules from "#eslint-rules/install-path-literals";
|
|
21
21
|
import n from "eslint-plugin-n";
|
|
22
|
+
import playwright from "eslint-plugin-playwright";
|
|
22
23
|
import tseslint from "typescript-eslint";
|
|
23
24
|
import boundaries from "eslint-plugin-boundaries";
|
|
24
25
|
import functional from "eslint-plugin-functional";
|
|
@@ -697,6 +698,22 @@ export default [
|
|
|
697
698
|
files: TEST_FILES,
|
|
698
699
|
rules: { "max-lines-per-function": ["error", MAX_LINES_TESTS] },
|
|
699
700
|
},
|
|
701
|
+
// Browser tests find an element by its test id (`data-test`, set in the page and in
|
|
702
|
+
// `selectors.setTestIdAttribute`) and read its values from `data-test-meta`: a CSS selector or a
|
|
703
|
+
// visible text breaks when the look changes. The rules fire under vitest as well as under
|
|
704
|
+
// Playwright's own runner (measured on label-page-browser.test.ts and candidate_page.test.ts).
|
|
705
|
+
// The plugin's `testIdAttribute` option belongs to `prefer-native-locators`, which this block does
|
|
706
|
+
// not enable, so there is nothing to configure.
|
|
707
|
+
{
|
|
708
|
+
files: TEST_FILES,
|
|
709
|
+
plugins: { playwright },
|
|
710
|
+
rules: {
|
|
711
|
+
"playwright/no-raw-locators": "error",
|
|
712
|
+
"playwright/prefer-locator": "error",
|
|
713
|
+
"playwright/no-wait-for-selector": "error",
|
|
714
|
+
"playwright/no-eval": "error",
|
|
715
|
+
},
|
|
716
|
+
},
|
|
700
717
|
// 🔴 DEBT, NOT A PERMISSION. The novelty-check scripts and their harnesses were moved from a consumer
|
|
701
718
|
// repository AS THEY WERE (the 2026-11-02 decision): untyped JSON in, `any` throughout. Making them pass the
|
|
702
719
|
// type-safety rules below means rewriting them, which is paperlint#339 (TypeScript rewrite of the Python
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paperlint",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.3.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",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"eslint-plugin-boundaries": "^7.2.0",
|
|
56
56
|
"eslint-plugin-functional": "^10.0.1",
|
|
57
57
|
"eslint-plugin-n": "^18.3.0",
|
|
58
|
+
"eslint-plugin-playwright": "^2.12.2",
|
|
58
59
|
"eslint-plugin-project-structure": "^3.14.5",
|
|
59
60
|
"eslint-plugin-sonarjs": "^4.2.1",
|
|
60
61
|
"jsonc-parser": "^3.3.1",
|
|
@@ -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:9d7a2c35df4e6a10 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
|
|
|
@@ -106,8 +106,10 @@ building. In one line each:
|
|
|
106
106
|
It writes `label-page/page.html` and `label-page/key.json` (the only link from `G001` to the
|
|
107
107
|
source id; it never goes on the page). `kind` (`approval`, `witness`, `labels`) is required,
|
|
108
108
|
and each kind requires its card fields on every item and practice card. Any card may also carry
|
|
109
|
-
the generic content blocks (a code excerpt with a marked line,
|
|
110
|
-
|
|
109
|
+
the generic content blocks (a code excerpt with a marked line, a note under it and an extra
|
|
110
|
+
gutter column; the words asked about as a quote; tap-to-see notes on its terms; a sub-line under
|
|
111
|
+
each answer; references, a list, a table, none of them folded), every word on them from
|
|
112
|
+
`items.json`, with the question right under the excerpt. `"preset":
|
|
111
113
|
"linter-config-witness"` is a narrow preset for comparing linter configs; the blocks underneath
|
|
112
114
|
are generic. Written by hand, the page meets the requirements above.
|
|
113
115
|
4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
|
|
@@ -142,6 +144,20 @@ building. In one line each:
|
|
|
142
144
|
same pass, and add the line "labels → `data/<study>-labels-<date>.json`" to the paper's
|
|
143
145
|
`NOTES.md` for the numbers that use them.
|
|
144
146
|
|
|
147
|
+
## Two answerers, and a time on screen
|
|
148
|
+
|
|
149
|
+
Two optional top-level keys of `items.json`, both left out by default:
|
|
150
|
+
|
|
151
|
+
- `"answerers": ["author", "second"]` — two or more people answer the same published page, each
|
|
152
|
+
under their own name (picked on the page) and without seeing the other's answers; an item's own
|
|
153
|
+
`answerers` shows it to that subset only. The store's ids become `<answerer>~<id>`, and the
|
|
154
|
+
labels file keeps each answerer's answers under `by_answerer`.
|
|
155
|
+
- `"min_seconds": 30` — a card's answer saves only after the card has been on screen that long;
|
|
156
|
+
the seconds at its first save are stored and exported as `seconds`.
|
|
157
|
+
|
|
158
|
+
Details and the export's shape: [`references/closed-choice-generator.md`](references/closed-choice-generator.md),
|
|
159
|
+
"Two answerers, and a minimum time on screen".
|
|
160
|
+
|
|
145
161
|
## When the page misbehaves
|
|
146
162
|
|
|
147
163
|
Look at what the store holds (`ArtifactData` `list`) before changing anything: the page may be fine
|
|
@@ -117,8 +117,10 @@ building. In one line each:
|
|
|
117
117
|
It writes \`label-page/page.html\` and \`label-page/key.json\` (the only link from \`G001\` to the
|
|
118
118
|
source id; it never goes on the page). \`kind\` (\`approval\`, \`witness\`, \`labels\`) is required,
|
|
119
119
|
and each kind requires its card fields on every item and practice card. Any card may also carry
|
|
120
|
-
the generic content blocks (a code excerpt with a marked line,
|
|
121
|
-
|
|
120
|
+
the generic content blocks (a code excerpt with a marked line, a note under it and an extra
|
|
121
|
+
gutter column; the words asked about as a quote; tap-to-see notes on its terms; a sub-line under
|
|
122
|
+
each answer; references, a list, a table, none of them folded), every word on them from
|
|
123
|
+
\`items.json\`, with the question right under the excerpt. \`"preset":
|
|
122
124
|
"linter-config-witness"\` is a narrow preset for comparing linter configs; the blocks underneath
|
|
123
125
|
are generic. Written by hand, the page meets the requirements above.
|
|
124
126
|
4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
|
|
@@ -153,6 +155,20 @@ building. In one line each:
|
|
|
153
155
|
same pass, and add the line "labels → \`data/<study>-labels-<date>.json\`" to the paper's
|
|
154
156
|
\`NOTES.md\` for the numbers that use them.
|
|
155
157
|
|
|
158
|
+
## Two answerers, and a time on screen
|
|
159
|
+
|
|
160
|
+
Two optional top-level keys of \`items.json\`, both left out by default:
|
|
161
|
+
|
|
162
|
+
- \`"answerers": ["author", "second"]\` — two or more people answer the same published page, each
|
|
163
|
+
under their own name (picked on the page) and without seeing the other's answers; an item's own
|
|
164
|
+
\`answerers\` shows it to that subset only. The store's ids become \`<answerer>~<id>\`, and the
|
|
165
|
+
labels file keeps each answerer's answers under \`by_answerer\`.
|
|
166
|
+
- \`"min_seconds": 30\` — a card's answer saves only after the card has been on screen that long;
|
|
167
|
+
the seconds at its first save are stored and exported as \`seconds\`.
|
|
168
|
+
|
|
169
|
+
Details and the export's shape: [\`references/closed-choice-generator.md\`](references/closed-choice-generator.md),
|
|
170
|
+
"Two answerers, and a minimum time on screen".
|
|
171
|
+
|
|
156
172
|
## When the page misbehaves
|
|
157
173
|
|
|
158
174
|
Look at what the store holds (\`ArtifactData\` \`list\`) before changing anything: the page may be fine
|
|
@@ -21,7 +21,7 @@ with the core's payload. Another page shape would be another template on the sam
|
|
|
21
21
|
Rules 2 to 5, 7 and the page half of 8 of [`checklist.md`](checklist.md): a filter instead of
|
|
22
22
|
scrolling, complete-item counting, neutral ids in a seeded order, the leak guard (an item field
|
|
23
23
|
outside the allow-list stops the build), save on click with a time, the store-unavailable banner,
|
|
24
|
-
the "How to answer" screen (folded into a toggle once the practice is done), the practice before
|
|
24
|
+
the "How to answer" screen (folded into a toggle once the practice is done; it is page-level, not part of any card), the practice before
|
|
25
25
|
the real items, one question per screen, each card's place (`labels.place`, by default
|
|
26
26
|
"G007 · 7 of 20"), the card just
|
|
27
27
|
answered kept under "Next unanswered" until the next answer (so a mis-tap can be changed), and a
|
|
@@ -111,7 +111,7 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
|
|
|
111
111
|
"mark_label": "◀ the line in question", // optional; default «◀ this line»
|
|
112
112
|
},
|
|
113
113
|
"references": {
|
|
114
|
-
"summary": "What the guideline says", // the
|
|
114
|
+
"summary": "What the guideline says", // the block's heading
|
|
115
115
|
"entries": [
|
|
116
116
|
{
|
|
117
117
|
"label": "Guideline", // optional row label
|
|
@@ -162,17 +162,50 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
|
|
|
162
162
|
4. the older fields (`code`, `unit`, `rules`, `readings`, `file`, `context`);
|
|
163
163
|
5. the situation's summary;
|
|
164
164
|
6. the excerpt;
|
|
165
|
-
7. **the question and its answers
|
|
166
|
-
8. `references`, `more` and `table`, each
|
|
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).
|
|
167
168
|
|
|
168
169
|
The question therefore sits right under the code it names.
|
|
169
170
|
|
|
170
|
-
- **`situation`**: `{summary
|
|
171
|
-
- `summary` is one sentence above the excerpt.
|
|
171
|
+
- **`situation`**: `{summary?, question}`.
|
|
172
|
+
- `summary` is one sentence above the excerpt; optional.
|
|
172
173
|
- `question` replaces the text of the page's first question on this card. Other questions keep their own text.
|
|
173
174
|
- In either text, `{line}` stands for the excerpt's first marked line. The page writes «line N: », then the line's code as code, without its indent. A code line holding a backtick reads as it is.
|
|
174
175
|
- `{line}` on a card without an `excerpt` is refused by path.
|
|
175
|
-
- **`excerpt`**: at least one line is marked (`mark: true`). The marked line gets a `▶`
|
|
176
|
+
- **`excerpt`**: at least one line is marked (`mark: true`). The marked line gets a `▶` before its number and keeps its background, so it reads as marked even without its colour; after its code comes `mark_label`, unless the excerpt has an `annotation`. A line's `text` may be empty, for a blank line.
|
|
177
|
+
- `lang` follows the file name: «path · Python».
|
|
178
|
+
- Where two lines' numbers are not consecutive, a `⋮` row says what is left out (`labels.hidden`, `labels.hidden_one`). A line with `context: true` is drawn faint: one shown only to place the code, such as an enclosing block's opener.
|
|
179
|
+
- `gutter_header` and each line's `gutter` add a column between the number and the code: one word on top, one value per line («depth», `4`). The values are the consumer's, never computed by the page.
|
|
180
|
+
- `annotation` is one short fact in a box right under the first marked line, inside the code, with an arrow pointing at the line, as a code-review comment sits.
|
|
181
|
+
|
|
182
|
+
### The approved card: a quote, notes on terms, answers that say what they mean
|
|
183
|
+
|
|
184
|
+
`scripts/fixtures/v7-cards-items.json` is a page of three such cards (#392). The fields:
|
|
185
|
+
|
|
186
|
+
```jsonc
|
|
187
|
+
{
|
|
188
|
+
"quote": { "text": "Docstrings use Google-style format", "source": "`.github/copilot-instructions.md`, line 67" },
|
|
189
|
+
"situation": { "question": "Do these words forbid line 48?" },
|
|
190
|
+
"excerpt": { "file": "src/tables/merge.py", "lang": "Python", "annotation": "Ruff rule `D413` reports: …", "lines": [ … ] },
|
|
191
|
+
"terms": [
|
|
192
|
+
{
|
|
193
|
+
"term": "Google-style", // the card's own words that open the note
|
|
194
|
+
"note": [
|
|
195
|
+
// one or more entries; each has `quote` or `code` (verbatim), and its source link
|
|
196
|
+
{ "title": "Google style", "quote": "Each section begins with a heading line, which ends with a colon.",
|
|
197
|
+
"example": "(e.g. `Args:`)", "url": "https://google.github.io/styleguide/pyguide.html", "url_label": "Google Python Style Guide" },
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
"answer_notes": { "forbid": { "forbid": "like a config with rule D413 on, which flags it" } }, // question → answer → sub-line
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
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`.
|
|
206
|
+
- **`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
|
+
- **`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
|
+
- Whether a note pushes the labeller toward one answer is not checked: the consumer lists each note for the study's author.
|
|
176
209
|
- **`references.entries[].fields`**:
|
|
177
210
|
- A field without `style` is a paragraph («label: text» when it has a label). A long one shows three lines and a «more».
|
|
178
211
|
- `style: "code"` is a code block on a `label` row.
|
|
@@ -189,6 +222,9 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
|
|
|
189
222
|
| `line_part` | the `unit` row's label when the line holds several requirements | `{line} · part {part} of {of}` |
|
|
190
223
|
| `repeat` | the repeat sentence | `Card {index} of {of}` |
|
|
191
224
|
| `practice_example` | text before each answer's example on a practice button | empty |
|
|
225
|
+
| `hidden` | a `⋮` row for lines left out | `lines {from}–{to}` |
|
|
226
|
+
| `hidden_one` | a `⋮` row for one line left out | `line {from}` |
|
|
227
|
+
| `wait` | the bar under the answers while `min_seconds` runs | `⏳ You can answer in {s} s` |
|
|
192
228
|
|
|
193
229
|
- The leak guard reads every string these blocks render, one string at a time.
|
|
194
230
|
|
|
@@ -199,7 +235,7 @@ A preset is opt-in. An items file names it with `"preset": "<name>"`. The preset
|
|
|
199
235
|
- **`linter-config-witness`** is narrow: two linter configs of one requirement disagree on a line of code. The blocks underneath are generic.
|
|
200
236
|
- **Input fields on a card:**
|
|
201
237
|
- `example: {file, url?, lines, message, caught_by}`, which becomes the excerpt, with the caption «The linter says: …».
|
|
202
|
-
- `rules: [{linter, id, explanation}]`, whose explanation has `source` (`ruff rule`, `eslint docs`, `plugin docs`, `config message` or `none`), plus `what`, `why`, `example_bad`, `example_good` and `url`. These become the references,
|
|
238
|
+
- `rules: [{linter, id, explanation}]`, whose explanation has `source` (`ruff rule`, `eslint docs`, `plugin docs`, `config message` or `none`), plus `what`, `why`, `example_bad`, `example_good` and `url`. These become the references, under «What the linter rule does (official docs)».
|
|
203
239
|
- `more_like_this: {count, files}`, which becomes the list «N more file(s) with the same situation».
|
|
204
240
|
- `details: {a, b}`: rows of `{key, raw, plain, matches_example}`. They become the table «Settings behind this card», with «A only / B only / A and B» rows.
|
|
205
241
|
- **Its labels**: «Card {n} of {total}», «Requirement», «Card {index} of {of} for this requirement», and «Example of this answer, about other code: ».
|
|
@@ -246,6 +282,48 @@ author cannot read it is an exclusion made after the draw and goes unrecorded. T
|
|
|
246
282
|
units whose fate is obvious. Each dropped unit carries its reason in the labels file. The author's words, with the date, then go into
|
|
247
283
|
`author_check` of the plan; a dropped unit is replaced by the next one in the draw.
|
|
248
284
|
|
|
285
|
+
## Two answerers, and a minimum time on screen
|
|
286
|
+
|
|
287
|
+
Both keys are optional and top-level. Left out, the page, its store documents and the export are
|
|
288
|
+
exactly as above.
|
|
289
|
+
|
|
290
|
+
- **`answerers`**: two or more names (letters, digits, `-` and `_`), for a study where two people
|
|
291
|
+
answer the same published page, each under their own name and without seeing the other's
|
|
292
|
+
answers. The page first asks "Who is answering?"; the choice is kept in the browser's
|
|
293
|
+
localStorage when it has one (otherwise it asks again on every visit), and "Switch" beside
|
|
294
|
+
"Answering as …" asks again. Every document the page writes is named `<answerer>~<id>`
|
|
295
|
+
(`second~G004`, `second~r1-P2`, `second~python`): `~` is outside the name pattern, so no answerer,
|
|
296
|
+
item, group or practice id can hold it. The page reads only the current answerer's documents, so
|
|
297
|
+
their progress, done-state and practice are their own. An item may carry its own `answerers`, a
|
|
298
|
+
subset of the page's: it is shown only to those. A name an item lists that the page does not, a
|
|
299
|
+
name twice, or an item's list on a page without one stops the build.
|
|
300
|
+
- **`min_seconds`**: an item's answer saves only after its card has been on screen that long. The
|
|
301
|
+
cards are a list, so "on screen" is measured by an `IntersectionObserver`: half the card in view,
|
|
302
|
+
or half the viewport filled by a card taller than that, while the tab is visible. A locked card
|
|
303
|
+
shows "You can answer in N s" under its disabled buttons. The answer document gains `seconds`:
|
|
304
|
+
how long the card had been on screen at its first save, kept on every later save. The practice
|
|
305
|
+
cards are not held.
|
|
306
|
+
|
|
307
|
+
`key.json` carries `answerers`, each item's own subset (`ids.<id>.answerers`, only where it has one)
|
|
308
|
+
and `min_seconds`. With `answerers`, `export` refuses a document id without `<answerer>~`, an
|
|
309
|
+
answerer the key does not list, and an answer to an item not shown to its answerer, as it refuses
|
|
310
|
+
an unknown item. The labels file keeps each answerer's answers apart:
|
|
311
|
+
|
|
312
|
+
```text
|
|
313
|
+
{ "answerers": ["author", "second"],
|
|
314
|
+
"items": [{ "id": "G001", "source_id": "case-1000", "group": "python",
|
|
315
|
+
"answerers": ["author", "second"],
|
|
316
|
+
"by_answerer": { "author": { "answers": { "label": "yes" }, "complete": true,
|
|
317
|
+
"seconds": 41, "…": "the fields of an item above" },
|
|
318
|
+
"second": { "…": "…" } } }],
|
|
319
|
+
"by_answerer": { "author": { "groups": {}, "complete_items": 1, "total_items": 1, "…": "…" },
|
|
320
|
+
"second": { "…": "…" } } }
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
An answerer with no document for an item is still listed, with every answer `null`. Each
|
|
324
|
+
answerer's practice is scored into its own file, `<study>-practice-r<round>-<answerer>-<date>.json`.
|
|
325
|
+
`seconds` appears only when the page was built with `min_seconds`.
|
|
326
|
+
|
|
249
327
|
## The export
|
|
250
328
|
|
|
251
329
|
`export` reads the store's three collections (`answers`, `groups`, `practice`) from `dump.json`, in
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
{
|
|
2
|
+
"study": "demo-words-forbid",
|
|
3
|
+
"kind": "labels",
|
|
4
|
+
"title": "Do the words forbid it",
|
|
5
|
+
"seed": 3,
|
|
6
|
+
"min_seconds": 10,
|
|
7
|
+
"groups": { "all": "Cards" },
|
|
8
|
+
"labels": { "place": "Card {n} of {total}", "wait": "Save · available after {s} seconds on this card" },
|
|
9
|
+
"questions": [
|
|
10
|
+
{
|
|
11
|
+
"id": "forbid",
|
|
12
|
+
"text": "Do these words forbid the marked line?",
|
|
13
|
+
"short": "do the words forbid it?",
|
|
14
|
+
"options": [
|
|
15
|
+
{ "value": "forbid", "label": "Forbid", "icon": "⛔", "example": "«No print calls» and the line print(x)" },
|
|
16
|
+
{ "value": "allow", "label": "Allow", "icon": "✅", "example": "«No print calls» and the line log.info(x)" },
|
|
17
|
+
{ "value": "unsettled", "label": "The words don't settle it", "icon": "↔️", "example": "«Keep functions short» and a 30-line function" },
|
|
18
|
+
{ "value": "elsewhere", "label": "The words are about something else", "icon": "🔀", "example": "«Use tabs» and a line about a database query" },
|
|
19
|
+
{ "value": "cannot", "label": "I can't judge (say why)", "icon": "❔", "example": "a line in a language you do not read", "ask_why": "What stops you?" }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"practice": [
|
|
24
|
+
{
|
|
25
|
+
"quote": { "text": "No print calls in library code", "source": "`CONTRIBUTING.md`, line 4" },
|
|
26
|
+
"situation": { "question": "Do these words forbid line 3?" },
|
|
27
|
+
"excerpt": { "file": "lib/sum.py", "lang": "Python", "lines": [
|
|
28
|
+
{ "n": 2, "text": "def total(xs):", "mark": false },
|
|
29
|
+
{ "n": 3, "text": " print(xs)", "mark": true }
|
|
30
|
+
] },
|
|
31
|
+
"expected": { "forbid": "forbid" }, "why": "print is a print call, and lib/ is library code."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"quote": { "text": "No print calls in library code", "source": "`CONTRIBUTING.md`, line 4" },
|
|
35
|
+
"situation": { "question": "Do these words forbid line 3?" },
|
|
36
|
+
"excerpt": { "file": "lib/sum.py", "lang": "Python", "lines": [
|
|
37
|
+
{ "n": 2, "text": "def total(xs):", "mark": false },
|
|
38
|
+
{ "n": 3, "text": " log.debug(xs)", "mark": true }
|
|
39
|
+
] },
|
|
40
|
+
"expected": { "forbid": "allow" }, "why": "A log call is not a print call."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"quote": { "text": "Use tabs for indentation", "source": "`STYLE.md`, line 2" },
|
|
44
|
+
"situation": { "question": "Do these words forbid line 8?" },
|
|
45
|
+
"excerpt": { "file": "db/query.py", "lang": "Python", "lines": [
|
|
46
|
+
{ "n": 8, "text": "rows = db.execute(sql)", "mark": true }
|
|
47
|
+
] },
|
|
48
|
+
"expected": { "forbid": "elsewhere" }, "why": "The words are about indentation; the line is a query."
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"items": [
|
|
52
|
+
{
|
|
53
|
+
"source_id": "card-docstring", "group": "all",
|
|
54
|
+
"quote": { "text": "Docstrings use Google-style format", "source": "`.github/copilot-instructions.md`, line 67" },
|
|
55
|
+
"situation": { "question": "Do these words forbid line 48?" },
|
|
56
|
+
"excerpt": {
|
|
57
|
+
"file": "src/tables/merge.py", "lang": "Python",
|
|
58
|
+
"annotation": "Ruff rule `D413` reports: “Missing blank line after last section (\"Returns\")”",
|
|
59
|
+
"lines": [
|
|
60
|
+
{ "n": 37, "text": "def merge_tables(", "mark": false, "context": true },
|
|
61
|
+
{ "n": 40, "text": " \"\"\"", "mark": false },
|
|
62
|
+
{ "n": 41, "text": " Merge the tables picked by the flags.", "mark": false },
|
|
63
|
+
{ "n": 42, "text": "", "mark": false },
|
|
64
|
+
{ "n": 43, "text": " picks: For each table name, whether to keep its rows of each kind", "mark": false },
|
|
65
|
+
{ "n": 44, "text": " (kept, dropped), and which of the two to merge first.", "mark": false },
|
|
66
|
+
{ "n": 45, "text": " tables: Every table that can be merged.", "mark": false },
|
|
67
|
+
{ "n": 46, "text": " Columns needed: \"name\", \"kind\"", "mark": false },
|
|
68
|
+
{ "n": 47, "text": "", "mark": false },
|
|
69
|
+
{ "n": 48, "text": " Returns:", "mark": true },
|
|
70
|
+
{ "n": 49, "text": " One table with the rows of every table picked.", "mark": false },
|
|
71
|
+
{ "n": 50, "text": " \"\"\"", "mark": false },
|
|
72
|
+
{ "n": 51, "text": " kinds_needed = {", "mark": false }
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"terms": [
|
|
76
|
+
{ "term": "Ruff", "note": [{ "quote": "An extremely fast Python linter and code formatter, written in Rust.", "url": "https://docs.astral.sh/ruff/", "url_label": "Ruff docs" }] },
|
|
77
|
+
{ "term": "D413", "note": [{ "title": "Ruff rule D413", "quote": "Checks for missing blank lines after the last section of a multiline docstring", "url": "https://docs.astral.sh/ruff/rules/missing-blank-line-after-last-section/", "url_label": "docs" }] },
|
|
78
|
+
{ "term": "Google-style", "note": [
|
|
79
|
+
{ "title": "Google style", "quote": "Each section begins with a heading line, which ends with a colon.", "example": "(e.g. `Args:`, `Returns:`, `Raises:`)", "url": "https://google.github.io/styleguide/pyguide.html#383-functions-and-methods", "url_label": "Google Python Style Guide, 3.8.3" },
|
|
80
|
+
{ "title": "NumPy style, the other common one", "quote": "Each heading should be underlined in hyphens", "example": "(e.g. `Returns` with `-------` on the line under it)", "url": "https://numpydoc.readthedocs.io/en/latest/format.html#sections", "url_label": "numpydoc format guide" }
|
|
81
|
+
] },
|
|
82
|
+
{ "term": ".github/copilot-instructions.md", "note": [{ "quote": "Repository-wide custom instructions apply to all requests made in the context of a repository.", "url": "https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions", "url_label": "GitHub Docs" }] }
|
|
83
|
+
],
|
|
84
|
+
"answer_notes": { "forbid": { "forbid": "like a config with rule D413 on, which flags it", "allow": "like a config without D413, which passes it" } }
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"source_id": "card-call", "group": "all",
|
|
88
|
+
"quote": { "text": "Use cachedElement('elementId') instead of document.getElementById", "source": "`AGENTS.md`, line 7" },
|
|
89
|
+
"situation": { "question": "Do these words forbid line 22?" },
|
|
90
|
+
"excerpt": {
|
|
91
|
+
"file": "src/dom/cache.ts", "lang": "TypeScript",
|
|
92
|
+
"annotation": "calls `document.getElementById`",
|
|
93
|
+
"lines": [
|
|
94
|
+
{ "n": 9, "text": "export const cachedElement = (id: string) => {", "mark": false, "context": true },
|
|
95
|
+
{ "n": 19, "text": " return hit", "mark": false },
|
|
96
|
+
{ "n": 20, "text": " }", "mark": false },
|
|
97
|
+
{ "n": 21, "text": "", "mark": false },
|
|
98
|
+
{ "n": 22, "text": " const el = document.getElementById(id)", "mark": true },
|
|
99
|
+
{ "n": 23, "text": "", "mark": false },
|
|
100
|
+
{ "n": 24, "text": " if (!el) {", "mark": false },
|
|
101
|
+
{ "n": 25, "text": " throw new TypeError(`No element with id \"${id}\"`)", "mark": false }
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
"terms": [
|
|
105
|
+
{ "term": "cachedElement", "note": [{ "says": "this project's own function, defined at line 9 of the file above; its comment reads", "quote": "A cache for DOM elements", "url": "https://example.com/src/dom/cache.ts#L3-L9", "url_label": "src/dom/cache.ts" }] },
|
|
106
|
+
{ "term": "document.getElementById", "note": [{ "quote": "returns an Element object representing the element whose id property matches the specified string", "url": "https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById", "url_label": "MDN" }] },
|
|
107
|
+
{ "term": "AGENTS.md", "note": [{ "quote": "a dedicated, predictable place to provide the context and instructions to help AI coding agents work on your project.", "url": "https://agents.md/", "url_label": "agents.md" }] }
|
|
108
|
+
],
|
|
109
|
+
"answer_notes": { "forbid": { "forbid": "like a config that bans this call and checks this file", "allow": "like a config that bans this call but doesn't check this file" } }
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"source_id": "card-depth", "group": "all",
|
|
113
|
+
"quote": { "text": "Avoid deep nesting (use early returns and guard clauses)", "source": "`CLAUDE.md`, line 85" },
|
|
114
|
+
"situation": { "question": "Do these words forbid line 279?" },
|
|
115
|
+
"excerpt": {
|
|
116
|
+
"file": "src/index/FileIndex.ts", "lang": "TypeScript", "gutter_header": "depth",
|
|
117
|
+
"annotation": "nested 4 blocks deep",
|
|
118
|
+
"lines": [
|
|
119
|
+
{ "n": 22, "text": "export class FileIndex {", "mark": false, "context": true },
|
|
120
|
+
{ "n": 257, "text": " private onDeleted(uri: Uri): void {", "mark": false, "context": true },
|
|
121
|
+
{ "n": 262, "text": " try {", "mark": false, "context": true, "gutter": "1" },
|
|
122
|
+
{ "n": 276, "text": " if (entry.isDir) {", "mark": false, "gutter": "2" },
|
|
123
|
+
{ "n": 277, "text": " const prefix = `${rel}/`;", "mark": false },
|
|
124
|
+
{ "n": 278, "text": " for (const key of this.entries.keys()) {", "mark": false, "gutter": "3" },
|
|
125
|
+
{ "n": 279, "text": " if (key.startsWith(prefix)) {", "mark": true, "gutter": "4" },
|
|
126
|
+
{ "n": 280, "text": " this.entries.delete(key);", "mark": false },
|
|
127
|
+
{ "n": 281, "text": " }", "mark": false },
|
|
128
|
+
{ "n": 282, "text": " }", "mark": false }
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"terms": [
|
|
132
|
+
{ "term": "guard clauses", "note": [{ "code": "function price(order) {\n if (!order) return 0;\n if (order.free) return 0;\n if (order.staff) return staffPrice(order);\n return listPrice(order);\n}", "url": "https://example.com/style-guide#guard-clauses", "url_label": "Style guide (example): guard clauses" }] },
|
|
133
|
+
{ "term": "CLAUDE.md", "note": [{ "quote": "CLAUDE.md files are markdown files that give Claude persistent instructions for a project, your personal workflow, or your entire organization.", "url": "https://code.claude.com/docs/en/memory", "url_label": "Claude Code docs" }] }
|
|
134
|
+
],
|
|
135
|
+
"answer_notes": { "forbid": { "forbid": "like a config with limit 3, which flags it", "allow": "like ESLint's default, limit 4, which passes it" } }
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|