@joewinke/jatui 0.37.0 → 0.38.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 (266) hide show
  1. package/dist/artifact-blocks/AttachmentChip.svelte +66 -0
  2. package/dist/artifact-blocks/AttachmentChip.svelte.d.ts +7 -0
  3. package/dist/artifact-blocks/BlockComments.svelte +309 -0
  4. package/dist/artifact-blocks/BlockComments.svelte.d.ts +33 -0
  5. package/dist/artifact-blocks/BlockRenderer.svelte +674 -0
  6. package/dist/artifact-blocks/BlockRenderer.svelte.d.ts +55 -0
  7. package/dist/artifact-blocks/BlockVerdict.svelte +151 -0
  8. package/dist/artifact-blocks/BlockVerdict.svelte.d.ts +13 -0
  9. package/dist/artifact-blocks/BlocksArtifactView.svelte +1616 -0
  10. package/dist/artifact-blocks/BlocksArtifactView.svelte.d.ts +113 -0
  11. package/dist/artifact-blocks/CONTRACT.md +225 -0
  12. package/dist/artifact-blocks/CommentPill.svelte +49 -0
  13. package/dist/artifact-blocks/CommentPill.svelte.d.ts +16 -0
  14. package/dist/artifact-blocks/FoldedRounds.svelte +95 -0
  15. package/dist/artifact-blocks/FoldedRounds.svelte.d.ts +9 -0
  16. package/dist/artifact-blocks/Glyph.svelte +54 -0
  17. package/dist/artifact-blocks/Glyph.svelte.d.ts +10 -0
  18. package/dist/artifact-blocks/HistoryScrubber.svelte +500 -0
  19. package/dist/artifact-blocks/HistoryScrubber.svelte.d.ts +15 -0
  20. package/dist/artifact-blocks/Markdown.svelte +186 -0
  21. package/dist/artifact-blocks/Markdown.svelte.d.ts +30 -0
  22. package/dist/artifact-blocks/NoteComposer.svelte +31 -0
  23. package/dist/artifact-blocks/NoteComposer.svelte.d.ts +8 -0
  24. package/dist/artifact-blocks/PlainComposer.svelte +200 -0
  25. package/dist/artifact-blocks/PlainComposer.svelte.d.ts +15 -0
  26. package/dist/artifact-blocks/SectionTools.svelte +102 -0
  27. package/dist/artifact-blocks/SectionTools.svelte.d.ts +19 -0
  28. package/dist/artifact-blocks/adapter.d.ts +13 -0
  29. package/dist/artifact-blocks/adapter.js +31 -0
  30. package/dist/artifact-blocks/autoSend.d.ts +49 -0
  31. package/dist/artifact-blocks/autoSend.js +103 -0
  32. package/dist/artifact-blocks/blocks/actions/ActionsBlock.svelte +557 -0
  33. package/dist/artifact-blocks/blocks/actions/ActionsBlock.svelte.d.ts +48 -0
  34. package/dist/artifact-blocks/blocks/actions/block.d.ts +8 -0
  35. package/dist/artifact-blocks/blocks/actions/block.js +74 -0
  36. package/dist/artifact-blocks/blocks/actions/requests.d.ts +62 -0
  37. package/dist/artifact-blocks/blocks/actions/requests.js +26 -0
  38. package/dist/artifact-blocks/blocks/actions/schema.d.ts +168 -0
  39. package/dist/artifact-blocks/blocks/actions/schema.js +253 -0
  40. package/dist/artifact-blocks/blocks/callout/CalloutBlock.svelte +779 -0
  41. package/dist/artifact-blocks/blocks/callout/CalloutBlock.svelte.d.ts +40 -0
  42. package/dist/artifact-blocks/blocks/callout/block.d.ts +7 -0
  43. package/dist/artifact-blocks/blocks/callout/block.js +39 -0
  44. package/dist/artifact-blocks/blocks/callout/schema.d.ts +72 -0
  45. package/dist/artifact-blocks/blocks/callout/schema.js +115 -0
  46. package/dist/artifact-blocks/blocks/chart/ChartBlock.svelte +875 -0
  47. package/dist/artifact-blocks/blocks/chart/ChartBlock.svelte.d.ts +59 -0
  48. package/dist/artifact-blocks/blocks/chart/block.d.ts +9 -0
  49. package/dist/artifact-blocks/blocks/chart/block.js +70 -0
  50. package/dist/artifact-blocks/blocks/chart/schema.d.ts +171 -0
  51. package/dist/artifact-blocks/blocks/chart/schema.js +498 -0
  52. package/dist/artifact-blocks/blocks/chart/thumb.d.ts +2 -0
  53. package/dist/artifact-blocks/blocks/chart/thumb.js +201 -0
  54. package/dist/artifact-blocks/blocks/checklist/ChecklistBlock.svelte +1048 -0
  55. package/dist/artifact-blocks/blocks/checklist/ChecklistBlock.svelte.d.ts +16 -0
  56. package/dist/artifact-blocks/blocks/checklist/Icon.svelte +105 -0
  57. package/dist/artifact-blocks/blocks/checklist/Icon.svelte.d.ts +17 -0
  58. package/dist/artifact-blocks/blocks/checklist/block.d.ts +10 -0
  59. package/dist/artifact-blocks/blocks/checklist/block.js +63 -0
  60. package/dist/artifact-blocks/blocks/checklist/itemReview.d.ts +16 -0
  61. package/dist/artifact-blocks/blocks/checklist/itemReview.js +63 -0
  62. package/dist/artifact-blocks/blocks/checklist/labels.d.ts +62 -0
  63. package/dist/artifact-blocks/blocks/checklist/labels.js +96 -0
  64. package/dist/artifact-blocks/blocks/checklist/retest.d.ts +43 -0
  65. package/dist/artifact-blocks/blocks/checklist/retest.js +68 -0
  66. package/dist/artifact-blocks/blocks/checklist/schema.d.ts +84 -0
  67. package/dist/artifact-blocks/blocks/checklist/schema.js +91 -0
  68. package/dist/artifact-blocks/blocks/code/CodeBlock.svelte +128 -0
  69. package/dist/artifact-blocks/blocks/code/CodeBlock.svelte.d.ts +12 -0
  70. package/dist/artifact-blocks/blocks/code/block.d.ts +8 -0
  71. package/dist/artifact-blocks/blocks/code/block.js +31 -0
  72. package/dist/artifact-blocks/blocks/code/highlight.d.ts +34 -0
  73. package/dist/artifact-blocks/blocks/code/highlight.js +280 -0
  74. package/dist/artifact-blocks/blocks/code/schema.d.ts +32 -0
  75. package/dist/artifact-blocks/blocks/code/schema.js +39 -0
  76. package/dist/artifact-blocks/blocks/collage/CollageBlock.svelte +290 -0
  77. package/dist/artifact-blocks/blocks/collage/CollageBlock.svelte.d.ts +29 -0
  78. package/dist/artifact-blocks/blocks/collage/block.d.ts +9 -0
  79. package/dist/artifact-blocks/blocks/collage/block.js +29 -0
  80. package/dist/artifact-blocks/blocks/collage/schema.d.ts +52 -0
  81. package/dist/artifact-blocks/blocks/collage/schema.js +86 -0
  82. package/dist/artifact-blocks/blocks/compare/CompareBlock.svelte +484 -0
  83. package/dist/artifact-blocks/blocks/compare/CompareBlock.svelte.d.ts +25 -0
  84. package/dist/artifact-blocks/blocks/compare/block.d.ts +9 -0
  85. package/dist/artifact-blocks/blocks/compare/block.js +32 -0
  86. package/dist/artifact-blocks/blocks/compare/schema.d.ts +74 -0
  87. package/dist/artifact-blocks/blocks/compare/schema.js +148 -0
  88. package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte +392 -0
  89. package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +30 -0
  90. package/dist/artifact-blocks/blocks/diagram/block.d.ts +9 -0
  91. package/dist/artifact-blocks/blocks/diagram/block.js +50 -0
  92. package/dist/artifact-blocks/blocks/diagram/constants.d.ts +48 -0
  93. package/dist/artifact-blocks/blocks/diagram/constants.js +53 -0
  94. package/dist/artifact-blocks/blocks/diagram/schema.d.ts +161 -0
  95. package/dist/artifact-blocks/blocks/diagram/schema.js +621 -0
  96. package/dist/artifact-blocks/blocks/diagram/thumb.d.ts +2 -0
  97. package/dist/artifact-blocks/blocks/diagram/thumb.js +157 -0
  98. package/dist/artifact-blocks/blocks/diagram/types.d.ts +74 -0
  99. package/dist/artifact-blocks/blocks/diagram/types.js +1 -0
  100. package/dist/artifact-blocks/blocks/diff/DiffBlock.svelte +81 -0
  101. package/dist/artifact-blocks/blocks/diff/DiffBlock.svelte.d.ts +10 -0
  102. package/dist/artifact-blocks/blocks/diff/block.d.ts +7 -0
  103. package/dist/artifact-blocks/blocks/diff/block.js +36 -0
  104. package/dist/artifact-blocks/blocks/diff/schema.d.ts +18 -0
  105. package/dist/artifact-blocks/blocks/diff/schema.js +35 -0
  106. package/dist/artifact-blocks/blocks/diff-review/DiffReviewBlock.svelte +1055 -0
  107. package/dist/artifact-blocks/blocks/diff-review/DiffReviewBlock.svelte.d.ts +5 -0
  108. package/dist/artifact-blocks/blocks/diff-review/block.d.ts +15 -0
  109. package/dist/artifact-blocks/blocks/diff-review/block.js +64 -0
  110. package/dist/artifact-blocks/blocks/diff-review/decisions.d.ts +57 -0
  111. package/dist/artifact-blocks/blocks/diff-review/decisions.js +80 -0
  112. package/dist/artifact-blocks/blocks/diff-review/schema.d.ts +13 -0
  113. package/dist/artifact-blocks/blocks/diff-review/schema.js +84 -0
  114. package/dist/artifact-blocks/blocks/diff-review/types.d.ts +35 -0
  115. package/dist/artifact-blocks/blocks/diff-review/types.js +1 -0
  116. package/dist/artifact-blocks/blocks/explainer/ExplainerBlock.svelte +145 -0
  117. package/dist/artifact-blocks/blocks/explainer/ExplainerBlock.svelte.d.ts +5 -0
  118. package/dist/artifact-blocks/blocks/explainer/block.d.ts +8 -0
  119. package/dist/artifact-blocks/blocks/explainer/block.js +21 -0
  120. package/dist/artifact-blocks/blocks/explainer/schema.d.ts +26 -0
  121. package/dist/artifact-blocks/blocks/explainer/schema.js +69 -0
  122. package/dist/artifact-blocks/blocks/group-content.d.ts +10 -0
  123. package/dist/artifact-blocks/blocks/group-content.js +16 -0
  124. package/dist/artifact-blocks/blocks/group-data.d.ts +10 -0
  125. package/dist/artifact-blocks/blocks/group-data.js +7 -0
  126. package/dist/artifact-blocks/blocks/group-review.d.ts +10 -0
  127. package/dist/artifact-blocks/blocks/group-review.js +12 -0
  128. package/dist/artifact-blocks/blocks/group-visual.d.ts +17 -0
  129. package/dist/artifact-blocks/blocks/group-visual.js +14 -0
  130. package/dist/artifact-blocks/blocks/map/MapBlock.svelte +413 -0
  131. package/dist/artifact-blocks/blocks/map/MapBlock.svelte.d.ts +53 -0
  132. package/dist/artifact-blocks/blocks/map/block.d.ts +9 -0
  133. package/dist/artifact-blocks/blocks/map/block.js +62 -0
  134. package/dist/artifact-blocks/blocks/map/schema.d.ts +123 -0
  135. package/dist/artifact-blocks/blocks/map/schema.js +113 -0
  136. package/dist/artifact-blocks/blocks/map/thumb.d.ts +2 -0
  137. package/dist/artifact-blocks/blocks/map/thumb.js +69 -0
  138. package/dist/artifact-blocks/blocks/markdown/MarkdownBlock.svelte +33 -0
  139. package/dist/artifact-blocks/blocks/markdown/MarkdownBlock.svelte.d.ts +15 -0
  140. package/dist/artifact-blocks/blocks/markdown/block.d.ts +7 -0
  141. package/dist/artifact-blocks/blocks/markdown/block.js +28 -0
  142. package/dist/artifact-blocks/blocks/markdown/render.d.ts +17 -0
  143. package/dist/artifact-blocks/blocks/markdown/render.js +115 -0
  144. package/dist/artifact-blocks/blocks/markdown/schema.d.ts +10 -0
  145. package/dist/artifact-blocks/blocks/markdown/schema.js +23 -0
  146. package/dist/artifact-blocks/blocks/media-picker/MediaPickerBlock.svelte +2112 -0
  147. package/dist/artifact-blocks/blocks/media-picker/MediaPickerBlock.svelte.d.ts +5 -0
  148. package/dist/artifact-blocks/blocks/media-picker/block.d.ts +24 -0
  149. package/dist/artifact-blocks/blocks/media-picker/block.js +137 -0
  150. package/dist/artifact-blocks/blocks/media-picker/choices.d.ts +28 -0
  151. package/dist/artifact-blocks/blocks/media-picker/choices.js +35 -0
  152. package/dist/artifact-blocks/blocks/media-picker/decisions.d.ts +51 -0
  153. package/dist/artifact-blocks/blocks/media-picker/decisions.js +75 -0
  154. package/dist/artifact-blocks/blocks/media-picker/schema.d.ts +7 -0
  155. package/dist/artifact-blocks/blocks/media-picker/schema.js +199 -0
  156. package/dist/artifact-blocks/blocks/media-picker/types.d.ts +92 -0
  157. package/dist/artifact-blocks/blocks/media-picker/types.js +1 -0
  158. package/dist/artifact-blocks/blocks/media-picker/versions.d.ts +51 -0
  159. package/dist/artifact-blocks/blocks/media-picker/versions.js +118 -0
  160. package/dist/artifact-blocks/blocks/mockup/MockupBlock.svelte +213 -0
  161. package/dist/artifact-blocks/blocks/mockup/MockupBlock.svelte.d.ts +5 -0
  162. package/dist/artifact-blocks/blocks/mockup/block.d.ts +26 -0
  163. package/dist/artifact-blocks/blocks/mockup/block.js +52 -0
  164. package/dist/artifact-blocks/blocks/mockup/doc.d.ts +24 -0
  165. package/dist/artifact-blocks/blocks/mockup/doc.js +44 -0
  166. package/dist/artifact-blocks/blocks/mockup/sandbox.d.ts +21 -0
  167. package/dist/artifact-blocks/blocks/mockup/sandbox.js +27 -0
  168. package/dist/artifact-blocks/blocks/mockup/schema.d.ts +7 -0
  169. package/dist/artifact-blocks/blocks/mockup/schema.js +90 -0
  170. package/dist/artifact-blocks/blocks/mockup/types.d.ts +36 -0
  171. package/dist/artifact-blocks/blocks/mockup/types.js +17 -0
  172. package/dist/artifact-blocks/blocks/program/ProgramBlock.svelte +359 -0
  173. package/dist/artifact-blocks/blocks/program/ProgramBlock.svelte.d.ts +32 -0
  174. package/dist/artifact-blocks/blocks/program/block.d.ts +35 -0
  175. package/dist/artifact-blocks/blocks/program/block.js +68 -0
  176. package/dist/artifact-blocks/blocks/program/schema.d.ts +132 -0
  177. package/dist/artifact-blocks/blocks/program/schema.js +268 -0
  178. package/dist/artifact-blocks/blocks/stats/StatsBlock.svelte +315 -0
  179. package/dist/artifact-blocks/blocks/stats/StatsBlock.svelte.d.ts +24 -0
  180. package/dist/artifact-blocks/blocks/stats/block.d.ts +7 -0
  181. package/dist/artifact-blocks/blocks/stats/block.js +49 -0
  182. package/dist/artifact-blocks/blocks/stats/schema.d.ts +82 -0
  183. package/dist/artifact-blocks/blocks/stats/schema.js +43 -0
  184. package/dist/artifact-blocks/blocks/stats/thumb.d.ts +2 -0
  185. package/dist/artifact-blocks/blocks/stats/thumb.js +102 -0
  186. package/dist/artifact-blocks/blocks/table/TableBlock.svelte +490 -0
  187. package/dist/artifact-blocks/blocks/table/TableBlock.svelte.d.ts +28 -0
  188. package/dist/artifact-blocks/blocks/table/block.d.ts +9 -0
  189. package/dist/artifact-blocks/blocks/table/block.js +100 -0
  190. package/dist/artifact-blocks/blocks/table/schema.d.ts +143 -0
  191. package/dist/artifact-blocks/blocks/table/schema.js +278 -0
  192. package/dist/artifact-blocks/blocks/timeline/TimelineBlock.svelte +347 -0
  193. package/dist/artifact-blocks/blocks/timeline/TimelineBlock.svelte.d.ts +42 -0
  194. package/dist/artifact-blocks/blocks/timeline/block.d.ts +9 -0
  195. package/dist/artifact-blocks/blocks/timeline/block.js +96 -0
  196. package/dist/artifact-blocks/blocks/timeline/constants.d.ts +26 -0
  197. package/dist/artifact-blocks/blocks/timeline/constants.js +27 -0
  198. package/dist/artifact-blocks/blocks/timeline/schema.d.ts +102 -0
  199. package/dist/artifact-blocks/blocks/timeline/schema.js +405 -0
  200. package/dist/artifact-blocks/blocks/timeline/thumb.d.ts +2 -0
  201. package/dist/artifact-blocks/blocks/timeline/thumb.js +117 -0
  202. package/dist/artifact-blocks/blocks/timeline/types.d.ts +66 -0
  203. package/dist/artifact-blocks/blocks/timeline/types.js +1 -0
  204. package/dist/artifact-blocks/blocks/transcript/TranscriptBlock.svelte +169 -0
  205. package/dist/artifact-blocks/blocks/transcript/TranscriptBlock.svelte.d.ts +26 -0
  206. package/dist/artifact-blocks/blocks/transcript/block.d.ts +9 -0
  207. package/dist/artifact-blocks/blocks/transcript/block.js +86 -0
  208. package/dist/artifact-blocks/blocks/transcript/schema.d.ts +114 -0
  209. package/dist/artifact-blocks/blocks/transcript/schema.js +126 -0
  210. package/dist/artifact-blocks/builtins.d.ts +40 -0
  211. package/dist/artifact-blocks/builtins.js +22 -0
  212. package/dist/artifact-blocks/contractBlocks.d.ts +92 -0
  213. package/dist/artifact-blocks/contractBlocks.js +279 -0
  214. package/dist/artifact-blocks/history.d.ts +140 -0
  215. package/dist/artifact-blocks/history.js +363 -0
  216. package/dist/artifact-blocks/index.d.ts +40 -0
  217. package/dist/artifact-blocks/index.js +46 -0
  218. package/dist/artifact-blocks/listen/context.d.ts +30 -0
  219. package/dist/artifact-blocks/listen/context.js +30 -0
  220. package/dist/artifact-blocks/liveData.d.ts +78 -0
  221. package/dist/artifact-blocks/liveData.js +137 -0
  222. package/dist/artifact-blocks/liveDataPoller.d.ts +41 -0
  223. package/dist/artifact-blocks/liveDataPoller.js +140 -0
  224. package/dist/artifact-blocks/openNav.d.ts +46 -0
  225. package/dist/artifact-blocks/openNav.js +106 -0
  226. package/dist/artifact-blocks/pageReviewKeys.d.ts +17 -0
  227. package/dist/artifact-blocks/pageReviewKeys.js +115 -0
  228. package/dist/artifact-blocks/params.d.ts +98 -0
  229. package/dist/artifact-blocks/params.js +159 -0
  230. package/dist/artifact-blocks/registry.d.ts +27 -0
  231. package/dist/artifact-blocks/registry.js +83 -0
  232. package/dist/artifact-blocks/review/BlockIcon.svelte +99 -0
  233. package/dist/artifact-blocks/review/BlockIcon.svelte.d.ts +17 -0
  234. package/dist/artifact-blocks/review/BulkRejectForm.svelte +94 -0
  235. package/dist/artifact-blocks/review/BulkRejectForm.svelte.d.ts +11 -0
  236. package/dist/artifact-blocks/review/BulkUndoBar.svelte +40 -0
  237. package/dist/artifact-blocks/review/BulkUndoBar.svelte.d.ts +9 -0
  238. package/dist/artifact-blocks/review/ItemNote.svelte +193 -0
  239. package/dist/artifact-blocks/review/ItemNote.svelte.d.ts +33 -0
  240. package/dist/artifact-blocks/review/NoteToggle.svelte +52 -0
  241. package/dist/artifact-blocks/review/NoteToggle.svelte.d.ts +12 -0
  242. package/dist/artifact-blocks/review/TeamChip.svelte +71 -0
  243. package/dist/artifact-blocks/review/TeamChip.svelte.d.ts +8 -0
  244. package/dist/artifact-blocks/review/attachments.d.ts +10 -0
  245. package/dist/artifact-blocks/review/attachments.js +37 -0
  246. package/dist/artifact-blocks/review/bulk.d.ts +52 -0
  247. package/dist/artifact-blocks/review/bulk.js +61 -0
  248. package/dist/artifact-blocks/review/itemReview.d.ts +33 -0
  249. package/dist/artifact-blocks/review/itemReview.js +112 -0
  250. package/dist/artifact-blocks/review/roundLines.d.ts +14 -0
  251. package/dist/artifact-blocks/review/roundLines.js +20 -0
  252. package/dist/artifact-blocks/review/rounds.d.ts +86 -0
  253. package/dist/artifact-blocks/review/rounds.js +302 -0
  254. package/dist/artifact-blocks/review/saver.d.ts +23 -0
  255. package/dist/artifact-blocks/review/saver.js +48 -0
  256. package/dist/artifact-blocks/review/team.d.ts +31 -0
  257. package/dist/artifact-blocks/review/team.js +42 -0
  258. package/dist/artifact-blocks/review/visualOrder.d.ts +29 -0
  259. package/dist/artifact-blocks/review/visualOrder.js +66 -0
  260. package/dist/artifact-blocks/review.d.ts +180 -0
  261. package/dist/artifact-blocks/review.js +716 -0
  262. package/dist/artifact-blocks/thumb-kit.d.ts +80 -0
  263. package/dist/artifact-blocks/thumb-kit.js +149 -0
  264. package/dist/artifact-blocks/types.d.ts +513 -0
  265. package/dist/artifact-blocks/types.js +1 -0
  266. package/package.json +11 -2
@@ -0,0 +1,1048 @@
1
+ <script lang="ts">
2
+ /**
3
+ * checklist block — the Runbook archetype of the one review experience
4
+ * (docs/review-ux/proposal.md, mos-lsmve.7): rows with a title, an optional
5
+ * test URL, an owner, a verdict and a note. Viewers decide each row; that
6
+ * per-viewer state persists via ctx.saveState (keyed per row id). With
7
+ * teamMode:true the AUTHORED status in params renders as the shared "Team"
8
+ * status — the agents' view, updated by republishing the spec — beside the
9
+ * viewer's own private verdict ("You").
10
+ *
11
+ * Built from the shared jatui review primitives so what a reviewer learns
12
+ * here carries to every other review page:
13
+ * - Rows are focusable cards walked by reviewKeys (j/k, ↓/↑, gg/G). On the
14
+ * focused card: a pass, x fail, b blocked, u undo (back to Untested),
15
+ * n note, Enter opens the row's test link, [ / ] walk earlier rounds,
16
+ * f next row that needs you (retest, then undecided), ? shortcuts. The
17
+ * old p letter still works for one release; the shortcuts modal says so.
18
+ * - VerdictControl: Pass / Fail / Blocked, labels always visible, a second
19
+ * press never clears, Undo is explicit.
20
+ * - ReviewNote: the note of the CURRENT round, opening itself on Fail or
21
+ * Blocked, with its own Saving / Saved ✓ / Not saved state.
22
+ * - RoundHistory: earlier rounds, locked (Joe D1 = A).
23
+ * - ReviewFilterBar (chips with counts + one Sort/Group menu) and
24
+ * ReviewProgress (one tone bar + a sentence) on top.
25
+ *
26
+ * Rounds (Joe D2 = A): a row's round is its `retestSince`. When the team
27
+ * marks a row ready with a new retestSince, the viewer's previous verdict
28
+ * and note show as a locked history line and the current round starts
29
+ * blank; the first save in the round writes that move (retest.ts
30
+ * CHECKLIST_ROUNDS, on the viewer's own save — never a migration).
31
+ *
32
+ * Team progress (mos-mssz0): in teamMode the Team column also speaks two
33
+ * team-only states — "In progress" and "Ready to retest". A ready row the
34
+ * viewer has not re-verdicted in its round carries a "Retest" chip and
35
+ * counts in the progress line; the same rule (retest.ts) is what puts the
36
+ * page under Needs you on /admin/artifacts.
37
+ *
38
+ * Read-only surfaces (public share, print, tests without saveState) render
39
+ * verdicts as badges with no controls, plus the locked history — they must
40
+ * look correct without ever calling saveState.
41
+ *
42
+ * Sort + group (mos-iwmjz) live in the filter bar's menu. They are
43
+ * viewer-LOCAL layout choices — persisted in the viewer's browser
44
+ * (localStorage, try/catch), never in block state — and render server-side
45
+ * with the defaults so hydration never disagrees with a saved row order.
46
+ * The DOM order always equals the visual order, so j/k and Tab follow what
47
+ * the viewer sees.
48
+ */
49
+ import Icon from "./Icon.svelte"
50
+ import { tick } from "svelte"
51
+ import ReviewFilterBar from "../../../components/review/ReviewFilterBar.svelte"
52
+ import ReviewNote from "../../../components/review/ReviewNote.svelte"
53
+ import ReviewProgress from "../../../components/review/ReviewProgress.svelte"
54
+ import ReviewShortcuts from "../../../components/review/ReviewShortcuts.svelte"
55
+ import RoundHistory from "../../../components/review/RoundHistory.svelte"
56
+ import VerdictControl from "../../../components/review/VerdictControl.svelte"
57
+ import {
58
+ buildReviewFilters,
59
+ orderRounds,
60
+ type ReviewRound,
61
+ } from "../../../components/review/reviewSummary.js"
62
+ import {
63
+ createReviewKeys,
64
+ type NeedsYou,
65
+ type ReviewKeysOptions,
66
+ } from "../../../actions/reviewKeys.js"
67
+ import { isTypingTarget } from "../../../utils/keyboardShortcutRegistry.js"
68
+ import type { BlockProps } from "../../types.js"
69
+ import {
70
+ CHECKLIST_STATUSES,
71
+ type ChecklistParams,
72
+ type ChecklistRow,
73
+ type ChecklistRowState,
74
+ type ChecklistState,
75
+ type ChecklistStatus,
76
+ } from "./schema.js"
77
+ import {
78
+ CHECKLIST_ROUNDS,
79
+ rowAwaitsViewer,
80
+ rowRound,
81
+ seedStatus,
82
+ } from "./retest.js"
83
+ import {
84
+ CHECKLIST_GROUPS,
85
+ CHECKLIST_SORTS,
86
+ GROUP_OPTIONS,
87
+ ICON_TONE,
88
+ LABEL,
89
+ LEGACY_KEYS,
90
+ SORT_OPTIONS,
91
+ STATUS_BADGE,
92
+ STATUS_RANK,
93
+ TEAM_ICON_TONE,
94
+ TEAM_LABEL,
95
+ TITLE_LINK,
96
+ VERDICT_OPTIONS,
97
+ type ChecklistGroup,
98
+ type ChecklistSort,
99
+ } from "./labels.js"
100
+ import SectionTools from "../../SectionTools.svelte"
101
+
102
+ let { params, ctx }: BlockProps<ChecklistParams> = $props()
103
+
104
+ const rows = $derived(params?.rows ?? [])
105
+ const teamMode = $derived(params?.teamMode === true)
106
+ /** Interactive = the viewer can set verdicts here (private to them). */
107
+ const interactive = $derived(Boolean(ctx?.saveState) && !ctx?.readonly)
108
+ /** This viewer's saved state for this block (null = none yet). */
109
+ const saved = $derived((ctx?.state ?? null) as ChecklistState | null)
110
+ const groupName = $derived(`chk-${ctx?.blockId ?? "checklist"}`)
111
+
112
+ // ---- viewer view prefs: sort + group (mos-iwmjz) ------------------------
113
+ // Layout choices are UI preference, not verdict data: they persist in THIS
114
+ // BROWSER under a per-(artifact, block) key and never through ctx.saveState
115
+ // (state is shared per-viewer verdict data keyed per artifact version — a
116
+ // sort must not write it). Every access is try/catch'd and guarded, so a
117
+ // missing/full/blocked storage renders the defaults and works fine.
118
+ const VIEW_KEY = `jat-checklist-view:${ctx?.artifactId ?? "artifact"}:${ctx?.blockId ?? "checklist"}`
119
+
120
+ // Raw (string) values are what the filter bar's menu binds; the typed
121
+ // views below coerce, so corrupted storage can never produce an unknown
122
+ // mode.
123
+ let sortByRaw = $state<string>("authored")
124
+ let groupByRaw = $state<string>("none")
125
+ const sortBy = $derived(isSort(sortByRaw) ? sortByRaw : "authored")
126
+ const groupBy = $derived(isGroup(groupByRaw) ? groupByRaw : "none")
127
+
128
+ function isSort(v: unknown): v is ChecklistSort {
129
+ return (
130
+ typeof v === "string" &&
131
+ (CHECKLIST_SORTS as readonly string[]).includes(v)
132
+ )
133
+ }
134
+ function isGroup(v: unknown): v is ChecklistGroup {
135
+ return (
136
+ typeof v === "string" &&
137
+ (CHECKLIST_GROUPS as readonly string[]).includes(v)
138
+ )
139
+ }
140
+
141
+ function loadViewPrefs(): {
142
+ sortBy?: ChecklistSort
143
+ groupBy?: ChecklistGroup
144
+ } {
145
+ try {
146
+ if (typeof localStorage === "undefined") return {}
147
+ const raw = localStorage.getItem(VIEW_KEY)
148
+ if (!raw) return {}
149
+ const parsed: unknown = JSON.parse(raw)
150
+ if (!parsed || typeof parsed !== "object") return {}
151
+ const o = parsed as Record<string, unknown>
152
+ return {
153
+ ...(isSort(o.sortBy) ? { sortBy: o.sortBy } : {}),
154
+ ...(isGroup(o.groupBy) ? { groupBy: o.groupBy } : {}),
155
+ }
156
+ } catch {
157
+ return {}
158
+ }
159
+ }
160
+
161
+ function saveViewPrefs(next: { sortBy: string; groupBy: string }) {
162
+ try {
163
+ if (typeof localStorage === "undefined") return
164
+ localStorage.setItem(VIEW_KEY, JSON.stringify(next))
165
+ } catch {
166
+ /* storage unavailable or full — prefs just stay session-local */
167
+ }
168
+ }
169
+
170
+ /** Set once the saved prefs (or their absence) landed on mount. */
171
+ let viewHydrated = $state(false)
172
+
173
+ // Restore once on mount. Effects never run on SSR, so the server render
174
+ // always paints the defaults and hydration never disagrees with a
175
+ // saved-prefs row order; the restore is a post-hydration update.
176
+ $effect(() => {
177
+ const prefs = loadViewPrefs()
178
+ if (prefs.sortBy) sortByRaw = prefs.sortBy
179
+ if (prefs.groupBy) groupByRaw = prefs.groupBy
180
+ viewHydrated = true
181
+ })
182
+
183
+ // Persist on change — only after the restore above ran, so a default can
184
+ // never overwrite what the viewer saved.
185
+ $effect(() => {
186
+ if (!viewHydrated) return
187
+ saveViewPrefs({ sortBy: sortByRaw, groupBy: groupByRaw })
188
+ })
189
+
190
+ const STATUS_ICON = {
191
+ pass: "circle-check",
192
+ fail: "circle-x",
193
+ blocked: "circle-pause",
194
+ untested: "circle-dashed",
195
+ } as const
196
+ const TEAM_ICON = {
197
+ ...STATUS_ICON,
198
+ ready: "rotate-ccw",
199
+ in_progress: "circle-dot-dashed",
200
+ } as const
201
+
202
+ // ---- the viewer's stored entries, one per row ---------------------------
203
+ // State is keyed per (artifact, version, user, blockId) and does not change
204
+ // while mounted — one mount-time read, like the callout block. Each entry
205
+ // is the row's saved shape: the CURRENT round flat ({status, note, at,
206
+ // statusAt, round}) plus the locked `history[]`. Stored JSON is untrusted,
207
+ // so only known fields of the right type survive the read.
208
+ let entries = $state<Record<string, Record<string, unknown>>>({})
209
+ {
210
+ const s = (ctx?.state as ChecklistState | null)?.rows ?? {}
211
+ for (const [id, r] of Object.entries(s)) {
212
+ if (!r || typeof r !== "object") continue
213
+ const raw = r as Record<string, unknown>
214
+ const e: Record<string, unknown> = {}
215
+ if (isStatus(raw.status)) e.status = raw.status
216
+ for (const k of ["note", "at", "statusAt", "round"] as const)
217
+ if (typeof raw[k] === "string") e[k] = raw[k]
218
+ const history = CHECKLIST_ROUNDS.parseHistory(raw.history)
219
+ if (history.length > 0) e.history = history
220
+ entries[id] = e
221
+ }
222
+ }
223
+
224
+ function isStatus(v: unknown): v is ChecklistStatus {
225
+ return (
226
+ typeof v === "string" &&
227
+ (CHECKLIST_STATUSES as readonly string[]).includes(v)
228
+ )
229
+ }
230
+
231
+ /** Each row's current round + locked history, as the viewer sees it now. */
232
+ const views = $derived.by(() => {
233
+ const out: Record<string, ReturnType<typeof CHECKLIST_ROUNDS.view>> = {}
234
+ for (const row of rows)
235
+ out[row.id] = CHECKLIST_ROUNDS.view(entries[row.id], rowRound(row))
236
+ return out
237
+ })
238
+
239
+ /**
240
+ * The viewer's verdict for a row in its current round. Their save wins;
241
+ * a stale round starts blank. What fills a row with NO save differs by
242
+ * mode (mos-lsmve.16 F8): in teamMode the viewer starts UNTETED — the
243
+ * Team column already shows the team's verdict, and seeding "You" from
244
+ * it double-counted the team's answer as the viewer's (untouched rows
245
+ * carried an Undo, a Blocked row's note sat open, they counted as
246
+ * decided). Outside teamMode the authored status still seeds: a solo
247
+ * checklist's statuses ARE the statuses being confirmed.
248
+ */
249
+ function viewerStatus(row: ChecklistRow): ChecklistStatus {
250
+ const v = views[row.id]
251
+ const st = v?.current.status
252
+ if (isStatus(st)) return st
253
+ if (v?.stale) return "untested"
254
+ if (teamMode) return "untested"
255
+ return seedStatus(row)
256
+ }
257
+
258
+ /** The current round's note ("" when none). */
259
+ function currentNote(row: ChecklistRow): string {
260
+ const n = views[row.id]?.current.note
261
+ return typeof n === "string" ? n : ""
262
+ }
263
+
264
+ /** A `ready` row the viewer has not re-verdicted in its round. */
265
+ function awaitsRetest(row: ChecklistRow): boolean {
266
+ return rowAwaitsViewer(row, entries[row.id])
267
+ }
268
+
269
+ /** Earlier rounds, oldest first (how RoundHistory numbers them). Keys are
270
+ * made unique per line, so two lines of one round never collide. */
271
+ function historyOf(row: ChecklistRow): ReviewRound[] {
272
+ const lines = views[row.id]?.history ?? []
273
+ return [...lines].reverse().map((l, i) => ({
274
+ round: `${i}:${l.round ?? "legacy"}`,
275
+ verdict: l.verdict ?? null,
276
+ note: l.note ?? null,
277
+ at: l.at ?? null,
278
+ }))
279
+ }
280
+
281
+ // ---- saving (coalesced locally; the host may debounce further) ----------
282
+ let saving = $state(false)
283
+ let saveError = $state<string | null>(null)
284
+ let justSavedAt = $state<string | null>(null)
285
+ let saveTimer: ReturnType<typeof setTimeout> | null = null
286
+
287
+ function queueSave() {
288
+ if (!interactive || !ctx?.saveState) return
289
+ if (saveTimer) clearTimeout(saveTimer)
290
+ saving = true
291
+ saveTimer = setTimeout(() => void flushSave(), 350)
292
+ }
293
+
294
+ /**
295
+ * The payload carries every row the viewer touched, each in its saved
296
+ * shape — status even when it equals the authored seed (a note-only save
297
+ * must not drop a saved status — the state replaces as a whole), an
298
+ * empty-string note to persist an intentional clear, and the row's own
299
+ * `round` + `history`. Each row keeps its own `at` / `statusAt` — stamped
300
+ * when THAT row changed, never by a save elsewhere on the page.
301
+ */
302
+ function statePayload(): ChecklistState {
303
+ const out: NonNullable<ChecklistState["rows"]> = {}
304
+ for (const row of rows) {
305
+ const e = entries[row.id]
306
+ if (e && Object.keys(e).length > 0)
307
+ out[row.id] = { ...e } as ChecklistRowState
308
+ }
309
+ return { rows: out, at: new Date().toISOString() }
310
+ }
311
+
312
+ /** Save now. Resolves false (and shows the error) when the host refused. */
313
+ async function flushSave(): Promise<boolean> {
314
+ if (saveTimer) clearTimeout(saveTimer)
315
+ saveTimer = null
316
+ if (!ctx?.saveState) return false
317
+ saveError = null
318
+ saving = true
319
+ try {
320
+ await ctx.saveState(statePayload())
321
+ justSavedAt = new Date().toISOString()
322
+ return true
323
+ } catch (e) {
324
+ saveError = e instanceof Error ? e.message : "Saving failed"
325
+ return false
326
+ } finally {
327
+ saving = false
328
+ }
329
+ }
330
+
331
+ /** Apply one change to a row through the round model (the first save in
332
+ * a new round locks the old verdict + note into history first). */
333
+ function patchRow(row: ChecklistRow, patch: Record<string, unknown>) {
334
+ entries[row.id] = CHECKLIST_ROUNDS.save(
335
+ entries[row.id],
336
+ patch,
337
+ rowRound(row),
338
+ )
339
+ }
340
+
341
+ function setStatus(row: ChecklistRow, status: string) {
342
+ if (!interactive || !isStatus(status)) return
343
+ const now = new Date().toISOString()
344
+ const opensNote = VERDICT_OPTIONS.some(
345
+ (o) => o.id === status && o.opensNote,
346
+ )
347
+ patchRow(row, { status, at: now, statusAt: now })
348
+ queueSave()
349
+ // ReviewNote opens itself on a verdict CHANGE only while it is mounted
350
+ // open; a closed note is not, so open + focus it here.
351
+ if (opensNote && !openNotes[row.id]) void toggleNote(row)
352
+ }
353
+
354
+ /** Undo: the current round back to Untested. The note stays. */
355
+ function undo(row: ChecklistRow) {
356
+ if (viewerStatus(row) === "untested") return
357
+ setStatus(row, "untested")
358
+ }
359
+
360
+ /** Every keystroke lands in the round (locally); ReviewNote's debounced
361
+ * onSave is what sends it. */
362
+ function noteInput(row: ChecklistRow, text: string) {
363
+ if (!interactive) return
364
+ patchRow(row, { note: text, at: new Date().toISOString() })
365
+ }
366
+
367
+ async function noteSave(row: ChecklistRow, text: string) {
368
+ noteInput(row, text)
369
+ if (!(await flushSave())) throw new Error(saveError ?? "Saving failed")
370
+ }
371
+
372
+ /** Rows whose note field is open. Seeded open where a note matters: the
373
+ * viewer's own note exists on the row, or their verdict stands at Fail /
374
+ * Blocked. In teamMode an UNDECIDED row never seeds its note open — a
375
+ * Team-blocked row is the team's problem statement, not this viewer's
376
+ * open field, and a seeded-open field IS a note-only save waiting to
377
+ * happen (mos-lsmve.16 F8). A row the viewer HAS saved keeps the old
378
+ * rule (their own fail/blocked opens the field). */
379
+ let openNotes = $state<Record<string, boolean>>({})
380
+ for (const row of params?.rows ?? []) {
381
+ if (teamMode && !entries[row.id]) continue
382
+ const st = viewerStatus(row)
383
+ if (currentNote(row) !== "" || st === "fail" || st === "blocked")
384
+ openNotes[row.id] = true
385
+ }
386
+
387
+ function noteInputId(row: ChecklistRow): string {
388
+ return `${groupName}-note-${row.id}`
389
+ }
390
+
391
+ async function toggleNote(row: ChecklistRow) {
392
+ if (!interactive) return
393
+ const open = !openNotes[row.id]
394
+ openNotes[row.id] = open
395
+ if (!open) return
396
+ await tick()
397
+ document.getElementById(noteInputId(row))?.focus()
398
+ }
399
+
400
+ // ---- earlier rounds -----------------------------------------------------
401
+ let historyOpen = $state<Record<string, boolean>>({})
402
+ let historySelected = $state<Record<string, string | null>>({})
403
+
404
+ /** `[` older / `]` newer: walk the locked lines; past the newest is back
405
+ * to the current round (nothing selected). */
406
+ function stepRound(row: ChecklistRow, delta: -1 | 1) {
407
+ const order = orderRounds(historyOf(row)).map((r) => r.entry.round)
408
+ if (order.length === 0) return
409
+ const cur = historySelected[row.id] ?? null
410
+ const at = cur === null ? -1 : order.indexOf(cur)
411
+ const next = delta === -1 ? Math.min(at + 1, order.length - 1) : at - 1
412
+ historySelected[row.id] = next < 0 ? null : order[next]
413
+ historyOpen[row.id] = next >= 0 || Boolean(historyOpen[row.id])
414
+ }
415
+
416
+ // ---- summary ------------------------------------------------------------
417
+ const counts = $derived.by(() => {
418
+ const c: Record<ChecklistStatus, number> = {
419
+ pass: 0,
420
+ fail: 0,
421
+ blocked: 0,
422
+ untested: 0,
423
+ }
424
+ for (const row of rows) c[viewerStatus(row)]++
425
+ return c
426
+ })
427
+ const total = $derived(rows.length)
428
+ /** Ready rows awaiting this viewer's retest (teamMode only). */
429
+ const retestCount = $derived(
430
+ teamMode ? rows.filter((r) => awaitsRetest(r)).length : 0,
431
+ )
432
+
433
+ // ---- filter (session-local) ---------------------------------------------
434
+ /** Does this row wait on the viewer — a retest, or no verdict yet? */
435
+ function needsViewer(row: ChecklistRow): boolean {
436
+ return (teamMode && awaitsRetest(row)) || viewerStatus(row) === "untested"
437
+ }
438
+ /** `f` asks this: retest wins over undecided, like the Needs-you chip. */
439
+ function needsYou(id: string): NeedsYou {
440
+ const row = rowById(id)
441
+ if (!row) return false
442
+ if (teamMode && awaitsRetest(row)) return "retest"
443
+ return viewerStatus(row) === "untested" ? "undecided" : false
444
+ }
445
+
446
+ const filters = $derived(
447
+ buildReviewFilters({
448
+ verdicts: VERDICT_OPTIONS,
449
+ counts,
450
+ total,
451
+ needsYou: teamMode ? rows.filter(needsViewer).length : undefined,
452
+ retest: teamMode ? retestCount : undefined,
453
+ }),
454
+ )
455
+ let filterRaw = $state("all")
456
+ /** A filter whose chip emptied out falls back to All, never a blank list. */
457
+ const filter = $derived(
458
+ filters.some((f) => f.id === filterRaw) ? filterRaw : "all",
459
+ )
460
+
461
+ function passesFilter(row: ChecklistRow): boolean {
462
+ switch (filter) {
463
+ case "all":
464
+ return true
465
+ case "needs-you":
466
+ return needsViewer(row)
467
+ case "undecided":
468
+ return viewerStatus(row) === "untested"
469
+ case "retest":
470
+ return awaitsRetest(row)
471
+ default:
472
+ return viewerStatus(row) === filter
473
+ }
474
+ }
475
+
476
+ // ---- sort + group (viewer-local choices, persisted in the browser) ------
477
+ /** One group of rows: stable key, DOM-safe slug, label, order, rows. */
478
+ interface ChecklistBucket {
479
+ key: string
480
+ slug: string
481
+ label: string
482
+ order: number
483
+ rows: ChecklistRow[]
484
+ }
485
+
486
+ /** Bucket keys are data (owner names etc.); ids must be DOM-safe. */
487
+ function slugify(key: string): string {
488
+ return key.toLowerCase().replace(/[^a-z0-9_-]+/g, "-")
489
+ }
490
+
491
+ /**
492
+ * Sorted rows. `sort` is stable, so equal keys keep the authored order —
493
+ * the status sort is a re-prioritisation (fail → blocked → untested →
494
+ * pass), never a reshuffle of peers. Status sorts the VIEWER's status
495
+ * (what they are working through), owner/title are alphabetical with
496
+ * ownerless rows last.
497
+ */
498
+ const sortedRows = $derived.by(() => {
499
+ const shown = rows.filter(passesFilter)
500
+ if (sortBy === "authored") return shown
501
+ const copy = [...shown]
502
+ if (sortBy === "status")
503
+ copy.sort(
504
+ (a, b) => STATUS_RANK[viewerStatus(a)] - STATUS_RANK[viewerStatus(b)],
505
+ )
506
+ else if (sortBy === "owner")
507
+ copy.sort((a, b) => {
508
+ if (a.owner && b.owner) return a.owner.localeCompare(b.owner)
509
+ if (a.owner) return -1
510
+ if (b.owner) return 1
511
+ return 0
512
+ })
513
+ else copy.sort((a, b) => a.title.localeCompare(b.title))
514
+ return copy
515
+ })
516
+
517
+ /** Which bucket a row belongs to under the current grouping. */
518
+ function bucketOf(row: ChecklistRow): {
519
+ key: string
520
+ label: string
521
+ order: number
522
+ } {
523
+ if (groupBy === "status") {
524
+ const st = viewerStatus(row)
525
+ return { key: `status-${st}`, label: LABEL[st], order: STATUS_RANK[st] }
526
+ }
527
+ if (groupBy === "owner") {
528
+ if (!row.owner) return { key: "owner-none", label: "No owner", order: 1 }
529
+ return { key: `owner-${row.owner}`, label: row.owner, order: 0 }
530
+ }
531
+ // mismatch — teamMode: an UNdecided row is neither a match nor a
532
+ // divergence (the viewer has not answered it — F8), so only decided
533
+ // rows group by divergence; undecided rows bucket under their own
534
+ // group. Solo mode: the viewer's verdict vs the authored seed.
535
+ if (teamMode && viewerStatus(row) === "untested")
536
+ return { key: "mismatch-undecided", label: "Not decided yet", order: 1 }
537
+ const diverged =
538
+ viewerStatus(row) !==
539
+ (teamMode ? (row.status ?? "untested") : seedStatus(row))
540
+ if (teamMode)
541
+ return diverged
542
+ ? { key: "mismatch-yes", label: "Mismatch", order: 0 }
543
+ : { key: "mismatch-no", label: "Match", order: 2 }
544
+ return diverged
545
+ ? { key: "mismatch-yes", label: "Changed", order: 0 }
546
+ : { key: "mismatch-no", label: "Unchanged", order: 1 }
547
+ }
548
+
549
+ /**
550
+ * Contiguous groups in display order (empty ones never appear). With
551
+ * grouping off this is one headerless bucket carrying the sorted rows.
552
+ */
553
+ const groupedRows = $derived.by(() => {
554
+ if (groupBy === "none")
555
+ return [
556
+ { key: "all", slug: "all", label: "", order: 0, rows: sortedRows },
557
+ ] satisfies ChecklistBucket[]
558
+ const map = new Map<string, ChecklistBucket>()
559
+ for (const row of sortedRows) {
560
+ const meta = bucketOf(row)
561
+ let bucket = map.get(meta.key)
562
+ if (!bucket) {
563
+ bucket = { ...meta, slug: slugify(meta.key), rows: [] }
564
+ map.set(meta.key, bucket)
565
+ }
566
+ bucket.rows.push(row)
567
+ }
568
+ return [...map.values()].sort(
569
+ (a, b) => a.order - b.order || a.label.localeCompare(b.label),
570
+ )
571
+ })
572
+
573
+ /** Collapsed groups are session-local UI state, not persisted. */
574
+ let collapsedGroups = $state<Record<string, boolean>>({})
575
+
576
+ function toggleGroup(key: string) {
577
+ collapsedGroups[key] = !collapsedGroups[key]
578
+ }
579
+
580
+ // ---- keyboard: the shared review map ------------------------------------
581
+ let listEl: HTMLUListElement | undefined = $state()
582
+ let helpOpen = $state(false)
583
+
584
+ function rowById(id: string): ChecklistRow | undefined {
585
+ return rows.find((r) => r.id === id)
586
+ }
587
+
588
+ /** Enter on a card follows its test link (the same anchor a click hits). */
589
+ function openRow(el: HTMLElement | null) {
590
+ el?.querySelector<HTMLAnchorElement>("a[data-row-link]")?.click()
591
+ }
592
+
593
+ const keyConfig: ReviewKeysOptions = $derived({
594
+ // Collapsed groups hide their cards; j/k skips what is not on screen.
595
+ getItems: () =>
596
+ listEl
597
+ ? [...listEl.querySelectorAll<HTMLElement>("[data-item-id]")].filter(
598
+ (el) => !el.closest("[hidden]"),
599
+ )
600
+ : [],
601
+ onOpen: (_id, el) => openRow(el),
602
+ onRound: (id, delta) => {
603
+ const row = rowById(id)
604
+ if (row) stepRound(row, delta)
605
+ },
606
+ onHelp: () => (helpOpen = true),
607
+ ...(interactive
608
+ ? {
609
+ verdicts: VERDICT_OPTIONS,
610
+ onVerdict: (id: string, v: string) => {
611
+ const row = rowById(id)
612
+ if (row) setStatus(row, v)
613
+ },
614
+ onUndo: (id: string) => {
615
+ const row = rowById(id)
616
+ if (row) undo(row)
617
+ },
618
+ onNote: (id: string) => {
619
+ const row = rowById(id)
620
+ if (row) void toggleNote(row)
621
+ },
622
+ needsYou,
623
+ }
624
+ : {}),
625
+ })
626
+
627
+ const keys = createReviewKeys({})
628
+ $effect(() => {
629
+ keys.update(keyConfig)
630
+ })
631
+
632
+ /**
633
+ * Listens on the block, not the window: a page can carry several review
634
+ * blocks, and each one's keys act on its own focused card. The old p letter
635
+ * is honoured after the shared map passes on a key (f is the shared
636
+ * "next item that needs you", so it never reaches this fallback).
637
+ */
638
+ function onKeydown(e: KeyboardEvent) {
639
+ if (keys.handleKeydown(e)) return
640
+ if (!interactive || e.defaultPrevented || e.isComposing) return
641
+ if (e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return
642
+ const legacy = LEGACY_KEYS[e.key.toLowerCase()]
643
+ const id = legacy ? keys.currentId() : null
644
+ const row = id ? rowById(id) : undefined
645
+ if (!legacy || !row) return
646
+ e.preventDefault()
647
+ setStatus(row, legacy)
648
+ }
649
+
650
+ const SHORTCUTS_FOOTNOTE =
651
+ "The old P still passes for one more release (now A). F no longer fails (that is X): it jumps to the next item that needs you, as on every review page. U is Undo now, not Untested."
652
+
653
+ const timeFmt = new Intl.DateTimeFormat("en-US", {
654
+ hour: "numeric",
655
+ minute: "2-digit",
656
+ })
657
+ const dateTimeFmt = new Intl.DateTimeFormat("en-US", {
658
+ month: "short",
659
+ day: "numeric",
660
+ hour: "numeric",
661
+ minute: "2-digit",
662
+ })
663
+
664
+ const savedLine = $derived.by(() => {
665
+ if (saveError) return saveError
666
+ if (saving) return "Saving…"
667
+ if (justSavedAt) return `Saved ${timeFmt.format(new Date(justSavedAt))}`
668
+ return ""
669
+ })
670
+
671
+ function notePlaceholder(st: ChecklistStatus): string {
672
+ if (st === "fail") return "What failed?"
673
+ if (st === "blocked") return "What is it waiting on?"
674
+ return "Add a note"
675
+ }
676
+
677
+ /**
678
+ * The external-link marker rides with the title's LAST word (nowrap), so a
679
+ * wrapping title can never strand the icon alone on its own line.
680
+ */
681
+ function titleHead(title: string): string {
682
+ const i = title.trimEnd().lastIndexOf(" ")
683
+ return i < 0 ? "" : title.slice(0, i + 1)
684
+ }
685
+ function titleTail(title: string): string {
686
+ const i = title.trimEnd().lastIndexOf(" ")
687
+ return i < 0 ? title : title.slice(i + 1)
688
+ }
689
+
690
+ /** https:// links leave the app (new tab + marker); root-relative stay. */
691
+ function isExternal(url: string): boolean {
692
+ return /^https:\/\//i.test(url.trim())
693
+ }
694
+ </script>
695
+
696
+ <!-- The cards are the interactive elements; the section only hosts the
697
+ review keys (they bail unless a card or its controls has focus). -->
698
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
699
+ <section
700
+ class="artifact-checklist @container w-full rounded-box border border-base-content/10 bg-base-100"
701
+ onkeydown={onKeydown}
702
+ >
703
+ <header class="flex flex-col gap-2 px-4 pt-3.5 pb-3">
704
+ {#if params?.title}
705
+ <div class="flex items-center gap-2">
706
+ <h2 class="min-w-0 text-sm font-semibold text-base-content">
707
+ {params.title}
708
+ </h2>
709
+ <SectionTools {ctx} inline />
710
+ </div>
711
+ {/if}
712
+ <ReviewProgress
713
+ verdicts={VERDICT_OPTIONS}
714
+ {counts}
715
+ {total}
716
+ retest={retestCount}
717
+ label="{params?.title ?? 'Checklist'} progress"
718
+ size="sm"
719
+ />
720
+ <ReviewFilterBar
721
+ {filters}
722
+ bind:active={filterRaw}
723
+ sortOptions={rows.length > 1 ? SORT_OPTIONS : undefined}
724
+ bind:sort={sortByRaw}
725
+ groupOptions={rows.length > 1 ? GROUP_OPTIONS : undefined}
726
+ bind:group={groupByRaw}
727
+ onHelp={() => (helpOpen = true)}
728
+ label="Filter {params?.title ?? 'checklist'}"
729
+ />
730
+ </header>
731
+
732
+ <ul
733
+ bind:this={listEl}
734
+ class="divide-y divide-base-content/10 border-t border-base-content/10"
735
+ aria-label={params?.title ?? "Checklist"}
736
+ >
737
+ {#snippet rowItem(row: ChecklistRow)}
738
+ {@const vStatus = viewerStatus(row)}
739
+ {@const iconName = STATUS_ICON[vStatus]}
740
+ {@const team = row.status ?? "untested"}
741
+ {@const teamIconName = TEAM_ICON[team]}
742
+ {@const retest = teamMode && awaitsRetest(row)}
743
+ {@const history = historyOf(row)}
744
+ {@const roundN = history.length + 1}
745
+ {@const verdict = vStatus === "untested" ? null : vStatus}
746
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
747
+ <li
748
+ class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-2 px-4 py-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
749
+ data-row-id={row.id}
750
+ data-item-id={row.id}
751
+ tabindex="0"
752
+ aria-labelledby="{groupName}-title-{row.id}"
753
+ data-status={vStatus}
754
+ data-awaits-retest={retest ? "" : undefined}
755
+ >
756
+ <span class="mt-0.5 {ICON_TONE[vStatus]}" aria-hidden="true">
757
+ <Icon name={iconName} size={18} strokeWidth={2} />
758
+ </span>
759
+
760
+ <div class="min-w-0">
761
+ {#if row.url}
762
+ {#if isExternal(row.url)}
763
+ <a
764
+ id="{groupName}-title-{row.id}"
765
+ href={row.url}
766
+ target="_blank"
767
+ rel="noopener noreferrer"
768
+ data-row-link
769
+ class="{TITLE_LINK} font-medium text-base-content"
770
+ >{titleHead(row.title)}<span class="whitespace-nowrap"
771
+ >{titleTail(row.title)}<Icon
772
+ name="external-link"
773
+ size={12}
774
+ class="ml-1 inline align-baseline text-base-content/50"
775
+ aria-hidden="true"
776
+ /></span
777
+ ><span class="sr-only"> (opens in a new tab)</span></a
778
+ >
779
+ {:else}
780
+ <a
781
+ id="{groupName}-title-{row.id}"
782
+ href={row.url}
783
+ data-row-link
784
+ class="{TITLE_LINK} font-medium text-base-content"
785
+ >{row.title}</a
786
+ >
787
+ {/if}
788
+ {:else}
789
+ <span
790
+ id="{groupName}-title-{row.id}"
791
+ class="font-medium text-base-content">{row.title}</span
792
+ >
793
+ {/if}
794
+ {#if row.note}
795
+ <p
796
+ class="mt-0.5 max-w-[75ch] text-sm leading-snug whitespace-pre-wrap text-base-content/75"
797
+ >
798
+ {row.note}
799
+ </p>
800
+ {/if}
801
+ </div>
802
+
803
+ <!-- Meta line: owner, then the Team status + Retest chip. -->
804
+ {#if row.owner || teamMode}
805
+ <div
806
+ class="col-start-2 flex flex-wrap items-center gap-x-3 gap-y-1.5"
807
+ >
808
+ {#if row.owner}
809
+ <p class="text-xs text-base-content/70">
810
+ <span class="sr-only">{"Owner: "}</span><span
811
+ class="rounded-full bg-base-content/10 px-2 py-0.5"
812
+ >{row.owner}</span
813
+ >
814
+ </p>
815
+ {/if}
816
+ {#if teamMode}
817
+ <span
818
+ class="inline-flex items-center gap-1 text-xs"
819
+ data-team-status={team}
820
+ title="Team status: what the last publish recorded (update by republishing)"
821
+ >
822
+ <span class="text-base-content/70"
823
+ >Team<span class="sr-only">{": "}</span></span
824
+ >
825
+ <Icon
826
+ name={teamIconName}
827
+ size={16}
828
+ strokeWidth={2.25}
829
+ class={TEAM_ICON_TONE[team]}
830
+ aria-hidden="true"
831
+ />
832
+ <span class="font-medium text-base-content"
833
+ >{TEAM_LABEL[team]}</span
834
+ >
835
+ </span>
836
+ {#if retest}
837
+ <span
838
+ class="badge badge-sm badge-info"
839
+ data-retest-chip={row.id}
840
+ title={row.retestSince
841
+ ? `Fixed since your last verdict (${dateTimeFmt.format(new Date(row.retestSince))}) — test it again`
842
+ : "The team marked this ready — test it again"}>Retest</span
843
+ >
844
+ {/if}
845
+ {/if}
846
+ </div>
847
+ {/if}
848
+
849
+ <!-- Decision line: the current round's verdict (+ note toggle). -->
850
+ <div
851
+ class="col-start-2 flex flex-wrap items-center gap-x-3 gap-y-2"
852
+ data-decision={row.id}
853
+ >
854
+ {#if history.length > 0}
855
+ <span
856
+ class="text-xs font-medium text-base-content/70"
857
+ data-current-round={row.id}
858
+ title={row.retestSince
859
+ ? `Opened when the team marked it ready (${dateTimeFmt.format(new Date(row.retestSince))})`
860
+ : undefined}>Round {roundN}</span
861
+ >
862
+ {/if}
863
+ {#if interactive}
864
+ {#if teamMode}
865
+ <span class="text-xs text-base-content/70" aria-hidden="true"
866
+ >You</span
867
+ >
868
+ {/if}
869
+ <VerdictControl
870
+ options={VERDICT_OPTIONS}
871
+ value={verdict}
872
+ onVerdict={(v) => setStatus(row, v)}
873
+ onUndo={() => undo(row)}
874
+ label="{teamMode ? 'Your verdict' : 'Verdict'} for {row.title}"
875
+ />
876
+ {#if !openNotes[row.id]}
877
+ <button
878
+ type="button"
879
+ class="btn btn-ghost btn-xs gap-1 font-normal text-base-content/70 hover:text-base-content"
880
+ aria-label="Add a note to {row.title}"
881
+ aria-keyshortcuts="n"
882
+ data-add-note={row.id}
883
+ onclick={() => void toggleNote(row)}
884
+ >
885
+ <Icon name="message-square-plus" size={13} aria-hidden="true" />
886
+ Add note
887
+ </button>
888
+ {/if}
889
+ {:else}
890
+ <span class="flex items-center gap-1.5">
891
+ {#if teamMode}
892
+ <span class="text-xs text-base-content/70" aria-hidden="true"
893
+ >You</span
894
+ >
895
+ {/if}
896
+ <span
897
+ class="badge badge-sm {STATUS_BADGE[vStatus]}"
898
+ title={teamMode ? "Your saved status" : "Status"}
899
+ >
900
+ {#if teamMode}<span class="sr-only">{"Your status: "}</span
901
+ >{/if}{LABEL[vStatus]}
902
+ </span>
903
+ </span>
904
+ {/if}
905
+ </div>
906
+
907
+ {#if interactive}
908
+ <ReviewNote
909
+ class="checklist-note col-start-2"
910
+ id={noteInputId(row)}
911
+ label={history.length > 0 ? `Round ${roundN} note` : "Note"}
912
+ placeholder={notePlaceholder(vStatus)}
913
+ bind:open={() => openNotes[row.id] ?? false,
914
+ (v) => (openNotes[row.id] = v)}
915
+ value={currentNote(row)}
916
+ {verdict}
917
+ options={VERDICT_OPTIONS}
918
+ round={rowRound(row)}
919
+ rows={1}
920
+ onInput={(t) => noteInput(row, t)}
921
+ onSave={(t) => noteSave(row, t)}
922
+ />
923
+ {:else if currentNote(row)}
924
+ <p
925
+ class="col-start-2 text-sm whitespace-pre-wrap text-base-content/75"
926
+ >
927
+ Note: {currentNote(row)}
928
+ </p>
929
+ {/if}
930
+
931
+ {#if history.length > 0}
932
+ <div class="col-start-2" data-round-history={row.id}>
933
+ <RoundHistory
934
+ rounds={history}
935
+ verdicts={VERDICT_OPTIONS}
936
+ bind:open={() => historyOpen[row.id] ?? false,
937
+ (v) => (historyOpen[row.id] = v)}
938
+ bind:selected={() => historySelected[row.id] ?? null,
939
+ (v) => (historySelected[row.id] = v)}
940
+ toggleLabel={(n) =>
941
+ `${n} earlier ${n === 1 ? "round" : "rounds"}`}
942
+ />
943
+ </div>
944
+ {/if}
945
+ </li>
946
+ {/snippet}
947
+ {#each groupedRows as g (g.key)}
948
+ {#if groupBy === "none"}
949
+ {#each g.rows as row (row.id)}
950
+ {@render rowItem(row)}
951
+ {/each}
952
+ {:else}
953
+ <!-- Grouped: one header per non-empty group, count included,
954
+ collapsible via `hidden` so hidden rows also leave the tab
955
+ order — keyboard order always matches the visual order. -->
956
+ <li data-group={g.slug}>
957
+ <h3 class="m-0">
958
+ <button
959
+ type="button"
960
+ class="flex w-full items-center gap-2 px-4 py-2 text-xs font-semibold text-base-content/70 hover:bg-base-content/5 hover:text-base-content"
961
+ aria-expanded={!collapsedGroups[g.key]}
962
+ aria-controls="{groupName}-group-{g.slug}"
963
+ data-group-toggle={g.slug}
964
+ onclick={() => toggleGroup(g.key)}
965
+ >
966
+ <Icon
967
+ name="chevron-down"
968
+ size={14}
969
+ class="transition-transform {collapsedGroups[g.key]
970
+ ? '-rotate-90'
971
+ : ''}"
972
+ aria-hidden="true"
973
+ />
974
+ <span>{g.label}</span>
975
+ <span
976
+ class="rounded-full bg-base-content/10 px-1.5 py-0.5 text-2xs font-medium tabular-nums"
977
+ data-group-count={g.slug}
978
+ >
979
+ {g.rows.length}
980
+ </span>
981
+ </button>
982
+ </h3>
983
+ <ul
984
+ class="divide-y divide-base-content/10"
985
+ id="{groupName}-group-{g.slug}"
986
+ hidden={collapsedGroups[g.key]}
987
+ >
988
+ {#each g.rows as row (row.id)}
989
+ {@render rowItem(row)}
990
+ {/each}
991
+ </ul>
992
+ </li>
993
+ {/if}
994
+ {/each}
995
+ </ul>
996
+
997
+ {#if interactive}
998
+ <footer
999
+ class="flex flex-wrap items-center justify-between gap-2 border-t border-base-content/10 px-4 py-2.5"
1000
+ >
1001
+ <p class="text-xs text-base-content/70">
1002
+ {teamMode
1003
+ ? "Your verdicts are private to you. Team is what the last publish recorded."
1004
+ : "Verdicts and notes save privately for you on this page."}
1005
+ <!-- Keyboard hint hidden on touch-first (coarse pointer) devices. -->
1006
+ <span class="whitespace-nowrap [@media(pointer:coarse)]:hidden"
1007
+ >Keys: <kbd class="kbd kbd-xs">a</kbd> pass,
1008
+ <kbd class="kbd kbd-xs">x</kbd> fail,
1009
+ <kbd class="kbd kbd-xs">b</kbd> blocked,
1010
+ <kbd class="kbd kbd-xs">?</kbd> all shortcuts.</span
1011
+ >
1012
+ </p>
1013
+ <p
1014
+ aria-live="polite"
1015
+ class="text-xs tabular-nums {saveError
1016
+ ? 'text-error'
1017
+ : 'text-base-content/70'}"
1018
+ >
1019
+ {savedLine}
1020
+ </p>
1021
+ </footer>
1022
+ {:else if saved?.at}
1023
+ <footer
1024
+ class="border-t border-base-content/10 px-4 py-2.5 text-xs tabular-nums text-base-content/70"
1025
+ >
1026
+ Last updated {dateTimeFmt.format(new Date(saved.at))}
1027
+ </footer>
1028
+ {/if}
1029
+ </section>
1030
+
1031
+ {#if helpOpen}
1032
+ <ReviewShortcuts
1033
+ bind:open={helpOpen}
1034
+ config={keyConfig}
1035
+ title="Checklist shortcuts"
1036
+ footnote={interactive ? SHORTCUTS_FOOTNOTE : undefined}
1037
+ />
1038
+ {/if}
1039
+
1040
+ <style>
1041
+ /* ReviewNote's textarea is DaisyUI .textarea (min-height 5rem): a resting
1042
+ note is ONE line here, grows with its content and scrolls past ~6. */
1043
+ .artifact-checklist :global(.checklist-note textarea) {
1044
+ min-height: 0;
1045
+ max-height: 7.5rem;
1046
+ overflow-y: auto;
1047
+ }
1048
+ </style>