paperlint 14.0.0 → 14.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,9 @@ type SchemaNode = ({
18
18
  kind: "string";
19
19
  required: boolean;
20
20
  pattern?: string;
21
+ } | {
22
+ kind: "text";
23
+ required: boolean;
21
24
  } | {
22
25
  kind: "integer";
23
26
  required: boolean;
@@ -71,10 +74,67 @@ interface Unit {
71
74
  readonly part: number;
72
75
  readonly of: number;
73
76
  }
77
+ interface TextField {
78
+ readonly label?: string;
79
+ readonly text: string;
80
+ readonly style?: string;
81
+ }
82
+ interface CodeLine {
83
+ readonly n: number;
84
+ readonly text: string;
85
+ readonly mark: boolean;
86
+ }
87
+ /** The content blocks (#380), each optional on any kind. */
88
+ interface Blocks {
89
+ readonly situation?: {
90
+ readonly summary: string;
91
+ readonly question: string;
92
+ };
93
+ readonly excerpt?: {
94
+ readonly file?: string;
95
+ readonly url?: string;
96
+ readonly lines: readonly CodeLine[];
97
+ readonly caption?: string;
98
+ readonly mark_label?: string;
99
+ };
100
+ readonly references?: {
101
+ readonly summary: string;
102
+ readonly entries: readonly {
103
+ readonly label?: string;
104
+ readonly title: string;
105
+ readonly fields: readonly TextField[];
106
+ readonly url?: string;
107
+ readonly url_label?: string;
108
+ }[];
109
+ };
110
+ readonly more?: {
111
+ readonly summary: string;
112
+ readonly items: readonly string[];
113
+ readonly rest?: string;
114
+ };
115
+ readonly table?: {
116
+ readonly summary: string;
117
+ readonly note?: string;
118
+ readonly rows: readonly {
119
+ readonly label: string;
120
+ readonly value: string;
121
+ readonly plain?: string;
122
+ readonly mark?: {
123
+ readonly tone: string;
124
+ readonly text: string;
125
+ };
126
+ }[];
127
+ };
128
+ readonly repeat?: {
129
+ readonly group: string;
130
+ readonly index: number;
131
+ readonly of: number;
132
+ };
133
+ }
74
134
  /** What a card shows, on an item and on a practice item alike. */
75
135
  type Card = {
76
136
  readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
77
- } & {
137
+ } & Blocks & {
78
138
  readonly body: string;
79
139
  readonly rules?: readonly RuleRef[];
80
140
  readonly unit?: Unit;
@@ -86,6 +146,7 @@ type Card = {
86
146
  type Item = Card & {
87
147
  readonly source_id: string;
88
148
  readonly group: string;
149
+ readonly answerers?: readonly string[];
89
150
  };
90
151
  type Practice = Card & {
91
152
  readonly expected: Readonly<Record<string, string>>;
@@ -99,17 +160,22 @@ interface Config {
99
160
  readonly seed: number;
100
161
  readonly groups: Readonly<Record<string, string>>;
101
162
  readonly labeller_knows?: readonly string[];
163
+ readonly answerers?: readonly string[];
164
+ readonly min_seconds?: number;
102
165
  readonly familiarity?: boolean;
103
166
  readonly familiarity_question?: string;
104
167
  readonly questions: readonly Question[];
105
168
  readonly practice: readonly Practice[];
106
169
  readonly practice_round?: number;
170
+ readonly labels?: Readonly<Record<string, string>>;
107
171
  readonly items: readonly Item[];
108
172
  }
109
173
  interface KeyEntry {
110
174
  readonly source_id: string;
111
175
  readonly group: string;
112
176
  readonly on_page: boolean;
177
+ /** Who the item is shown to, when not every answerer of the page. */
178
+ readonly answerers?: readonly string[];
113
179
  }
114
180
  /** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
115
181
  type Described = ReadonlyMap<string, RuleText>;
@@ -122,18 +188,6 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
122
188
  };
123
189
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
124
190
  declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
125
- title: string;
126
- intro: string;
127
- groups: {
128
- id: string;
129
- label: string;
130
- }[];
131
- familiarity_question: string;
132
- familiarity: {
133
- value: string;
134
- label: string;
135
- }[];
136
- allowed: JsonObject;
137
191
  questions: readonly Question[];
138
192
  practice: {
139
193
  round: number;
@@ -155,9 +209,11 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
155
209
  })[];
156
210
  };
157
211
  items: ({
212
+ answerers?: readonly string[];
158
213
  rules?: never;
159
214
  id: string;
160
215
  } | {
216
+ answerers?: readonly string[];
161
217
  rules: {
162
218
  description?: string;
163
219
  family?: string;
@@ -166,6 +222,23 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
166
222
  }[];
167
223
  id: string;
168
224
  })[];
225
+ answerers?: readonly string[];
226
+ min_seconds?: number;
227
+ title: string;
228
+ intro: string;
229
+ labels: {
230
+ [x: string]: string;
231
+ };
232
+ groups: {
233
+ id: string;
234
+ label: string;
235
+ }[];
236
+ familiarity_question: string;
237
+ familiarity: {
238
+ value: string;
239
+ label: string;
240
+ }[];
241
+ allowed: JsonObject;
169
242
  };
170
243
  export type Payload = ReturnType<typeof payloadOf>;
171
244
  /**
@@ -175,18 +248,6 @@ export type Payload = ReturnType<typeof payloadOf>;
175
248
  */
176
249
  export declare function prepareBuild(configPath: string, outDir: string): {
177
250
  payload: {
178
- title: string;
179
- intro: string;
180
- groups: {
181
- id: string;
182
- label: string;
183
- }[];
184
- familiarity_question: string;
185
- familiarity: {
186
- value: string;
187
- label: string;
188
- }[];
189
- allowed: JsonObject;
190
251
  questions: readonly Question[];
191
252
  practice: {
192
253
  round: number;
@@ -208,9 +269,11 @@ export declare function prepareBuild(configPath: string, outDir: string): {
208
269
  })[];
209
270
  };
210
271
  items: ({
272
+ answerers?: readonly string[];
211
273
  rules?: never;
212
274
  id: string;
213
275
  } | {
276
+ answerers?: readonly string[];
214
277
  rules: {
215
278
  description?: string;
216
279
  family?: string;
@@ -219,6 +282,23 @@ export declare function prepareBuild(configPath: string, outDir: string): {
219
282
  }[];
220
283
  id: string;
221
284
  })[];
285
+ answerers?: readonly string[];
286
+ min_seconds?: number;
287
+ title: string;
288
+ intro: string;
289
+ labels: {
290
+ [x: string]: string;
291
+ };
292
+ groups: {
293
+ id: string;
294
+ label: string;
295
+ }[];
296
+ familiarity_question: string;
297
+ familiarity: {
298
+ value: string;
299
+ label: string;
300
+ }[];
301
+ allowed: JsonObject;
222
302
  };
223
303
  dropped: number;
224
304
  };
@@ -0,0 +1,10 @@
1
+ /** A preset's result: the config in the generic shape, or why it cannot be made. */
2
+ export type Preset = {
3
+ readonly ok: true;
4
+ readonly value: unknown;
5
+ } | {
6
+ readonly ok: false;
7
+ readonly message: string;
8
+ };
9
+ /** The config with its preset applied; a config without `preset` comes back as it was. */
10
+ export declare function applyPreset(raw: unknown): Preset;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "14.0.0",
3
+ "version": "14.2.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:6e828827dc6fcfc1 compiled from skills/build-label-page/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:dce0979d7f187ae7 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
 
@@ -105,7 +105,11 @@ building. In one line each:
105
105
 
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
- and each kind requires its card fields on every item and practice card. Written by hand, the page meets the requirements above.
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, references, a list, a table), every
110
+ word on them from `items.json`, with the question right under the excerpt. `"preset":
111
+ "linter-config-witness"` is a narrow preset for comparing linter configs; the blocks underneath
112
+ are generic. Written by hand, the page meets the requirements above.
109
113
  4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
110
114
  nothing about the source: if anything on it tells the answer, take it out. Read each question as
111
115
  someone who did not design the study: can they tell what each answer means from the question, its
@@ -138,6 +142,20 @@ building. In one line each:
138
142
  same pass, and add the line "labels → `data/<study>-labels-<date>.json`" to the paper's
139
143
  `NOTES.md` for the numbers that use them.
140
144
 
145
+ ## Two answerers, and a time on screen
146
+
147
+ Two optional top-level keys of `items.json`, both left out by default:
148
+
149
+ - `"answerers": ["author", "second"]` — two or more people answer the same published page, each
150
+ under their own name (picked on the page) and without seeing the other's answers; an item's own
151
+ `answerers` shows it to that subset only. The store's ids become `<answerer>~<id>`, and the
152
+ labels file keeps each answerer's answers under `by_answerer`.
153
+ - `"min_seconds": 30` — a card's answer saves only after the card has been on screen that long;
154
+ the seconds at its first save are stored and exported as `seconds`.
155
+
156
+ Details and the export's shape: [`references/closed-choice-generator.md`](references/closed-choice-generator.md),
157
+ "Two answerers, and a minimum time on screen".
158
+
141
159
  ## When the page misbehaves
142
160
 
143
161
  Look at what the store holds (`ArtifactData` `list`) before changing anything: the page may be fine
@@ -116,7 +116,11 @@ building. In one line each:
116
116
 
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
- and each kind requires its card fields on every item and practice card. Written by hand, the page meets the requirements above.
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, references, a list, a table), every
121
+ word on them from \`items.json\`, with the question right under the excerpt. \`"preset":
122
+ "linter-config-witness"\` is a narrow preset for comparing linter configs; the blocks underneath
123
+ are generic. Written by hand, the page meets the requirements above.
120
124
  4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
121
125
  nothing about the source: if anything on it tells the answer, take it out. Read each question as
122
126
  someone who did not design the study: can they tell what each answer means from the question, its
@@ -149,6 +153,20 @@ building. In one line each:
149
153
  same pass, and add the line "labels → \`data/<study>-labels-<date>.json\`" to the paper's
150
154
  \`NOTES.md\` for the numbers that use them.
151
155
 
156
+ ## Two answerers, and a time on screen
157
+
158
+ Two optional top-level keys of \`items.json\`, both left out by default:
159
+
160
+ - \`"answerers": ["author", "second"]\` — two or more people answer the same published page, each
161
+ under their own name (picked on the page) and without seeing the other's answers; an item's own
162
+ \`answerers\` shows it to that subset only. The store's ids become \`<answerer>~<id>\`, and the
163
+ labels file keeps each answerer's answers under \`by_answerer\`.
164
+ - \`"min_seconds": 30\` — a card's answer saves only after the card has been on screen that long;
165
+ the seconds at its first save are stored and exported as \`seconds\`.
166
+
167
+ Details and the export's shape: [\`references/closed-choice-generator.md\`](references/closed-choice-generator.md),
168
+ "Two answerers, and a minimum time on screen".
169
+
152
170
  ## When the page misbehaves
153
171
 
154
172
  Look at what the store holds (\`ArtifactData\` \`list\`) before changing anything: the page may be fine
@@ -21,7 +21,11 @@ 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, the practice before the real items, one question per screen. Its look:
24
+ the "How to answer" screen (folded into a toggle once the practice is done), the practice before
25
+ the real items, one question per screen, each card's place (`labels.place`, by default
26
+ "G007 · 7 of 20"), the card just
27
+ answered kept under "Next unanswered" until the next answer (so a mis-tap can be changed), and a
28
+ closing line once every item is complete. Its look:
25
29
  one dark palette, one column, answer buttons as full-width rows with space between them, an optional
26
30
  icon per answer and never a coloured side stripe, `backticks` and the `code` field shown as code,
27
31
  code blocks that show leading and trailing whitespace (a space as ·, a tab as →) and each line's
@@ -76,10 +80,130 @@ length.
76
80
  full. Splitting the line is the study's job, before the page.
77
81
  - **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
78
82
  next one; a different seed is refused once a key exists.
79
- - **`ask_why`** on an option: a prompt. That answer is incomplete, on the page and in the export,
80
- until a reason is saved. The reason saves as it is typed (after a pause, and when the page is
81
- left), so a labeller who closes the page from inside the box does not leave a drop without its
82
- reason; a store echo while typing keeps the box and its focus.
83
+ - **`ask_why`** on an option: a prompt. The page does not write that answer to the store until its
84
+ reason box holds text: the card says «Not saved yet», and the store keeps what it had. The reason
85
+ then saves with the answer as it is typed (after a pause, and when the page is left); a store echo
86
+ while typing keeps the box and its focus. An answer stored without its reason (by an older page,
87
+ or merged from chat) still counts as incomplete, on the page and in the export.
88
+
89
+ ## Content blocks
90
+
91
+ Any card may carry these blocks, on any `kind`, and each one is optional. The page knows only the shape of each block. Every label and sentence on it comes from `items.json`, so a code review, a prose check or a configuration study each write their own words. A practice card carries the same blocks as the real cards it rehearses. `scripts/fixtures/blocks-items.json` is a whole page of them: a code review, with no preset.
92
+
93
+ ```jsonc
94
+ {
95
+ "situation": {
96
+ "summary": "Reviewer A asked for a change at {line}; reviewer B approved it.",
97
+ "question": "Does «Every query is parameterised» cover {line}?",
98
+ },
99
+ "excerpt": {
100
+ "file": "app/report.py", // optional; with `url`, the name is a link
101
+ "url": "https://example.com/app/report.py#L12", // optional
102
+ "lines": [
103
+ { "n": 11, "text": "def monthly(month):", "mark": false },
104
+ {
105
+ "n": 12,
106
+ "text": " rows = db.raw(\"SELECT … m=\" + month)",
107
+ "mark": true,
108
+ },
109
+ ],
110
+ "caption": "Reviewer A wrote: concatenated SQL.", // optional, under the code
111
+ "mark_label": "◀ the line in question", // optional; default «◀ this line»
112
+ },
113
+ "references": {
114
+ "summary": "What the guideline says", // the folded block's toggle
115
+ "entries": [
116
+ {
117
+ "label": "Guideline", // optional row label
118
+ "title": "Security guide `SQL-1`",
119
+ "fields": [
120
+ { "text": "Build every query with parameters." },
121
+ { "label": "Why", "text": "Input becomes part of the query." }, // «Why: …»
122
+ {
123
+ "style": "note",
124
+ "text": "The examples below are the guide's own.",
125
+ },
126
+ {
127
+ "label": "Not like this",
128
+ "style": "code",
129
+ "text": "db.raw(\"… id=\" + id)",
130
+ },
131
+ ],
132
+ "url": "https://example.com/guide#sql-1", // optional
133
+ "url_label": "Security guide", // optional; the link text
134
+ },
135
+ ],
136
+ },
137
+ "more": {
138
+ "summary": "3 more lines like this",
139
+ "items": ["`app/export.py`", "`app/admin.py`"],
140
+ "rest": "and 1 more", // optional, after the items
141
+ },
142
+ "table": {
143
+ "summary": "What each reviewer wrote",
144
+ "note": "Reviewer A asked for the change; reviewer B approved.", // optional
145
+ "rows": [
146
+ {
147
+ "label": "A only · Comment",
148
+ "value": "use parameters", // shown as code
149
+ "plain": "asks for a parameterised query", // optional
150
+ "mark": { "tone": "yes", "text": "✓ about this line" }, // optional; tone yes or no
151
+ },
152
+ ],
153
+ },
154
+ "repeat": { "group": "guide-sql", "index": 1, "of": 2 }, // `group` never reaches the page
155
+ }
156
+ ```
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 and its answers**;
166
+ 8. `references`, `more` and `table`, each folded and closed.
167
+
168
+ The question therefore sits right under the code it names.
169
+
170
+ - **`situation`**: `{summary, question}`.
171
+ - `summary` is one sentence above the excerpt.
172
+ - `question` replaces the text of the page's first question on this card. Other questions keep their own text.
173
+ - 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
+ - `{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 `▶` in the gutter and `mark_label` after its code, and keeps its background, so it reads as marked even without its colour. A line's `text` may be empty, for a blank line.
176
+ - **`references.entries[].fields`**:
177
+ - A field without `style` is a paragraph («label: text» when it has a label). A long one shows three lines and a «more».
178
+ - `style: "code"` is a code block on a `label` row.
179
+ - `style: "note"` is a quiet line.
180
+ - **`table.rows`**: shown in the order given. Each row shows its `label`, then `value` as code, `plain` under it, and `mark` last.
181
+ - **`repeat`**: shows the sentence `labels.repeat` when `of` is more than 1. `index` must lie between 1 and `of`.
182
+ - **`labels`** sits at page level, and every key in it is optional:
183
+
184
+ | key | what it is | default |
185
+ | ------------------ | --------------------------------------------------------------- | ------------------------------ |
186
+ | `place` | the card's place | `{id} · {n} of {total}` |
187
+ | `body` | a row label for the body | none |
188
+ | `line` | the label of the `unit` row | `Line` |
189
+ | `line_part` | the `unit` row's label when the line holds several requirements | `{line} · part {part} of {of}` |
190
+ | `repeat` | the repeat sentence | `Card {index} of {of}` |
191
+ | `practice_example` | text before each answer's example on a practice button | empty |
192
+
193
+ - The leak guard reads every string these blocks render, one string at a time.
194
+
195
+ ### Presets: named words for a narrow use
196
+
197
+ A preset is opt-in. An items file names it with `"preset": "<name>"`. The preset maps its own compact card fields onto the blocks above and supplies `labels`. The file's own `labels` override the preset's. The page and the core know nothing of any preset: each one lives in `scripts/label-page-presets.ts`.
198
+
199
+ - **`linter-config-witness`** is narrow: two linter configs of one requirement disagree on a line of code. The blocks underneath are generic.
200
+ - **Input fields on a card:**
201
+ - `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, folded under «What the linter rule does (official docs)».
203
+ - `more_like_this: {count, files}`, which becomes the list «N more file(s) with the same situation».
204
+ - `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
+ - **Its labels**: «Card {n} of {total}», «Requirement», «Card {index} of {of} for this requirement», and «Example of this answer, about other code: ».
206
+ - `scripts/fixtures/linter-config-witness-items.json` is a whole page of it.
83
207
 
84
208
  ## The approval page
85
209
 
@@ -122,6 +246,48 @@ author cannot read it is an exclusion made after the draw and goes unrecorded. T
122
246
  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
123
247
  `author_check` of the plan; a dropped unit is replaced by the next one in the draw.
124
248
 
249
+ ## Two answerers, and a minimum time on screen
250
+
251
+ Both keys are optional and top-level. Left out, the page, its store documents and the export are
252
+ exactly as above.
253
+
254
+ - **`answerers`**: two or more names (letters, digits, `-` and `_`), for a study where two people
255
+ answer the same published page, each under their own name and without seeing the other's
256
+ answers. The page first asks "Who is answering?"; the choice is kept in the browser's
257
+ localStorage when it has one (otherwise it asks again on every visit), and "Switch" beside
258
+ "Answering as …" asks again. Every document the page writes is named `<answerer>~<id>`
259
+ (`second~G004`, `second~r1-P2`, `second~python`): `~` is outside the name pattern, so no answerer,
260
+ item, group or practice id can hold it. The page reads only the current answerer's documents, so
261
+ their progress, done-state and practice are their own. An item may carry its own `answerers`, a
262
+ subset of the page's: it is shown only to those. A name an item lists that the page does not, a
263
+ name twice, or an item's list on a page without one stops the build.
264
+ - **`min_seconds`**: an item's answer saves only after its card has been on screen that long. The
265
+ cards are a list, so "on screen" is measured by an `IntersectionObserver`: half the card in view,
266
+ or half the viewport filled by a card taller than that, while the tab is visible. A locked card
267
+ shows "You can answer in N s" under its disabled buttons. The answer document gains `seconds`:
268
+ how long the card had been on screen at its first save, kept on every later save. The practice
269
+ cards are not held.
270
+
271
+ `key.json` carries `answerers`, each item's own subset (`ids.<id>.answerers`, only where it has one)
272
+ and `min_seconds`. With `answerers`, `export` refuses a document id without `<answerer>~`, an
273
+ answerer the key does not list, and an answer to an item not shown to its answerer, as it refuses
274
+ an unknown item. The labels file keeps each answerer's answers apart:
275
+
276
+ ```text
277
+ { "answerers": ["author", "second"],
278
+ "items": [{ "id": "G001", "source_id": "case-1000", "group": "python",
279
+ "answerers": ["author", "second"],
280
+ "by_answerer": { "author": { "answers": { "label": "yes" }, "complete": true,
281
+ "seconds": 41, "…": "the fields of an item above" },
282
+ "second": { "…": "…" } } }],
283
+ "by_answerer": { "author": { "groups": {}, "complete_items": 1, "total_items": 1, "…": "…" },
284
+ "second": { "…": "…" } } }
285
+ ```
286
+
287
+ An answerer with no document for an item is still listed, with every answer `null`. Each
288
+ answerer's practice is scored into its own file, `<study>-practice-r<round>-<answerer>-<date>.json`.
289
+ `seconds` appears only when the page was built with `min_seconds`.
290
+
125
291
  ## The export
126
292
 
127
293
  `export` reads the store's three collections (`answers`, `groups`, `practice`) from `dump.json`, in