papilot 0.1.1 → 0.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.
Files changed (112) hide show
  1. package/README.md +16 -4
  2. package/README.zh-CN.md +15 -3
  3. package/bin/doctor.mjs +239 -0
  4. package/bin/papilot.mjs +95 -86
  5. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/assets/index-BkowXmKr.js +206 -0
  65. package/dist/assets/index-Dq3s-vhH.css +1 -0
  66. package/dist/assets/math-BTm2hvOz.js +263 -0
  67. package/dist/assets/math-DEcVZfaU.css +1 -0
  68. package/dist/assets/{pdf-view-DJo0X38w.js → pdf-view-BEn1EztO.js} +18 -18
  69. package/dist/index.html +11 -2
  70. package/examples/papilot-paper/preamble.tex +17 -11
  71. package/package.json +13 -2
  72. package/scripts/service.ts +8 -2
  73. package/skills/check-math.md +12 -0
  74. package/skills/consistency.md +11 -0
  75. package/skills/latex-warnings.md +12 -0
  76. package/skills/polish.md +13 -0
  77. package/skills/proofread.md +12 -0
  78. package/skills/reviewer-response.md +11 -0
  79. package/skills/shorten.md +12 -0
  80. package/src/commit.ts +23 -0
  81. package/src/conversation.ts +48 -0
  82. package/src/notes.ts +5 -0
  83. package/src/server/agents.ts +4 -3
  84. package/src/server/main.ts +51 -8
  85. package/src/server/manuscript.ts +91 -10
  86. package/src/server/review.ts +2 -1
  87. package/src/server/service-environment.ts +22 -0
  88. package/src/server/skills.ts +57 -0
  89. package/src/server/workspace.ts +186 -26
  90. package/src/shared.ts +17 -3
  91. package/vendor/latex-review/dist/web/assets/index-Bg-VqnPa.css +1 -0
  92. package/vendor/latex-review/dist/web/assets/index-xLeqYBJt.js +19 -0
  93. package/vendor/latex-review/dist/web/index.html +2 -2
  94. package/vendor/latex-review/src/client.ts +10 -3
  95. package/vendor/latex-review/src/core/build.ts +53 -7
  96. package/vendor/latex-review/src/core/outline.ts +6 -3
  97. package/vendor/latex-review/src/core/publish.ts +8 -6
  98. package/vendor/latex-review/src/runtime.ts +14 -0
  99. package/vendor/latex-review/src/server.ts +2 -1
  100. package/vendor/latex-review/src/types.ts +16 -1
  101. package/vendor/latex-review/src/web/ChangeMap.tsx +21 -9
  102. package/vendor/latex-review/src/web/PdfStrip.tsx +5 -1
  103. package/vendor/latex-review/src/web/i18n.ts +48 -0
  104. package/vendor/latex-review/src/web/model.ts +23 -4
  105. package/vendor/latex-review/src/web/overview.tsx +25 -20
  106. package/vendor/latex-review/src/web/review.tsx +399 -145
  107. package/vendor/latex-review/src/web/style.css +130 -0
  108. package/vendor/latex-review/src/web/zh-CN.ts +188 -0
  109. package/dist/assets/index-BhQFCMTx.js +0 -131
  110. package/dist/assets/index-CAV3-87U.css +0 -1
  111. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +0 -1
  112. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +0 -19
@@ -18,7 +18,9 @@ import type {
18
18
  import { PdfStrip } from "./PdfStrip.tsx";
19
19
  import { ChangeMap } from "./ChangeMap.tsx";
20
20
  import { STATUS } from "./overview.tsx";
21
+ import { t, tn, withLanguage } from "./i18n.ts";
21
22
  import {
23
+ STATE,
22
24
  buildStops,
23
25
  copyReference,
24
26
  describeLocation,
@@ -37,6 +39,11 @@ const KIND = {
37
39
  identical: "No visible change",
38
40
  unmapped: "Source only",
39
41
  };
42
+ const ACTION: Record<string, string> = {
43
+ accept: "Accept",
44
+ reject: "Reject",
45
+ comment: "Comment",
46
+ };
40
47
  const PHASE: Record<string, string> = {
41
48
  snapshot: "Taking a snapshot",
42
49
  build: "Compiling LaTeX",
@@ -64,9 +71,13 @@ function store(key: string, value: unknown) {
64
71
  function jobText(job: PageState["job"]) {
65
72
  return job?.status === "queued"
66
73
  ? job.ahead
67
- ? `Queued behind ${job.ahead} other rounds…`
68
- : "Queued…"
69
- : `${PHASE[job?.phase ?? ""] || "Starting"}…`;
74
+ ? tn(
75
+ job.ahead,
76
+ "Queued behind {n} other round…",
77
+ "Queued behind {n} other rounds…",
78
+ )
79
+ : t("Queued…")
80
+ : `${t(PHASE[job?.phase ?? ""] || "Starting")}…`;
70
81
  }
71
82
  export function ReviewApp({
72
83
  task,
@@ -85,7 +96,10 @@ export function ReviewApp({
85
96
  [openSource, setOpenSource] = useState<Set<number>>(new Set()),
86
97
  [composer, setComposer] = useState<Composer | null>(null),
87
98
  [draft, setDraft] = useState(""),
88
- [help, setHelp] = useState(false);
99
+ [help, setHelp] = useState(false),
100
+ [history, setHistory] = useState(
101
+ Boolean(pinned) || new URLSearchParams(location.search).has("history"),
102
+ );
89
103
  const paper = useRef<HTMLElement>(null),
90
104
  epoch = useRef(0),
91
105
  scrollUntil = useRef(0),
@@ -101,6 +115,7 @@ export function ReviewApp({
101
115
  stops.map((stop) =>
102
116
  describeLocation(
103
117
  stop.units.map((id) => units.get(id)!).filter(Boolean),
118
+ t,
104
119
  ),
105
120
  ),
106
121
  [stops, units],
@@ -113,12 +128,34 @@ export function ReviewApp({
113
128
  latest = Boolean(
114
129
  detail && state && detail.round === state.latest && !state.closed,
115
130
  );
116
- const actionLock = (kind: ActionKind) =>
117
- kind === "comment" ? state?.commentLock : state?.decisionLock;
131
+ const navigation = stops
132
+ .map((stop, index) => ({ stop, index }))
133
+ .filter(({ stop }) => history || pendingUnits(stop, decisions).length)
134
+ .map(({ index }) => index);
135
+ const adjacent = (index: number, direction: number) =>
136
+ navigation[navigation.indexOf(index) + direction] ?? -1;
137
+ const undoChanges = state?.undo
138
+ ? stops.flatMap((stop, index) =>
139
+ stop.units.some((id) => state.undo!.changes.includes(id))
140
+ ? [index + 1]
141
+ : [],
142
+ )
143
+ : [];
144
+ useEffect(() => {
145
+ if (history || composer) return;
146
+ if (!navigation.includes(current)) setCurrent(navigation[0] ?? -1);
147
+ }, [decisions, history, composer, detail]);
148
+ // Lock reasons come from the host in English; known ones are translated.
149
+ const actionLock = (kind: ActionKind) => {
150
+ const reason =
151
+ kind === "comment" ? state?.commentLock : state?.decisionLock;
152
+ return reason ? t(reason) : reason;
153
+ };
118
154
  function blocked(kind: ActionKind): boolean {
119
155
  const current = snapshot.current.state;
120
- const reason = kind === "comment" ? current?.commentLock : current?.decisionLock;
121
- if (reason) setToast(reason);
156
+ const reason =
157
+ kind === "comment" ? current?.commentLock : current?.decisionLock;
158
+ if (reason) setToast(t(reason));
122
159
  return Boolean(reason);
123
160
  }
124
161
  async function api<T>(route: string, body?: unknown): Promise<T> {
@@ -207,12 +244,19 @@ export function ReviewApp({
207
244
  !pendingUnits(nextStops[index], loadedState.decisions).length
208
245
  )
209
246
  index = nextPending(nextStops, loadedState.decisions, index);
210
- if (index < 0) index = nextStops.length ? 0 : -1;
247
+ if (index < 0) index = pinned && nextStops.length ? 0 : -1;
248
+ if (
249
+ previous.detail &&
250
+ loaded.round !== previous.detail.round &&
251
+ !pinned
252
+ )
253
+ setHistory(false);
211
254
  setState(loadedState);
212
255
  setDetail(loaded);
213
256
  setCurrent(index);
214
257
  setOpenSource(new Set());
215
- if (previous.detail) setToast(`Round ${target} is ready`);
258
+ if (previous.detail)
259
+ setToast(t("Round {round} is ready", { round: target }));
216
260
  } else setState(next);
217
261
  } catch (e) {
218
262
  if (!cancelled) {
@@ -247,7 +291,14 @@ export function ReviewApp({
247
291
  return () => clearTimeout(timer);
248
292
  }, [toast]);
249
293
  function beginComposer(changes = pendingUnits(stops[current], decisions)) {
250
- if (!latest || !changes.length || busy || state?.closed || blocked("comment")) return;
294
+ if (
295
+ !latest ||
296
+ !changes.length ||
297
+ busy ||
298
+ state?.closed ||
299
+ blocked("comment")
300
+ )
301
+ return;
251
302
  const key = `latex-review:${task}:draft:${detail?.round}:${changes.join(",")}`;
252
303
  setDraft(readStorage(key, ""));
253
304
  setComposer({ changes, stop: current, key });
@@ -261,7 +312,14 @@ export function ReviewApp({
261
312
  changes = pendingUnits(stops[current], decisions),
262
313
  body = "",
263
314
  ) {
264
- if (!changes.length || snapshot.current.busy || !latest || !detail || blocked(kind)) return;
315
+ if (
316
+ !changes.length ||
317
+ snapshot.current.busy ||
318
+ !latest ||
319
+ !detail ||
320
+ blocked(kind)
321
+ )
322
+ return;
265
323
  epoch.current++;
266
324
  setBusy(true);
267
325
  snapshot.current.busy = true;
@@ -279,8 +337,10 @@ export function ReviewApp({
279
337
  ? current
280
338
  : nextPending(stops, next.decisions, current);
281
339
  if (target >= 0 && target !== current) select(target);
282
- if (target < 0)
283
- setToast("This round is reviewed. Your decisions are saved.");
340
+ if (target < 0) {
341
+ if (!history) setCurrent(-1);
342
+ setToast(t("All changes reviewed. Your decisions are saved."));
343
+ }
284
344
  } catch (e) {
285
345
  setToast((e as Error).message);
286
346
  } finally {
@@ -290,7 +350,8 @@ export function ReviewApp({
290
350
  }
291
351
  }
292
352
  async function undo() {
293
- if (!state?.undo || snapshot.current.busy || blocked("accept")) return;
353
+ if (!state?.undo || !latest || snapshot.current.busy || blocked("accept"))
354
+ return;
294
355
  epoch.current++;
295
356
  setBusy(true);
296
357
  snapshot.current.busy = true;
@@ -302,8 +363,11 @@ export function ReviewApp({
302
363
  const target = stops.findIndex((s) =>
303
364
  s.units.some((id) => next.restored.includes(id)),
304
365
  );
366
+ setHistory(false);
305
367
  if (target >= 0) select(target);
306
- setToast("Decision undone.");
368
+ setToast(
369
+ t("Last decision undone. The restored changes need review again."),
370
+ );
307
371
  } catch (e) {
308
372
  setToast((e as Error).message);
309
373
  } finally {
@@ -340,12 +404,14 @@ export function ReviewApp({
340
404
  );
341
405
  setToast(
342
406
  selected
343
- ? "Copied selected text with file, lines and PDF page."
344
- : "Copied the change location.",
407
+ ? t("Copied selected text with file, lines and PDF page.")
408
+ : t("Copied the change location."),
345
409
  );
346
410
  } catch (e) {
347
411
  setToast(
348
- "Clipboard unavailable. Select the PDF text and use your browser’s Copy command.",
412
+ t(
413
+ "Clipboard unavailable. Select the PDF text and use your browser’s Copy command.",
414
+ ),
349
415
  );
350
416
  }
351
417
  }
@@ -402,8 +468,10 @@ export function ReviewApp({
402
468
  if (key === "a") void decide("accept");
403
469
  else if (key === "r") void decide("reject");
404
470
  else if (key === "c") beginComposer();
405
- else if (key === "j" || key === "arrowright") select(current + 1);
406
- else if (key === "k" || key === "arrowleft") select(current - 1);
471
+ else if (key === "j" || key === "arrowright")
472
+ select(adjacent(current, 1));
473
+ else if (key === "k" || key === "arrowleft")
474
+ select(adjacent(current, -1));
407
475
  else if (key === "n") select(nextPending(stops, decisions, current));
408
476
  else if (key === "s") toggleSource(current);
409
477
  else if (key === "u") void undo();
@@ -411,7 +479,7 @@ export function ReviewApp({
411
479
  };
412
480
  window.addEventListener("keydown", listener);
413
481
  return () => window.removeEventListener("keydown", listener);
414
- }, [state, detail, current, busy, composer, draft, help]);
482
+ }, [state, detail, current, busy, composer, draft, help, history]);
415
483
  function onScroll() {
416
484
  if (
417
485
  !paper.current ||
@@ -421,16 +489,16 @@ export function ReviewApp({
421
489
  )
422
490
  return;
423
491
  const box = paper.current.getBoundingClientRect(),
424
- regions = [
425
- ...paper.current.querySelectorAll<HTMLElement>("[data-stop]"),
426
- ].map((article) => {
427
- const b = article.getBoundingClientRect();
428
- return {
429
- index: Number(article.dataset.stop),
430
- top: b.top,
431
- bottom: b.bottom,
432
- };
433
- });
492
+ regions = [...paper.current.querySelectorAll<HTMLElement>("[data-stop]")]
493
+ .filter((article) => navigation.includes(Number(article.dataset.stop)))
494
+ .map((article) => {
495
+ const b = article.getBoundingClientRect();
496
+ return {
497
+ index: Number(article.dataset.stop),
498
+ top: b.top,
499
+ bottom: b.bottom,
500
+ };
501
+ });
434
502
  const index = visibleStop(regions, current, box.top + 60, box.bottom - 100);
435
503
  if (index !== current) select(index, false);
436
504
  }
@@ -440,21 +508,21 @@ export function ReviewApp({
440
508
  remaining = nextPending(stops, decisions);
441
509
  return (
442
510
  <div className="dock">
443
- <nav className="actions" aria-label="Review the current change">
444
- {stops.length > 1 && (
511
+ <nav className="actions" aria-label={t("Review the current change")}>
512
+ {navigation.length > 1 && (
445
513
  <>
446
514
  <button
447
515
  className="nav"
448
- aria-label="Previous change"
516
+ aria-label={t("Previous change")}
449
517
  aria-keyshortcuts="k ArrowLeft"
450
- title="Previous change (K or ←)"
451
- disabled={index === 0}
452
- onClick={() => select(index - 1)}
518
+ title={t("Previous change (K or ←)")}
519
+ disabled={adjacent(index, -1) < 0}
520
+ onClick={() => select(adjacent(index, -1))}
453
521
  >
454
522
  ‹
455
523
  </button>
456
524
  <span className="position">
457
- {index + 1} / {stops.length}
525
+ {navigation.indexOf(index) + 1} / {navigation.length}
458
526
  </span>
459
527
  </>
460
528
  )}
@@ -465,14 +533,19 @@ export function ReviewApp({
465
533
  className={kind}
466
534
  key={kind}
467
535
  aria-keyshortcuts={kind[0]}
468
- title={actionLock(kind) || `${kind[0].toUpperCase() + kind.slice(1)} (${kind[0].toUpperCase()})`}
469
- aria-describedby={actionLock(kind) ? "review-lock" : undefined}
536
+ title={
537
+ actionLock(kind) ||
538
+ `${t(ACTION[kind])} (${kind[0].toUpperCase()})`
539
+ }
540
+ aria-describedby={
541
+ actionLock(kind) ? "review-lock" : undefined
542
+ }
470
543
  disabled={busy || !latest || Boolean(actionLock(kind))}
471
544
  onClick={() =>
472
545
  kind === "comment" ? beginComposer() : void decide(kind)
473
546
  }
474
547
  >
475
- {kind[0].toUpperCase() + kind.slice(1)}
548
+ {t(ACTION[kind])}
476
549
  {pending.length > 1 && kind !== "comment"
477
550
  ? ` (${pending.length})`
478
551
  : ""}
@@ -485,34 +558,25 @@ export function ReviewApp({
485
558
  aria-keyshortcuts="n"
486
559
  onClick={() => select(nextPending(stops, decisions, current))}
487
560
  >
488
- Next undecided
561
+ {t("Next undecided")}
489
562
  </button>
490
563
  ) : (
491
564
  <span className="complete">
492
- {latest ? "Review complete" : "Earlier round"}
565
+ {latest ? t("Review complete") : t("Earlier round")}
493
566
  </span>
494
567
  )}
495
- {stops.length > 1 && (
568
+ {navigation.length > 1 && (
496
569
  <button
497
570
  className="nav"
498
- aria-label="Next change"
571
+ aria-label={t("Next change")}
499
572
  aria-keyshortcuts="j ArrowRight"
500
- title="Next change (J or →)"
501
- disabled={index === stops.length - 1}
502
- onClick={() => select(index + 1)}
573
+ title={t("Next change (J or →)")}
574
+ disabled={adjacent(index, 1) < 0}
575
+ onClick={() => select(adjacent(index, 1))}
503
576
  >
504
577
  ›
505
578
  </button>
506
579
  )}
507
- <button
508
- className="undo"
509
- title={state.decisionLock || undefined}
510
- aria-describedby={state.decisionLock ? "review-lock" : undefined}
511
- disabled={!state.undo || busy || Boolean(state.decisionLock)}
512
- onClick={() => void undo()}
513
- >
514
- Undo
515
- </button>
516
580
  </nav>
517
581
  </div>
518
582
  );
@@ -523,8 +587,12 @@ export function ReviewApp({
523
587
  inline: boolean,
524
588
  ): ReactNode {
525
589
  const known = block.units.map((id) => units.get(id)!).filter(Boolean),
526
- resolved = resolvedSide(block.units, decisions),
527
590
  status = stopState(block.units, decisions),
591
+ reviewed = !pendingUnits(stops[index], decisions).length,
592
+ resolved = history
593
+ ? undefined
594
+ : resolvedSide(block.units, decisions) ||
595
+ (status === "commented" ? "after" : undefined),
528
596
  source =
529
597
  openSource.has(index) ||
530
598
  block.identical ||
@@ -534,24 +602,24 @@ export function ReviewApp({
534
602
  block.units.map((id) => state?.comments[id]).filter(Boolean),
535
603
  ),
536
604
  ],
537
- location = describeLocation(known),
605
+ location = describeLocation(known, t),
538
606
  kindLabel = block.reflow
539
- ? "Reflow"
607
+ ? t("Reflow")
540
608
  : [...new Set(known.map((u) => u.visual ?? "unmapped"))]
541
609
  .filter((kind) => kind !== "text")
542
- .map((kind) => KIND[kind])
610
+ .map((kind) => t(KIND[kind]))
543
611
  .join(", ");
544
612
  const images = (items: Strip[], side: "before" | "after") => (
545
613
  <div
546
614
  className={`side ${side}${block.identical ? " identical" : ""}`}
547
- aria-label={side === "before" ? "Before" : "After"}
615
+ aria-label={side === "before" ? t("Before") : t("After")}
548
616
  >
549
617
  {items.map((image) => (
550
618
  <PdfStrip
551
619
  key={image.src}
552
620
  image={image}
553
621
  base={detail!.files}
554
- label={side === "before" ? "Before" : "After"}
622
+ label={side === "before" ? t("Before") : t("After")}
555
623
  clean={Boolean(resolved)}
556
624
  />
557
625
  ))}
@@ -560,42 +628,64 @@ export function ReviewApp({
560
628
  return (
561
629
  <article
562
630
  key={index}
563
- className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}`}
631
+ className={`change ${inline ? "inline" : "standalone"}${current === index ? " current" : ""}${reviewed && !history ? " reviewed-change" : ""}${reviewed && !history && !resolved ? " mixed-reviewed" : ""}`}
564
632
  data-stop={index}
565
633
  data-state={status}
566
634
  data-resolved={resolved}
567
635
  tabIndex={-1}
568
636
  onClick={() => {
569
- if (index !== current && !composer) select(index, false);
637
+ if (index !== current && !composer && (history || !reviewed))
638
+ select(index, false);
570
639
  }}
571
640
  >
641
+ {reviewed && !history && !resolved && (
642
+ <p className="reviewed-placeholder">
643
+ <button
644
+ className="link"
645
+ onClick={() => {
646
+ setHistory(true);
647
+ select(index, false);
648
+ }}
649
+ >
650
+ {t("Reviewed changes · View in History")}
651
+ </button>
652
+ </p>
653
+ )}
572
654
  <header className="change-head">
573
- <span className="change-number">Change {index + 1}</span>
574
- <span className="change-where" title={location}>
655
+ {/* The English number and location are kept for tools that quote
656
+ them to an agent, whatever the interface language. */}
657
+ <span className="change-number" data-change={index + 1}>
658
+ {t("Change {n}", { n: index + 1 })}
659
+ </span>
660
+ <span
661
+ className="change-where"
662
+ title={location}
663
+ data-location={describeLocation(known)}
664
+ >
575
665
  {location}
576
666
  </span>
577
667
  {actionBar(index)}
578
668
  {kindLabel && <span className="change-kind">{kindLabel}</span>}
579
669
  {status !== "pending" && (
580
- <span className="badge">
581
- {status[0].toUpperCase() + status.slice(1)}
582
- </span>
670
+ <span className="badge">{t(STATE[status] ?? status)}</span>
583
671
  )}
584
672
  <button
585
673
  className="link copy-reference"
586
- title="Copy selected text with this change’s file, line numbers and PDF page"
674
+ title={t(
675
+ "Copy selected text with this change’s file, line numbers and PDF page",
676
+ )}
587
677
  onPointerDown={(e) => e.preventDefault()}
588
678
  onClick={() => void copy(index)}
589
679
  >
590
- Copy reference
680
+ {t("Copy reference")}
591
681
  </button>
592
682
  <button
593
683
  className={`link ${source ? "active" : ""}`}
594
- title="Show the LaTeX source (S)"
684
+ title={t("Show the LaTeX source (S)")}
595
685
  onClick={() => toggleSource(index)}
596
686
  >
597
687
  {block.units.length > 1
598
- ? `${block.units.length} edits · LaTeX`
688
+ ? t("{n} edits · LaTeX", { n: block.units.length })
599
689
  : "LaTeX"}
600
690
  </button>
601
691
  </header>
@@ -606,7 +696,9 @@ export function ReviewApp({
606
696
  .map((h, i) => (
607
697
  <p key={i}>
608
698
  <strong>
609
- Revised after your comment in round {h.round}:{" "}
699
+ {t("Revised after your comment in round {round}: ", {
700
+ round: h.round,
701
+ })}
610
702
  </strong>
611
703
  “{h.comment}”
612
704
  </p>
@@ -620,17 +712,19 @@ export function ReviewApp({
620
712
  {block.after.length > 0 && images(block.after, "after")}
621
713
  {block.identical && (
622
714
  <p className="caption">
623
- No visible change in the PDF; see the LaTeX source.
715
+ {t("No visible change in the PDF; see the LaTeX source.")}
624
716
  </p>
625
717
  )}
626
718
  {block.reflow && (
627
- <p className="caption">Continued: only line breaks moved here.</p>
719
+ <p className="caption">
720
+ {t("Continued: only line breaks moved here.")}
721
+ </p>
628
722
  )}
629
723
  {remarks.length > 0 && (
630
724
  <div className="remark">
631
725
  {remarks.map((r, i) => (
632
726
  <p key={i}>
633
- <strong>Your comment: </strong>
727
+ <strong>{t("Your comment: ")}</strong>
634
728
  {r}
635
729
  </p>
636
730
  ))}
@@ -646,13 +740,17 @@ export function ReviewApp({
646
740
  >
647
741
  <div className="source-title">
648
742
  <span>
649
- {unit.file} · lines {unit.new_start}–{unit.new_end}
743
+ {unit.file} ·{" "}
744
+ {t("lines {start}–{end}", {
745
+ start: unit.new_start,
746
+ end: unit.new_end,
747
+ })}
650
748
  </span>
651
749
  {known.length > 1 && (
652
750
  <span className="unit-actions">
653
751
  <span className="unit-state">
654
752
  {decisions[unit.id] !== "pending"
655
- ? decisions[unit.id]
753
+ ? t(STATE[decisions[unit.id]]).toLowerCase()
656
754
  : ""}
657
755
  </span>
658
756
  {decisions[unit.id] === "pending" &&
@@ -662,15 +760,19 @@ export function ReviewApp({
662
760
  className={kind}
663
761
  key={kind}
664
762
  title={actionLock(kind) || undefined}
665
- aria-describedby={actionLock(kind) ? "review-lock" : undefined}
666
- disabled={busy || !latest || Boolean(actionLock(kind))}
763
+ aria-describedby={
764
+ actionLock(kind) ? "review-lock" : undefined
765
+ }
766
+ disabled={
767
+ busy || !latest || Boolean(actionLock(kind))
768
+ }
667
769
  onClick={() =>
668
770
  kind === "comment"
669
771
  ? beginComposer([unit.id])
670
772
  : void decide(kind, [unit.id])
671
773
  }
672
774
  >
673
- {kind[0].toUpperCase() + kind.slice(1)}
775
+ {t(ACTION[kind])}
674
776
  </button>
675
777
  ),
676
778
  )}
@@ -678,7 +780,7 @@ export function ReviewApp({
678
780
  )}
679
781
  </div>
680
782
  {unit.kind === "binary" ? (
681
- <p>Binary file changed.</p>
783
+ <p>{t("Binary file changed.")}</p>
682
784
  ) : (
683
785
  <pre className="source-code">
684
786
  {wordDiff(unit.old_text, unit.new_text).map((part, i) =>
@@ -693,7 +795,9 @@ export function ReviewApp({
693
795
  </pre>
694
796
  )}
695
797
  {unit.truncated && (
696
- <p className="muted">Long change: the source is shortened.</p>
798
+ <p className="muted">
799
+ {t("Long change: the source is shortened.")}
800
+ </p>
697
801
  )}
698
802
  </div>
699
803
  ))}
@@ -706,21 +810,25 @@ export function ReviewApp({
706
810
  return (
707
811
  <>
708
812
  <header className="bar">
709
- <a className="ghost overview-link" href="/">
710
- ← All reviews
813
+ <a className="ghost overview-link" href={withLanguage("/")}>
814
+ ← {t("All reviews")}
711
815
  </a>
712
816
  <div className="identity">
713
817
  <h1>{state?.title ?? "LaTeX Review"}</h1>
714
818
  <span className="muted">
715
819
  {detail
716
- ? `Round ${detail.round}${detail.round !== state?.latest ? " (earlier round)" : ""}`
820
+ ? t("Round {round}", { round: detail.round }) +
821
+ (detail.round !== state?.latest ? t(" (earlier round)") : "")
717
822
  : ""}
718
823
  </span>
719
824
  </div>
720
825
  {count.total > 0 && (
721
826
  <div className="meter">
722
827
  <span>
723
- {count.decided} of {count.total} decided
828
+ {t("{decided} of {total} decided", {
829
+ decided: count.decided,
830
+ total: count.total,
831
+ })}
724
832
  </span>
725
833
  </div>
726
834
  )}
@@ -731,64 +839,177 @@ export function ReviewApp({
731
839
  {count.pending > 1 && (
732
840
  <button
733
841
  className="ghost accept accept-all"
734
- disabled={busy || !latest || Boolean(composer) || Boolean(state?.decisionLock)}
735
- title={state?.decisionLock || `Accept the ${count.pending} undecided ${count.pending === 1 ? "change" : "changes"} in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.`}
842
+ disabled={
843
+ busy ||
844
+ !latest ||
845
+ Boolean(composer) ||
846
+ Boolean(state?.decisionLock)
847
+ }
848
+ title={
849
+ state?.decisionLock ||
850
+ tn(
851
+ count.pending,
852
+ "Accept the {n} undecided change in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
853
+ "Accept the {n} undecided changes in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
854
+ )
855
+ }
856
+ aria-label={t("Accept all ({n})", { n: count.pending })}
736
857
  aria-describedby={state?.decisionLock ? "review-lock" : undefined}
737
858
  onClick={() => void decide("accept", undecided)}
738
859
  >
739
- Accept all ({count.pending})
860
+ <span className="batch-label">{t("Accept all")}</span>
861
+ <span className="batch-count">{count.pending}</span>
740
862
  </button>
741
863
  )}
742
864
  {count.pending > 1 && (
743
865
  <button
744
866
  className="ghost reject reject-all"
745
- disabled={busy || !latest || Boolean(composer) || Boolean(state?.decisionLock)}
746
- title={state?.decisionLock || `Reject the ${count.pending} undecided ${count.pending === 1 ? "change" : "changes"} in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.`}
867
+ disabled={
868
+ busy ||
869
+ !latest ||
870
+ Boolean(composer) ||
871
+ Boolean(state?.decisionLock)
872
+ }
873
+ title={
874
+ state?.decisionLock ||
875
+ tn(
876
+ count.pending,
877
+ "Reject the {n} undecided change in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
878
+ "Reject the {n} undecided changes in this round. Decided and commented changes stay as they are. Undo reverses the whole batch.",
879
+ )
880
+ }
881
+ aria-label={t("Reject all ({n})", { n: count.pending })}
747
882
  aria-describedby={state?.decisionLock ? "review-lock" : undefined}
748
883
  onClick={() => void decide("reject", undecided)}
749
884
  >
750
- Reject all ({count.pending})
885
+ <span className="batch-label">{t("Reject all")}</span>
886
+ <span className="batch-count">{count.pending}</span>
751
887
  </button>
752
888
  )}
753
- {(!online || state?.closed || state?.state !== "reviewing") && (
889
+ {/* "All decided" would repeat the meter's "N of N decided". */}
890
+ {(!online ||
891
+ state?.closed ||
892
+ !["reviewing", "done"].includes(state?.state ?? "")) && (
754
893
  <span
755
894
  className="status"
756
895
  role="status"
757
896
  data-state={!online ? "offline" : state?.state}
758
897
  >
759
898
  {!online
760
- ? "Reconnecting…"
899
+ ? t("Reconnecting…")
761
900
  : state?.closed
762
- ? "Finished"
901
+ ? t("Finished")
763
902
  : state?.state === "building"
764
903
  ? jobText(state.job)
765
- : STATUS[state?.state ?? ""]}
904
+ : t(STATUS[state?.state ?? ""] ?? "")}
766
905
  </span>
767
906
  )}
907
+ {/* History is a toolbar action; its own row appears only while
908
+ browsing earlier decisions. */}
909
+ {detail && !history && (
910
+ <button
911
+ className="ghost history"
912
+ title={t("Earlier decisions and rounds")}
913
+ onClick={() => {
914
+ setHistory(true);
915
+ if (current < 0 && stops.length) setCurrent(0);
916
+ }}
917
+ >
918
+ {t("History")}
919
+ </button>
920
+ )}
768
921
  <button
769
922
  className="ghost round"
770
- aria-label="Keyboard shortcuts"
923
+ aria-label={t("Keyboard shortcuts")}
771
924
  onClick={() => setHelp(true)}
772
925
  >
773
926
  ?
774
927
  </button>
775
928
  </div>
776
929
  </header>
930
+ {detail && history && (
931
+ <nav className="review-mode" aria-label={t("Review view")}>
932
+ <label>
933
+ {t("History")}{" "}
934
+ <select
935
+ aria-label={t("Review round")}
936
+ value={detail.round}
937
+ onChange={(e) => {
938
+ location.href = withLanguage(
939
+ `?task=${encodeURIComponent(task)}&round=${e.target.value}&history=1`,
940
+ );
941
+ }}
942
+ >
943
+ {Array.from(
944
+ { length: state?.latest || detail.round },
945
+ (_, i) => i + 1,
946
+ )
947
+ .reverse()
948
+ .map((round) => (
949
+ <option key={round} value={round}>
950
+ {t("Round {round}", { round })}
951
+ {round === state?.latest ? t(" (latest)") : ""}
952
+ </option>
953
+ ))}
954
+ </select>
955
+ </label>
956
+ {state?.undo && (
957
+ <button
958
+ className="ghost undo"
959
+ disabled={!latest || busy || Boolean(state.decisionLock)}
960
+ title={
961
+ state.decisionLock ||
962
+ t("Undo {action} · {changes}", {
963
+ action: t(
964
+ ACTION[state.undo.kind] ?? state.undo.kind,
965
+ ).toLowerCase(),
966
+ changes: tn(
967
+ undoChanges.length,
968
+ "Change {list}",
969
+ "Changes {list}",
970
+ { list: undoChanges.join(", ") },
971
+ ),
972
+ })
973
+ }
974
+ onClick={() => void undo()}
975
+ >
976
+ {t("Undo last decision")}
977
+ </button>
978
+ )}
979
+ <button
980
+ className="ghost"
981
+ onClick={() => {
982
+ if (pinned)
983
+ location.href = withLanguage(
984
+ `?task=${encodeURIComponent(task)}`,
985
+ );
986
+ else {
987
+ setHistory(false);
988
+ setCurrent(nextPending(stops, decisions));
989
+ }
990
+ }}
991
+ >
992
+ {t("Back to review")}
993
+ </button>
994
+ </nav>
995
+ )}
777
996
  <div className="review-body">
778
997
  <main id="paper" ref={paper} tabIndex={-1} onScroll={onScroll}>
779
998
  {(state?.commentLock || state?.decisionLock) && (
780
999
  <div className="banner" id="review-lock" role="status">
781
- {state.commentLock || state.decisionLock}
1000
+ {t((state.commentLock || state.decisionLock)!)}
782
1001
  </div>
783
1002
  )}
784
1003
  {state?.closed && !state.commentLock && (
785
1004
  <div className="banner">
786
- This review is finished. Accepted changes are in your manuscript.
1005
+ {t(
1006
+ "This review is finished. Accepted changes are in your manuscript.",
1007
+ )}
787
1008
  </div>
788
1009
  )}
789
1010
  {state?.state === "build_failed" && (
790
1011
  <div className="banner">
791
- <strong>The latest publish failed: </strong>
1012
+ <strong>{t("The latest publish failed: ")}</strong>
792
1013
  {state.job?.error}
793
1014
  {state.job?.details && (
794
1015
  <pre className="details">{state.job.details.join("\n")}</pre>
@@ -797,42 +1018,72 @@ export function ReviewApp({
797
1018
  )}
798
1019
  {state?.state === "building" && detail && !state.decisionLock && (
799
1020
  <div className="banner">
800
- The agent is publishing a new round. You can keep reviewing; the
801
- new round will include your decisions.
1021
+ {t(
1022
+ "The agent is publishing a new round. You can keep reviewing; the new round will include your decisions.",
1023
+ )}
802
1024
  </div>
803
1025
  )}
804
1026
  {detail && state?.latest && detail.round !== state.latest && (
805
1027
  <div className="banner">
806
- You are viewing round {detail.round}.{" "}
807
- <a href={`?task=${encodeURIComponent(task)}`}>
808
- Go to round {state.latest} →
1028
+ {t("You are viewing round {round}.", { round: detail.round })}{" "}
1029
+ <a href={withLanguage(`?task=${encodeURIComponent(task)}`)}>
1030
+ {t("Go to round {round} →", { round: state.latest })}
809
1031
  </a>
810
1032
  </div>
811
1033
  )}
812
1034
  {detail?.warnings.length ? (
813
1035
  <div className="banner">
814
1036
  <details>
815
- <summary>{detail.warnings.length} new LaTeX warnings</summary>
1037
+ <summary>
1038
+ {tn(
1039
+ detail.warnings.length,
1040
+ "{n} new LaTeX warning",
1041
+ "{n} new LaTeX warnings",
1042
+ )}
1043
+ </summary>
816
1044
  <pre className="details">{detail.warnings.join("\n")}</pre>
817
1045
  </details>
818
1046
  </div>
819
1047
  ) : null}
820
- {detail &&
821
- count.total > 0 &&
822
- !count.pending &&
823
- state?.state !== "building" && (
824
- <div className="banner">
825
- Round {detail.round} is reviewed:{" "}
826
- {(["accepted", "rejected", "commented"] as const)
827
- .filter((k) => count[k])
828
- .map((k) => `${count[k]} ${k}`)
829
- .join(", ")}
830
- .{" "}
831
- {count.commented
832
- ? "The agent will revise your comments."
833
- : "The review remains available for inspection and undo."}
834
- </div>
835
- )}
1048
+ {detail && !history && !count.pending && (
1049
+ <div className="review-complete" role="status">
1050
+ <strong>
1051
+ {t(
1052
+ state?.workspace?.status === "waiting_input"
1053
+ ? "Agent needs your input"
1054
+ : state?.workspace?.status === "queued"
1055
+ ? "Feedback queued"
1056
+ : state?.workspace?.status === "running"
1057
+ ? "Feedback sent · Agent is working"
1058
+ : state?.workspace?.status === "building" ||
1059
+ state?.state === "building"
1060
+ ? "Preparing the next review…"
1061
+ : "All changes reviewed",
1062
+ )}
1063
+ </strong>
1064
+ <span>
1065
+ {t(
1066
+ state?.workspace?.status === "waiting_input"
1067
+ ? "Open Chat to answer the agent's question."
1068
+ : ["running", "queued", "building"].includes(
1069
+ state?.workspace?.status || "",
1070
+ ) || state?.state === "building"
1071
+ ? "New changes will appear here when ready."
1072
+ : state?.workspace?.comments
1073
+ ? "Your comments are saved. Send feedback so the agent can revise."
1074
+ : state?.workspace?.accepted
1075
+ ? "Accepted changes are in your manuscript."
1076
+ : state?.workspace?.unsent && !count.accepted
1077
+ ? "Your decisions are saved. Send them to the agent to continue."
1078
+ : count.commented && !state?.workspace
1079
+ ? "Your comments are saved for the agent."
1080
+ : "There are no new changes to review. Previous decisions are available in History.",
1081
+ )}
1082
+ </span>
1083
+ {/* A host such as Papilot puts its next step here, for example a commit. */}
1084
+ <div className="review-complete-actions" />
1085
+ </div>
1086
+ )}
836
1087
  <div className="document">
837
1088
  {detail ? (
838
1089
  <>
@@ -849,14 +1100,14 @@ export function ReviewApp({
849
1100
  <PdfStrip
850
1101
  image={block.image}
851
1102
  base={detail.files}
852
- label="Unchanged text"
1103
+ label={t("Unchanged text")}
853
1104
  />
854
1105
  </div>
855
1106
  ) : block.kind === "gap" ? (
856
1107
  <div
857
1108
  className="fold"
858
1109
  key={`gap-${b}`}
859
- title="Unchanged text is folded"
1110
+ title={t("Unchanged text is folded")}
860
1111
  >
861
1112
  ⋯
862
1113
  </div>
@@ -870,13 +1121,15 @@ export function ReviewApp({
870
1121
  {detail.loose.length > 0 && (
871
1122
  <section className="part">
872
1123
  <h2 className="part-title">
873
- {detail.sections.length ? "Other changes" : "Changes"}
1124
+ {detail.sections.length
1125
+ ? t("Other changes")
1126
+ : t("Changes")}
874
1127
  </h2>
875
1128
  {detail.loose.map((block) => change(block, index++, false))}
876
1129
  </section>
877
1130
  )}
878
1131
  {!stops.length && (
879
- <p className="empty">This round has no changes.</p>
1132
+ <p className="empty">{t("This round has no changes.")}</p>
880
1133
  )}
881
1134
  </>
882
1135
  ) : error ? (
@@ -889,9 +1142,9 @@ export function ReviewApp({
889
1142
  <p>
890
1143
  {state?.state === "building"
891
1144
  ? jobText(state.job)
892
- : "Waiting for the agent to publish its changes."}
1145
+ : t("Waiting for the agent to publish its changes.")}
893
1146
  </p>
894
- <p className="muted">This page updates by itself.</p>
1147
+ <p className="muted">{t("This page updates by itself.")}</p>
895
1148
  </div>
896
1149
  )}
897
1150
  </div>
@@ -899,6 +1152,7 @@ export function ReviewApp({
899
1152
  <ChangeMap
900
1153
  scrollRoot={paper}
901
1154
  stops={stops}
1155
+ showReviewed={history}
902
1156
  locations={locations}
903
1157
  decisions={decisions}
904
1158
  current={current}
@@ -915,7 +1169,7 @@ export function ReviewApp({
915
1169
  }}
916
1170
  >
917
1171
  <label htmlFor="composer-text">
918
- Comment on change {composer.stop + 1}
1172
+ {t("Comment on change {n}", { n: composer.stop + 1 })}
919
1173
  </label>
920
1174
  <textarea
921
1175
  id="composer-text"
@@ -927,20 +1181,20 @@ export function ReviewApp({
927
1181
  setDraft(e.target.value);
928
1182
  store(composer.key, e.target.value);
929
1183
  }}
930
- placeholder="What should the agent change here?"
1184
+ placeholder={t("What should the agent change here?")}
931
1185
  />
932
1186
  <div className="composer-row">
933
- <span className="muted">⌘↵ send · Esc cancel</span>
1187
+ <span className="muted">{t("⌘↵ send · Esc cancel")}</span>
934
1188
  <button type="button" onClick={() => closeComposer()}>
935
- Cancel
1189
+ {t("Cancel")}
936
1190
  </button>
937
1191
  <button
938
1192
  type="submit"
939
- title={state?.commentLock || undefined}
1193
+ title={state?.commentLock ? t(state.commentLock) : undefined}
940
1194
  aria-describedby={state?.commentLock ? "review-lock" : undefined}
941
1195
  disabled={busy || !draft.trim() || Boolean(state?.commentLock)}
942
1196
  >
943
- Send
1197
+ {t("Send")}
944
1198
  </button>
945
1199
  </div>
946
1200
  </form>
@@ -957,7 +1211,7 @@ export function ReviewApp({
957
1211
  aria-labelledby="help-title"
958
1212
  onClick={(e) => e.stopPropagation()}
959
1213
  >
960
- <h2 id="help-title">Keyboard shortcuts</h2>
1214
+ <h2 id="help-title">{t("Keyboard shortcuts")}</h2>
961
1215
  <dl>
962
1216
  {[
963
1217
  ["A", "Accept"],
@@ -971,18 +1225,18 @@ export function ReviewApp({
971
1225
  ].map(([key, label]) => (
972
1226
  <Fragment key={key}>
973
1227
  <dt>
974
- <kbd>{key}</kbd>
1228
+ <kbd>{t(key)}</kbd>
975
1229
  </dt>
976
- <dd>{label}</dd>
1230
+ <dd>{t(label)}</dd>
977
1231
  </Fragment>
978
1232
  ))}
979
1233
  </dl>
980
1234
  <p className="muted">
981
- Select text directly in the PDF preview. Copy reference includes
982
- the selection and its file, line numbers and page. Review
983
- shortcuts pause while text is selected.
1235
+ {t(
1236
+ "Select text directly in the PDF preview. Copy reference includes the selection and its file, line numbers and page. Review shortcuts pause while text is selected.",
1237
+ )}
984
1238
  </p>
985
- <button onClick={() => setHelp(false)}>Close</button>
1239
+ <button onClick={() => setHelp(false)}>{t("Close")}</button>
986
1240
  </dialog>
987
1241
  </div>
988
1242
  )}