paperlint 14.3.0 → 14.5.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.
@@ -193,8 +193,45 @@ interface Config {
193
193
  readonly practice: readonly Practice[];
194
194
  readonly practice_round?: number;
195
195
  readonly labels?: Readonly<Record<string, string>>;
196
+ readonly lang?: string;
197
+ readonly translations?: Readonly<Record<string, Translation>>;
196
198
  readonly items: readonly Item[];
197
199
  }
200
+ interface QuestionWords {
201
+ readonly text: string;
202
+ readonly short?: string;
203
+ readonly options: Readonly<Record<string, {
204
+ readonly label: string;
205
+ readonly example: string;
206
+ readonly ask_why?: string;
207
+ }>>;
208
+ }
209
+ /** The page's own words in one more language (#394). */
210
+ interface Translation {
211
+ readonly title?: string;
212
+ readonly intro?: string;
213
+ readonly labels?: Readonly<Record<string, string>>;
214
+ readonly situation_question?: string;
215
+ readonly questions: Readonly<Record<string, QuestionWords>>;
216
+ }
217
+ /**
218
+ * Every language of the page with its words whole, the translations checked: each covers every
219
+ * question and answer of the page and names none it does not have, and gives an intro, a reason
220
+ * prompt and a situation question exactly where the page shows one. A label it leaves out reads as
221
+ * in the page's own language.
222
+ */
223
+ declare function translationsOf(cfg: Config): {
224
+ [x: string]: {
225
+ questions: Question[];
226
+ situation_question?: string;
227
+ title: string;
228
+ intro: string;
229
+ labels: {
230
+ [x: string]: string;
231
+ };
232
+ };
233
+ };
234
+ type Translated = ReturnType<typeof translationsOf>;
198
235
  interface KeyEntry {
199
236
  readonly source_id: string;
200
237
  readonly group: string;
@@ -212,7 +249,7 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
212
249
  idOf: (i: Item) => string;
213
250
  };
214
251
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
215
- declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
252
+ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described, translated: Translated): {
216
253
  questions: readonly Question[];
217
254
  practice: {
218
255
  round: number;
@@ -247,6 +284,71 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
247
284
  }[];
248
285
  id: string;
249
286
  })[];
287
+ lang?: string;
288
+ answerers?: readonly string[];
289
+ min_seconds?: number;
290
+ title: string;
291
+ intro: string;
292
+ labels: {
293
+ [x: string]: string;
294
+ };
295
+ groups: {
296
+ id: string;
297
+ label: string;
298
+ }[];
299
+ familiarity_question: string;
300
+ familiarity: {
301
+ value: string;
302
+ label: string;
303
+ }[];
304
+ allowed: JsonObject;
305
+ } | {
306
+ questions: readonly Question[];
307
+ practice: {
308
+ round: number;
309
+ items: ({
310
+ rules?: never;
311
+ id: string;
312
+ expected: Readonly<Record<string, string>>;
313
+ why: string;
314
+ } | {
315
+ rules: {
316
+ description?: string;
317
+ family?: string;
318
+ linter: "eslint" | "ruff";
319
+ id: string;
320
+ }[];
321
+ id: string;
322
+ expected: Readonly<Record<string, string>>;
323
+ why: string;
324
+ })[];
325
+ };
326
+ items: ({
327
+ answerers?: readonly string[];
328
+ rules?: never;
329
+ id: string;
330
+ } | {
331
+ answerers?: readonly string[];
332
+ rules: {
333
+ description?: string;
334
+ family?: string;
335
+ linter: "eslint" | "ruff";
336
+ id: string;
337
+ }[];
338
+ id: string;
339
+ })[];
340
+ lang: string;
341
+ translations: {
342
+ [x: string]: {
343
+ questions: Question[];
344
+ situation_question?: string;
345
+ title: string;
346
+ intro: string;
347
+ labels: {
348
+ [x: string]: string;
349
+ };
350
+ };
351
+ };
250
352
  answerers?: readonly string[];
251
353
  min_seconds?: number;
252
354
  title: string;
@@ -307,6 +409,71 @@ export declare function prepareBuild(configPath: string, outDir: string): {
307
409
  }[];
308
410
  id: string;
309
411
  })[];
412
+ lang?: string;
413
+ answerers?: readonly string[];
414
+ min_seconds?: number;
415
+ title: string;
416
+ intro: string;
417
+ labels: {
418
+ [x: string]: string;
419
+ };
420
+ groups: {
421
+ id: string;
422
+ label: string;
423
+ }[];
424
+ familiarity_question: string;
425
+ familiarity: {
426
+ value: string;
427
+ label: string;
428
+ }[];
429
+ allowed: JsonObject;
430
+ } | {
431
+ questions: readonly Question[];
432
+ practice: {
433
+ round: number;
434
+ items: ({
435
+ rules?: never;
436
+ id: string;
437
+ expected: Readonly<Record<string, string>>;
438
+ why: string;
439
+ } | {
440
+ rules: {
441
+ description?: string;
442
+ family?: string;
443
+ linter: "eslint" | "ruff";
444
+ id: string;
445
+ }[];
446
+ id: string;
447
+ expected: Readonly<Record<string, string>>;
448
+ why: string;
449
+ })[];
450
+ };
451
+ items: ({
452
+ answerers?: readonly string[];
453
+ rules?: never;
454
+ id: string;
455
+ } | {
456
+ answerers?: readonly string[];
457
+ rules: {
458
+ description?: string;
459
+ family?: string;
460
+ linter: "eslint" | "ruff";
461
+ id: string;
462
+ }[];
463
+ id: string;
464
+ })[];
465
+ lang: string;
466
+ translations: {
467
+ [x: string]: {
468
+ questions: Question[];
469
+ situation_question?: string;
470
+ title: string;
471
+ intro: string;
472
+ labels: {
473
+ [x: string]: string;
474
+ };
475
+ };
476
+ };
310
477
  answerers?: readonly string[];
311
478
  min_seconds?: number;
312
479
  title: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "14.3.0",
3
+ "version": "14.5.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:46ebfc043d459ff0 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
@@ -158,6 +158,16 @@ Two optional top-level keys of `items.json`, both left out by default:
158
158
  Details and the export's shape: [`references/closed-choice-generator.md`](references/closed-choice-generator.md),
159
159
  "Two answerers, and a minimum time on screen".
160
160
 
161
+ ## A second language
162
+
163
+ A labeller who reads another language better gets the page's own words in it: `"translations": {"ru":
164
+ {…}}` (and `"lang"`, the page's own, `en` by default) adds a switch under the title. The questions,
165
+ answers, examples, reason prompts, intro and labels change; the quote, the code and every note never
166
+ do, because those words are what is judged. The build refuses a translation that leaves out or
167
+ invents a question or an answer; the ids and `key.json` are the same with or without it.
168
+ [`references/closed-choice-generator.md`](references/closed-choice-generator.md), "A second
169
+ language for the page's own words".
170
+
161
171
  ## When the page misbehaves
162
172
 
163
173
  Look at what the store holds (`ArtifactData` `list`) before changing anything: the page may be fine
@@ -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
@@ -169,6 +169,16 @@ Two optional top-level keys of \`items.json\`, both left out by default:
169
169
  Details and the export's shape: [\`references/closed-choice-generator.md\`](references/closed-choice-generator.md),
170
170
  "Two answerers, and a minimum time on screen".
171
171
 
172
+ ## A second language
173
+
174
+ A labeller who reads another language better gets the page's own words in it: \`"translations": {"ru":
175
+ {…}}\` (and \`"lang"\`, the page's own, \`en\` by default) adds a switch under the title. The questions,
176
+ answers, examples, reason prompts, intro and labels change; the quote, the code and every note never
177
+ do, because those words are what is judged. The build refuses a translation that leaves out or
178
+ invents a question or an answer; the ids and \`key.json\` are the same with or without it.
179
+ [\`references/closed-choice-generator.md\`](references/closed-choice-generator.md), "A second
180
+ language for the page's own words".
181
+
172
182
  ## When the page misbehaves
173
183
 
174
184
  Look at what the store holds (\`ArtifactData\` \`list\`) before changing anything: the page may be fine
@@ -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.
@@ -225,6 +229,8 @@ Any card may carry these blocks, on any `kind`, and each one is optional. The pa
225
229
  | `hidden` | a `⋮` row for lines left out | `lines {from}–{to}` |
226
230
  | `hidden_one` | a `⋮` row for one line left out | `line {from}` |
227
231
  | `wait` | the bar under the answers while `min_seconds` runs | `⏳ You can answer in {s} s` |
232
+ | `how_to` | the heading of the first screen's questions and examples | `📖 How to answer` |
233
+ | `for_example` | text before each example on the first screen | `For example: ` |
228
234
 
229
235
  - The leak guard reads every string these blocks render, one string at a time.
230
236
 
@@ -282,6 +288,60 @@ author cannot read it is an exclusion made after the draw and goes unrecorded. T
282
288
  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
283
289
  `author_check` of the plan; a dropped unit is replaced by the next one in the draw.
284
290
 
291
+ ## A second language for the page's own words
292
+
293
+ For a labeller who reads another language better (#394). Both keys are optional and top-level; left
294
+ out, the page is exactly as above.
295
+
296
+ ```jsonc
297
+ {
298
+ "lang": "en", // the language of the page's own words; "en" when left out
299
+ "translations": {
300
+ "ru": {
301
+ "title": "…", // optional: the page's title when left out
302
+ "intro": "…", // given exactly when the page has an `intro`
303
+ "labels": {
304
+ "place": "Карточка {n} из {total}",
305
+ "how_to": "📖 Как отвечать",
306
+ "for_example": "Например: ",
307
+ },
308
+ "situation_question": "Эти слова запрещают строку {n}?", // given exactly when a card has a `situation`
309
+ "questions": {
310
+ "forbid": {
311
+ "text": "…",
312
+ "short": "…", // optional: the translated `text` stands in for it
313
+ "options": {
314
+ "forbid": { "label": "Запрещают", "example": "…" },
315
+ "cannot": { "label": "…", "example": "…", "ask_why": "…" },
316
+ },
317
+ },
318
+ },
319
+ },
320
+ },
321
+ }
322
+ ```
323
+
324
+ - **What changes with the language:** the title, the intro, the `labels`, every question's text and
325
+ short form, every answer's label and example, every reason prompt (`ask_why`), and the first question
326
+ as the cards with a `situation` ask it. The first screen's rows ("How to answer") change too.
327
+ - **What never does:** the quote and its source, the code, the annotation, the term notes, the
328
+ answer sub-lines (`answer_notes`), a situation's `summary`, and every other card field. Those words
329
+ are what the labeller judges; a translated quote would be a different item.
330
+ - **`situation_question`** replaces each card's own `situation.question` in that language. `{n}` is
331
+ the number of the card's first marked line; `{line}` works as in `situation`. Either one on a card
332
+ without an `excerpt` is refused by path.
333
+ - **Refused, by path:** a translation into the page's own `lang`; a question or an answer it leaves
334
+ out, or one the page does not have; two answers whose labels read the same; an `intro`, an
335
+ `ask_why` or a `situation_question` given where the page has none, or missing where it has one.
336
+ - **Left out on purpose:** a label the translation does not give reads as in the page's language.
337
+ The page's fixed words ("Practice", "Change", "Saved", "Next unanswered", "Who is answering?")
338
+ and the word «line» that `{line}` writes stay in English.
339
+ - **The switch** sits under the title (`data-test="lang-switch"`), one button per language with its
340
+ tag ("EN", "RU"). The choice is kept in the browser's localStorage under `label-lang` when it has
341
+ one.
342
+ - **Ids and key:** the neutral ids, the store's documents, `key.json` and the export are the same,
343
+ byte for byte, with or without translations.
344
+
285
345
  ## Two answerers, and a minimum time on screen
286
346
 
287
347
  Both keys are optional and top-level. Left out, the page, its store documents and the export are
@@ -0,0 +1,23 @@
1
+ {
2
+ "title": "Запрещают ли слова",
3
+ "labels": {
4
+ "place": "Карточка {n} из {total}",
5
+ "wait": "Сохранить · можно через {s} с на этой карточке",
6
+ "how_to": "📖 Как отвечать",
7
+ "for_example": "Например: "
8
+ },
9
+ "situation_question": "Эти слова запрещают строку {n}?",
10
+ "questions": {
11
+ "forbid": {
12
+ "text": "Эти слова запрещают отмеченную строку?",
13
+ "short": "запрещают ли слова?",
14
+ "options": {
15
+ "forbid": { "label": "Запрещают", "example": "«No print calls» и строка print(x)" },
16
+ "allow": { "label": "Разрешают", "example": "«No print calls» и строка log.info(x)" },
17
+ "unsettled": { "label": "Из слов это не следует", "example": "«Keep functions short» и функция на 30 строк" },
18
+ "elsewhere": { "label": "Слова про другое", "example": "«Use tabs» и строка с запросом к базе" },
19
+ "cannot": { "label": "Не берусь оценить (объясните)", "example": "строка на языке, который вы не читаете", "ask_why": "Что мешает?" }
20
+ }
21
+ }
22
+ }
23
+ }
@@ -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(