@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,2112 @@
1
+ <script lang="ts">
2
+ /**
3
+ * MediaPickerBlock — the Gallery review archetype (docs/review-ux/
4
+ * proposal.md): a grid of media cards where the asset is primary, each
5
+ * decided Approve / Needs work / Reject with an optional note. Ported from
6
+ * scripts/review-kit/lib/page.mjs behaviour (mos-nj3am, mos-vzame), built
7
+ * on the shared jatui review primitives since mos-lsmve.6 (M2):
8
+ * VerdictControl, ReviewNote, RoundHistory, ReviewProgress,
9
+ * ReviewFilterBar, ReviewShortcuts, reviewKeys() and MediaZoomLightbox.
10
+ *
11
+ * Viewer decisions persist through ctx.saveState as
12
+ * `{ [itemId]: { verdict, variant, note, at, round?, history? } }`
13
+ * (./decisions.ts). The flat fields are the CURRENT round; when saveState
14
+ * is undefined (public link, print, tests) the grid stays interactive but
15
+ * nothing persists. A failed save shows in the block with a Retry.
16
+ *
17
+ * Rounds (Joe D1/D2/D7 = A): a round is the version under review
18
+ * (`currentVersion`), or — when the team marks the item ready — that
19
+ * `team.readySince`. The first save in a new round locks the previous
20
+ * verdict + note into history and starts blank (../../review/rounds.ts);
21
+ * older versions are read-only and their locked line shows in RoundHistory.
22
+ *
23
+ * Campaign-blocks Gate A still holds: the asset is shown WHOLE
24
+ * (object-contain), views are one snapping filmstrip, the filter row is
25
+ * not sticky and scrolls sideways on a phone, columns come from a
26
+ * container query, rejects dim + sink (hover/focus restores them).
27
+ *
28
+ * D5 = A: images AND video open MediaZoomLightbox with the verdict control
29
+ * and note in its footer, and ←/→ (past the last view) or j/k page to the
30
+ * next card — decide without closing.
31
+ *
32
+ * mos-bk1k8 (Joe 2026-10-19: "I have to traverse the longest part of the
33
+ * screen to progress"): in the zoom an approve / pick moves on to the next
34
+ * OPEN card after a short confirm beat (instant under reduced motion), with
35
+ * a "Saved · next" cue and an Undo; Needs work / Reject stay put for the
36
+ * note. Previous / Next / Next open sit beside the verdict row, and the
37
+ * footer says "card 5 of 30 · 4 decided". `kind: "text"` cards drop the
38
+ * zoom chrome and render their words on a themed surface.
39
+ */
40
+ import MediaZoomLightbox from "../../../components/review/MediaZoomLightbox.svelte"
41
+ import type {
42
+ MediaZoomFooterContext,
43
+ MediaZoomView,
44
+ } from "../../../components/review/mediaZoom"
45
+ import ReviewFilterBar from "../../../components/review/ReviewFilterBar.svelte"
46
+ import ReviewProgress from "../../../components/review/ReviewProgress.svelte"
47
+ import ReviewShortcuts from "../../../components/review/ReviewShortcuts.svelte"
48
+ import RoundHistory from "../../../components/review/RoundHistory.svelte"
49
+ import VerdictControl from "../../../components/review/VerdictControl.svelte"
50
+ import { buildReviewFilters } from "../../../components/review/reviewSummary"
51
+ import { isTypingTarget } from "../../../utils/keyboardShortcutRegistry"
52
+ import {
53
+ reviewKeys,
54
+ type NeedsYou,
55
+ type ReviewKeyRow,
56
+ type ReviewKeysOptions,
57
+ type VerdictOption,
58
+ } from "../../../actions/reviewKeys"
59
+ import { showConfirm } from "../../../stores/confirmDialog"
60
+ import ItemNote from "../../review/ItemNote.svelte"
61
+ import type { NoteAttachment } from "../../review/attachments.js"
62
+ import { onDestroy, tick, untrack } from "svelte"
63
+ import type { BlockProps } from "../../types.js"
64
+ import BulkRejectForm from "../../review/BulkRejectForm.svelte"
65
+ import BulkUndoBar from "../../review/BulkUndoBar.svelte"
66
+ import { decideMany, undoMany, type BulkUndo } from "../../review/bulk.js"
67
+ import NoteToggle from "../../review/NoteToggle.svelte"
68
+ import TeamChip from "../../review/TeamChip.svelte"
69
+ import { looksLikeMarkdown, renderBlockMarkdown } from "../markdown/render.js"
70
+ import { historyRounds } from "../../review/roundLines.js"
71
+ import type { RoundView } from "../../review/rounds.js"
72
+ import { createStateSaver } from "../../review/saver.js"
73
+ import { teamRound } from "../../review/team.js"
74
+ import {
75
+ isTextTarget,
76
+ sortRanks,
77
+ visualItems,
78
+ } from "../../review/visualOrder.js"
79
+ import {
80
+ isEmptyDecision,
81
+ mediaRounds,
82
+ parseDecisions,
83
+ type MediaDecision,
84
+ type MediaVerdict,
85
+ } from "./decisions.js"
86
+ import { choiceLabelOf, choicesOf, isPick } from "./choices.js"
87
+ import type { MediaItem, MediaPickerParams, MediaVariant } from "./types.js"
88
+ import {
89
+ captionFor,
90
+ currentVersionOf,
91
+ historyLine,
92
+ versionOfVariant,
93
+ versionsOf,
94
+ viewLabel,
95
+ viewsOf,
96
+ } from "./versions.js"
97
+ import SectionTools from "../../SectionTools.svelte"
98
+
99
+ let { params, ctx }: BlockProps<MediaPickerParams> = $props()
100
+
101
+ /**
102
+ * The intro renders through the shared sanitized markdown renderer when it
103
+ * carries obvious markdown syntax (mos-8vdg9): paragraphs, lists, bold,
104
+ * links. Plain-text intros render unchanged — whitespace and line breaks
105
+ * exactly as authored (the sniff is conservative on purpose; see
106
+ * looksLikeMarkdown).
107
+ */
108
+ const introIsMarkdown = $derived(
109
+ Boolean(params.intro && looksLikeMarkdown(params.intro)),
110
+ )
111
+ const introHtml = $derived(
112
+ introIsMarkdown ? renderBlockMarkdown(params.intro) : null,
113
+ )
114
+
115
+ const VERDICTS: VerdictOption[] = [
116
+ { id: "approve", label: "Approve", tone: "success", key: "a" },
117
+ {
118
+ id: "redo",
119
+ label: "Needs work",
120
+ tone: "warning",
121
+ key: "x",
122
+ opensNote: true,
123
+ },
124
+ { id: "reject", label: "Reject", tone: "error", key: "r", opensNote: true },
125
+ ]
126
+ const VERDICT_WORD: Record<MediaVerdict, string> = {
127
+ approve: "approved",
128
+ redo: "needs work",
129
+ reject: "rejected",
130
+ }
131
+
132
+ /** Stored entries — seeded ONCE from the viewer's saved state (the host
133
+ * passes state at render, never streams it). */
134
+ const initialDecisions = untrack(() => parseDecisions(ctx.state))
135
+ let decisions = $state<Record<string, MediaDecision>>(initialDecisions)
136
+ /**
137
+ * mos-guqgs: deciding requires BOTH a session-free read-only flag AND a
138
+ * save channel. `ctx.readonly` alone was the old gate — which made the
139
+ * admin preview pane (readonly=false, saveState=undefined) look fully
140
+ * answerable while every answer evaporated. Same contract as checklist /
141
+ * compare / callout / actions: no saveState, no live controls.
142
+ */
143
+ const canDecide = $derived(Boolean(ctx.saveState) && !ctx.readonly)
144
+ const saver = createStateSaver(() => ctx.saveState)
145
+
146
+ /** The round an item is in: the team's readySince when it marked the item
147
+ * ready, else the version under review (null = no rounds). */
148
+ function roundKeyOf(item: MediaItem): string | null {
149
+ return teamRound(item.team) ?? currentVersionOf(item)
150
+ }
151
+
152
+ /** Each item's current round + locked history (pure read, no write). */
153
+ const views = $derived.by(() => {
154
+ const out: Record<string, RoundView> = {}
155
+ for (const item of params.items)
156
+ out[item.id] = mediaRounds.view(
157
+ decisions[item.id],
158
+ roundKeyOf(item),
159
+ item.history,
160
+ )
161
+ return out
162
+ })
163
+
164
+ function currentOf(id: string): MediaDecision {
165
+ return (views[id]?.current ?? {}) as MediaDecision
166
+ }
167
+ function verdictOf(id: string): MediaVerdict | null {
168
+ return currentOf(id).verdict ?? null
169
+ }
170
+
171
+ /** Selected view per item. A re-opened review lands on the pick the viewer
172
+ * approved this round (review-kit behaviour), else the first view of the
173
+ * version under review. */
174
+ const initialSelected: Record<string, string> = {}
175
+ for (const item of untrack(() => params.items)) {
176
+ const v = mediaRounds.view(
177
+ initialDecisions[item.id],
178
+ roundKeyOf(item),
179
+ ).current
180
+ const pre = typeof v.variant === "string" ? v.variant : null
181
+ initialSelected[item.id] = item.variants.some((x) => x.id === pre)
182
+ ? (pre as string)
183
+ : (viewsOf(item, currentVersionOf(item))[0] ?? item.variants[0]).id
184
+ }
185
+ let selected = $state<Record<string, string>>(initialSelected)
186
+ let filter = $state("all")
187
+ let notesOpen = $state<Record<string, boolean>>({})
188
+ let helpOpen = $state(false)
189
+ let rootEl = $state<HTMLElement | null>(null)
190
+ /** Zoom view: the item shown and its view index. */
191
+ let zoomItem = $state<string | null>(null)
192
+ let zoomIndex = $state(0)
193
+
194
+ const itemsById = $derived(
195
+ new Map<string, MediaItem>(params.items.map((i) => [i.id, i])),
196
+ )
197
+
198
+ /** Groups in display order; unlisted/ungrouped items fall into an
199
+ * implicit section — nothing published is ever hidden from the human. */
200
+ const sections = $derived.by(() => {
201
+ const named = (params.groups ?? [])
202
+ .map((g) => ({
203
+ id: g.id,
204
+ title: g.title,
205
+ items: params.items.filter((it) => it.group === g.id),
206
+ }))
207
+ .filter((g) => g.items.length > 0)
208
+ const listedIds = new Set(named.flatMap((g) => g.items.map((i) => i.id)))
209
+ const rest = params.items.filter((it) => !listedIds.has(it.id))
210
+ const other =
211
+ rest.length > 0
212
+ ? [
213
+ {
214
+ id: "__other__",
215
+ title: named.length > 0 ? "Other" : "",
216
+ items: rest,
217
+ },
218
+ ]
219
+ : []
220
+ return [...named, ...other]
221
+ })
222
+
223
+ /** A new round is open and this viewer has not decided it. Informational
224
+ * items ask no decision — never a retest, never "needs you". */
225
+ function isRetest(item: MediaItem): boolean {
226
+ if (item.informational || verdictOf(item.id)) return false
227
+ return item.team?.status === "ready" || !!views[item.id]?.stale
228
+ }
229
+
230
+ const counts = $derived.by(() => {
231
+ const byVerdict: Record<string, number> = {}
232
+ let retest = 0
233
+ // Informational items are not units — the census and the progress
234
+ // sentence count only items a decision is asked on (mos-lwbbt).
235
+ const units = params.items.filter((it) => !it.informational)
236
+ for (const item of units) {
237
+ const v = verdictOf(item.id)
238
+ if (v) byVerdict[v] = (byVerdict[v] ?? 0) + 1
239
+ else if (isRetest(item)) retest++
240
+ }
241
+ return { byVerdict, retest, total: units.length }
242
+ })
243
+
244
+ /** Chips read as states ("Approved 40 / Rejected 12", mos-u8i35), so the
245
+ * reviewer scans one group and flips the outliers. */
246
+ const FILTER_VERDICTS: VerdictOption[] = VERDICTS.map((o) => ({
247
+ ...o,
248
+ label:
249
+ o.id === "approve"
250
+ ? "Approved"
251
+ : o.id === "reject"
252
+ ? "Rejected"
253
+ : o.label,
254
+ }))
255
+
256
+ const filters = $derived(
257
+ buildReviewFilters({
258
+ verdicts: FILTER_VERDICTS,
259
+ counts: counts.byVerdict,
260
+ total: counts.total,
261
+ retest: counts.retest,
262
+ }),
263
+ )
264
+
265
+ function visible(id: string): boolean {
266
+ if (filter === "all") return true
267
+ const item = itemsById.get(id)
268
+ if (item?.informational) return false // context is never a filter bucket
269
+ const v = verdictOf(id)
270
+ if (filter === "undecided") return !v
271
+ if (filter === "retest") {
272
+ return !!item && isRetest(item)
273
+ }
274
+ return v === filter
275
+ }
276
+
277
+ /** Rejects sink to the end of their section — from a frozen snapshot
278
+ * (mos-lsmve.12, ../../review/visualOrder) re-taken only on a filter
279
+ * change, so j from a just-rejected card walks the order on screen. */
280
+ const SINK = { reject: 1 } as const
281
+ function snapshotRanks() {
282
+ return sortRanks(
283
+ params.items.map((it) => it.id),
284
+ verdictOf,
285
+ SINK,
286
+ )
287
+ }
288
+ let ranks = $state<Record<string, number>>(untrack(snapshotRanks))
289
+ $effect(() => {
290
+ void filter
291
+ untrack(() => (ranks = snapshotRanks()))
292
+ })
293
+ function sinkOrder(id: string): number {
294
+ return ranks[id] ?? 0
295
+ }
296
+
297
+ function needsYou(id: string): NeedsYou {
298
+ const item = itemsById.get(id)
299
+ if (!item || verdictOf(id) || item.informational) return false
300
+ return isRetest(item) ? "retest" : "undecided"
301
+ }
302
+
303
+ function selectedVariantOf(item: MediaItem): MediaVariant {
304
+ return (
305
+ item.variants.find((v) => v.id === selected[item.id]) ?? item.variants[0]
306
+ )
307
+ }
308
+
309
+ /** The variants a choice item offers Pick buttons for: every view of the
310
+ * version under review (never a choice item — its options are named
311
+ * choices over one image, and never an informational item — context asks
312
+ * no decision, whatever flags rode along with it). */
313
+ function pickableViews(item: MediaItem): MediaVariant[] {
314
+ if (!isPick(item) || item.informational || choicesOf(item).length > 0)
315
+ return []
316
+ return viewsOf(item, currentVersionOf(item))
317
+ }
318
+
319
+ /** Is this variant the one the round's approve is pinned to? */
320
+ function isPickedVariant(item: MediaItem, variantId: string): boolean {
321
+ const cur = currentOf(item.id)
322
+ return cur.verdict === "approve" && cur.variant === variantId
323
+ }
324
+
325
+ /** Human label for a pinned pick id: a named choice's label first, else
326
+ * the variant's. */
327
+ function pickLabel(item: MediaItem, id: string | null | undefined): string {
328
+ if (!id) return ""
329
+ return choiceLabelOf(item, id) ?? variantLabel(item, id)
330
+ }
331
+
332
+ /** Does the Approve button NAME the view on screen? Real choices and
333
+ * evidence cards never do (Joe 2026-10-14: an evidence card's images are
334
+ * EXAMPLES of the problem — the verdict approves the card, no image is
335
+ * named). */
336
+ function namesVariant(item: MediaItem): boolean {
337
+ if (!canDecide || item.evidence) return false
338
+ if (choicesOf(item).length > 0 || pickableViews(item).length > 1)
339
+ return false
340
+ return item.variants.length > 1
341
+ }
342
+
343
+ /** The verdict the control should SHOW as active. An approval is pinned to
344
+ * its variant: while the reviewer browses a different one, no radio reads
345
+ * active — the badge names what was approved, and pressing the approve
346
+ * button (which names the view on screen) moves the pin deliberately.
347
+ * mos-yntqm still holds: a repeat press of the SHOWN active verdict never
348
+ * re-sends, so it can never clear. */
349
+ function approveVisibleValue(item: MediaItem): MediaVerdict | null {
350
+ const cur = currentOf(item.id)
351
+ if (
352
+ cur.verdict === "approve" &&
353
+ cur.variant != null &&
354
+ cur.variant !== selected[item.id]
355
+ )
356
+ return null
357
+ return cur.verdict ?? null
358
+ }
359
+
360
+ /** The verdict row's options for one item. When the item offers more than
361
+ * one variant, Approve names the one the reviewer is looking at — "Approve
362
+ * Bamboo", never a bare Approve (Joe 2026-10-14: "where there are two
363
+ * options how am I picking it") — unless the item is an EVIDENCE card,
364
+ * whose images are examples, not choices: the Approve answers the card,
365
+ * so it stays plain. A choice item has no single Approve at all: its Pick
366
+ * buttons ARE the approve. Read-only surfaces show the full vocabulary,
367
+ * disabled (mos-guqgs: the answer must stay visible). */
368
+ function verdictOptions(item: MediaItem): VerdictOption[] {
369
+ if (!canDecide) return VERDICTS
370
+ if (choicesOf(item).length > 0 || pickableViews(item).length > 1)
371
+ return VERDICTS.filter((o) => o.id !== "approve")
372
+ if (namesVariant(item)) {
373
+ const label = viewLabel(item, selectedVariantOf(item))
374
+ return VERDICTS.map((o) =>
375
+ o.id === "approve" ? { ...o, label: `Approve ${label}` } : o,
376
+ )
377
+ }
378
+ return VERDICTS
379
+ }
380
+
381
+ /** The FULL option labels for the verdict buttons' `title` tooltips — a
382
+ * long approve name truncates with ellipsis (CSS) and the hover says the
383
+ * whole name (Joe 2026-10-14: never hide an option, never lose its name). */
384
+ function verdictFullLabels(item: MediaItem): Record<string, string> {
385
+ const out: Record<string, string> = {}
386
+ for (const o of VERDICTS) out[o.id] = o.label
387
+ if (namesVariant(item))
388
+ out.approve = `Approve ${viewLabel(item, selectedVariantOf(item))}`
389
+ return out
390
+ }
391
+
392
+ /** Stamp the full label as a `title` on each verdict button under `node`.
393
+ * Re-runs whenever the labels change (the approve name follows the view
394
+ * on screen). Deferred once so the child's buttons exist to stamp. */
395
+ function verdictTooltips(node: HTMLElement, labels: Record<string, string>) {
396
+ let latest = labels
397
+ const stamp = () => {
398
+ for (const b of node.querySelectorAll<HTMLButtonElement>(
399
+ "[data-verdict]",
400
+ )) {
401
+ const full = latest[b.dataset.verdict ?? ""]
402
+ if (full) b.setAttribute("title", full)
403
+ else b.removeAttribute("title")
404
+ }
405
+ }
406
+ queueMicrotask(stamp)
407
+ return {
408
+ update(next: Record<string, string>) {
409
+ latest = next
410
+ stamp()
411
+ },
412
+ }
413
+ }
414
+
415
+ /** The version the card is showing (null for an item without versions). */
416
+ function activeVersion(item: MediaItem): string | null {
417
+ if (versionsOf(item).length === 0) return null
418
+ return versionOfVariant(item, selectedVariantOf(item))
419
+ }
420
+
421
+ /** Is the card showing an OLDER version (read-only, no verdicts)? */
422
+ function viewingPrevious(item: MediaItem): boolean {
423
+ const av = activeVersion(item)
424
+ return av != null && av !== currentVersionOf(item)
425
+ }
426
+
427
+ function switchVersion(id: string, version: string) {
428
+ const item = itemsById.get(id)
429
+ if (!item) return
430
+ const first = viewsOf(item, version)[0]
431
+ if (first) selectVariant(id, first.id)
432
+ }
433
+
434
+ /** `[` (delta -1) older / `]` (+1) newer. The row lists the version under
435
+ * review first, then the publisher's order (newest first). */
436
+ function stepVersion(id: string, delta: -1 | 1) {
437
+ const item = itemsById.get(id)
438
+ if (!item) return
439
+ const versions = versionsOf(item)
440
+ if (versions.length === 0) return
441
+ const at = Math.max(0, versions.indexOf(activeVersion(item) ?? ""))
442
+ const next = Math.min(versions.length - 1, Math.max(0, at - delta))
443
+ if (next !== at) switchVersion(id, versions[next])
444
+ }
445
+
446
+ /** 1-9: pick a view of the version the card is showing. */
447
+ function pickView(id: string, index: number) {
448
+ const item = itemsById.get(id)
449
+ const v = item ? viewsOf(item, activeVersion(item))[index] : undefined
450
+ if (v) selectVariant(id, v.id)
451
+ }
452
+
453
+ // --- saving -----------------------------------------------------------------
454
+
455
+ /** Apply one change to an item's stored entry under its current round
456
+ * (the first save in a new round locks the old one into history). Local
457
+ * only — `flush()` persists. */
458
+ function update(item: MediaItem, patch: Record<string, unknown>) {
459
+ const next = mediaRounds.save(
460
+ decisions[item.id],
461
+ { ...patch, at: new Date().toISOString() },
462
+ roundKeyOf(item),
463
+ ) as MediaDecision
464
+ const updated = { ...decisions }
465
+ if (isEmptyDecision(next)) delete updated[item.id]
466
+ else updated[item.id] = next
467
+ decisions = updated
468
+ }
469
+
470
+ function flush(): Promise<void> {
471
+ return saver.save($state.snapshot(decisions) as Record<string, unknown>)
472
+ }
473
+
474
+ function persist(item: MediaItem, patch: Record<string, unknown>) {
475
+ update(item, patch)
476
+ // A failure shows as the block's alert (saver.failed), not a throw.
477
+ flush().catch(() => {})
478
+ }
479
+
480
+ /** A verdict save that may move the zoom on (mos-bk1k8). `advances` is
481
+ * true for an approve or a pick — a Needs work / Reject keeps the card so
482
+ * the reviewer can write the note. The entry BEFORE the save is kept for
483
+ * Undo, which restores it exactly (round history included). */
484
+ function decide(
485
+ item: MediaItem,
486
+ patch: Record<string, unknown>,
487
+ advances: boolean,
488
+ ) {
489
+ const before = decisions[item.id]
490
+ ? ($state.snapshot(decisions[item.id]) as MediaDecision)
491
+ : undefined
492
+ cancelAdvance()
493
+ persist(item, patch)
494
+ if (zoomItem !== item.id) return
495
+ lastPick = advances ? { id: item.id, title: item.title, before } : null
496
+ if (advances) scheduleAdvance(item.id)
497
+ }
498
+
499
+ /**
500
+ * a / x / r, or a verdict button. VerdictControl never re-sends the active
501
+ * verdict (mos-yntqm: a repeat press used to CLEAR an approve); a repeat
502
+ * KEY on a note-worthy verdict re-opens the note instead. A new Needs work
503
+ * / Reject opens + focuses the note itself (ReviewNote, mos-calzz).
504
+ * A named-choice item has no single Approve — `a` must not silently pin
505
+ * the comparison sheet as the answer; the choice buttons pick.
506
+ */
507
+ function setVerdict(id: string, verdict: MediaVerdict) {
508
+ const item = itemsById.get(id)
509
+ if (!item || !canDecide || viewingPrevious(item) || item.informational)
510
+ return
511
+ if (verdict === "approve" && choicesOf(item).length > 0) return
512
+ if (verdictOf(id) === verdict) {
513
+ // Approving again with a DIFFERENT view selected moves the pin to the
514
+ // view on screen (the button names it); the SAME view is a no-op
515
+ // (mos-yntqm: a repeat press must never clear or churn). An evidence
516
+ // card pins no variant — a repeat approve is a plain no-op.
517
+ if (
518
+ verdict === "approve" &&
519
+ !item.evidence &&
520
+ currentOf(id).variant !== selected[id]
521
+ ) {
522
+ decide(item, { verdict, variant: selected[id] }, true)
523
+ return
524
+ }
525
+ if (VERDICTS.find((o) => o.id === verdict)?.opensNote) openNote(id)
526
+ return
527
+ }
528
+ // An evidence card's approve answers the CARD (the images are examples,
529
+ // not choices) — it saves no variant (mos-9d62g).
530
+ decide(
531
+ item,
532
+ verdict === "approve" && !item.evidence
533
+ ? { verdict, variant: selected[id] }
534
+ : { verdict },
535
+ verdict === "approve",
536
+ )
537
+ }
538
+
539
+ /** u, or Undo: clear this round's verdict deliberately. The note stays (a
540
+ * remark is not a verdict); the pinned variant goes with the approve. */
541
+ function undoVerdict(id: string) {
542
+ const item = itemsById.get(id)
543
+ if (
544
+ !item ||
545
+ !canDecide ||
546
+ !verdictOf(id) ||
547
+ viewingPrevious(item) ||
548
+ item.informational
549
+ )
550
+ return
551
+ persist(item, { verdict: undefined, variant: undefined })
552
+ }
553
+
554
+ /** Pick one variant of a choice item: approve + THAT variant in one save —
555
+ * the action itself names what it approves (mos-lwbbt). Shows the pick. */
556
+ function pickVariant(id: string, variantId: string) {
557
+ const item = itemsById.get(id)
558
+ if (!item || !canDecide || viewingPrevious(item) || item.informational)
559
+ return
560
+ if (isPickedVariant(item, variantId)) return
561
+ selectVariant(id, variantId)
562
+ decide(item, { verdict: "approve", variant: variantId }, true)
563
+ }
564
+
565
+ /** Pick a named choice (the "A vs B" comparison sheet): the choice id is
566
+ * saved in `variant`, the same field every approve pins. */
567
+ function pickChoice(id: string, cid: string) {
568
+ const item = itemsById.get(id)
569
+ if (!item || !canDecide || viewingPrevious(item) || item.informational)
570
+ return
571
+ if (isPickedVariant(item, cid)) return
572
+ decide(item, { verdict: "approve", variant: cid }, true)
573
+ }
574
+
575
+ /** The items a bulk may decide: undecided, showing the version under
576
+ * review, shown under the active filter chip, and not a choice (a pick
577
+ * must be deliberate) or a retest (a reopened item deserves eyes).
578
+ * Informational items ask no decision. */
579
+ function bulkApprovable(item: MediaItem): boolean {
580
+ if (
581
+ item.informational ||
582
+ isPick(item) ||
583
+ isRetest(item) ||
584
+ verdictOf(item.id) ||
585
+ viewingPrevious(item) ||
586
+ !visible(item.id)
587
+ )
588
+ return false
589
+ return true
590
+ }
591
+
592
+ function approvableCount(items: MediaItem[]): number {
593
+ return items.filter((it) => bulkApprovable(it)).length
594
+ }
595
+
596
+ function itemCount(n: number): string {
597
+ return n === 1 ? "1 item" : `${n} items`
598
+ }
599
+
600
+ /** The last bulk, for its Undo (cleared by Undo, dismiss or the next bulk). */
601
+ let lastBulk = $state<{
602
+ undo: BulkUndo<MediaDecision>
603
+ count: number
604
+ } | null>(null)
605
+ /** The open bulk-reject form (mos-u8i35): the items it will reject and
606
+ * the control that opened it (focus returns there on Cancel). */
607
+ let rejectAsk = $state<{ ids: string[]; opener: HTMLElement | null } | null>(
608
+ null,
609
+ )
610
+
611
+ /** Decide every eligible item in one go — ONE save, not N. An approve
612
+ * carries each item's currently shown variant, exactly like pressing the
613
+ * (variant-named) Approve button on each card — except on evidence cards,
614
+ * where the approve answers the card and saves no variant (mos-9d62g). A
615
+ * reject carries the one shared note the form asked for. */
616
+ function decideAll(
617
+ items: MediaItem[],
618
+ verdict: "approve" | "reject",
619
+ note?: string,
620
+ ) {
621
+ if (!canDecide) return
622
+ const targets = items.filter((it) => bulkApprovable(it))
623
+ if (targets.length === 0) return
624
+ const { decisions: next, undo } = decideMany(
625
+ decisions,
626
+ targets.map((it) => ({
627
+ id: it.id,
628
+ round: roundKeyOf(it),
629
+ patch:
630
+ verdict === "approve" && !it.evidence
631
+ ? { variant: selected[it.id] }
632
+ : undefined,
633
+ })),
634
+ { verdict, note, at: new Date().toISOString(), save: mediaRounds.save },
635
+ )
636
+ decisions = next as Record<string, MediaDecision>
637
+ lastBulk = { undo, count: targets.length }
638
+ flush().catch(() => {})
639
+ }
640
+
641
+ /** A section's Approve all: one click, Undo-able. */
642
+ function approveAll(items: MediaItem[]) {
643
+ decideAll(items, "approve")
644
+ }
645
+
646
+ /** The page-level Approve all remaining asks first (mos-u8i35). */
647
+ async function confirmApproveAll() {
648
+ const ids = params.items.filter(bulkApprovable).map((it) => it.id)
649
+ if (!canDecide || ids.length === 0) return
650
+ const ok = await showConfirm({
651
+ title: `Approve ${itemCount(ids.length)}?`,
652
+ body: "You can still change any item after. Items you already decided stay as they are.",
653
+ confirmLabel: "Approve",
654
+ cancelLabel: "Cancel",
655
+ danger: false,
656
+ timeoutMs: 0,
657
+ })
658
+ // Re-check after the dialog: an item decided meanwhile is not overwritten.
659
+ if (ok)
660
+ decideAll(
661
+ params.items.filter((it) => ids.includes(it.id)),
662
+ "approve",
663
+ )
664
+ }
665
+
666
+ function askReject(items: MediaItem[]) {
667
+ const ids = items.filter(bulkApprovable).map((it) => it.id)
668
+ if (!canDecide || ids.length === 0) return
669
+ const active = document.activeElement
670
+ rejectAsk = {
671
+ ids,
672
+ opener: active instanceof HTMLElement ? active : null,
673
+ }
674
+ }
675
+
676
+ const rejectCount = $derived(
677
+ rejectAsk
678
+ ? params.items.filter(
679
+ (it) => rejectAsk!.ids.includes(it.id) && bulkApprovable(it),
680
+ ).length
681
+ : 0,
682
+ )
683
+
684
+ function confirmReject(note: string) {
685
+ if (!rejectAsk) return
686
+ const ids = rejectAsk.ids
687
+ rejectAsk = null
688
+ decideAll(
689
+ params.items.filter((it) => ids.includes(it.id)),
690
+ "reject",
691
+ note,
692
+ )
693
+ tick().then(() =>
694
+ visualItems(rootEl)
695
+ .find((el) => !el.hidden)
696
+ ?.focus(),
697
+ )
698
+ }
699
+
700
+ function cancelReject() {
701
+ const opener = rejectAsk?.opener
702
+ rejectAsk = null
703
+ tick().then(() => opener?.isConnected && opener.focus())
704
+ }
705
+
706
+ function undoBulk() {
707
+ if (!lastBulk || !canDecide) return
708
+ decisions = undoMany(decisions, lastBulk.undo)
709
+ lastBulk = null
710
+ flush().catch(() => {})
711
+ }
712
+
713
+ /** Shift+A / Shift+R on the block: approve / reject all remaining (both
714
+ * ask first). Never while typing, never with Ctrl / Meta / Alt. */
715
+ function onBulkKey(e: KeyboardEvent) {
716
+ if (!e.shiftKey || (e.key !== "A" && e.key !== "R")) return
717
+ if (e.ctrlKey || e.metaKey || e.altKey) return
718
+ if (e.defaultPrevented || e.isComposing || isTypingTarget(e.target)) return
719
+ if (!canDecide || approvableCount(params.items) === 0) return
720
+ e.preventDefault()
721
+ if (e.key === "A") void confirmApproveAll()
722
+ else askReject(params.items)
723
+ }
724
+
725
+ $effect(() => {
726
+ const el = rootEl
727
+ if (!el) return
728
+ el.addEventListener("keydown", onBulkKey)
729
+ return () => el.removeEventListener("keydown", onBulkKey)
730
+ })
731
+
732
+ function onNoteInput(id: string, text: string) {
733
+ const item = itemsById.get(id)
734
+ if (item) update(item, { note: text || undefined })
735
+ }
736
+
737
+ /** The note's uploaded clips (mos-rccb5): each upload adds its descriptor
738
+ * to the item's saved entry — the bytes are already in the artifact's
739
+ * private asset store by the time this fires. */
740
+ function onItemClips(id: string, clips: NoteAttachment[]) {
741
+ const item = itemsById.get(id)
742
+ if (item) persist(item, { attachments: clips })
743
+ }
744
+
745
+ /** ReviewNote's debounced save: throws so the note shows "Not saved". */
746
+ const noteSave = $derived(ctx.saveState ? () => flush() : undefined)
747
+
748
+ function noteInputId(id: string): string {
749
+ return zoomItem === id ? `zoomnote-${id}` : `noteinput-${id}`
750
+ }
751
+
752
+ /** Reveal the item's note and put the caret in it. */
753
+ function openNote(id: string) {
754
+ cancelAdvance()
755
+ notesOpen = { ...notesOpen, [id]: true }
756
+ tick().then(() => {
757
+ document
758
+ .getElementById(noteInputId(id))
759
+ ?.focus({ preventScroll: zoomItem === id })
760
+ })
761
+ }
762
+
763
+ function toggleNote(id: string) {
764
+ const item = itemsById.get(id)
765
+ if (!item || !canDecide || viewingPrevious(item)) return
766
+ if (notesOpen[id]) notesOpen = { ...notesOpen, [id]: false }
767
+ else openNote(id)
768
+ }
769
+
770
+ function selectVariant(id: string, variantId: string) {
771
+ const item = itemsById.get(id)
772
+ if (!item?.variants.some((v) => v.id === variantId)) return
773
+ selected = { ...selected, [id]: variantId }
774
+ // An approval is STICKY (mos-lwbbt): browsing another variant never
775
+ // moves it — the badge keeps naming what was approved and the Approve
776
+ // button, which always names the view you are on now, is how you move
777
+ // it deliberately. (It used to re-point silently on every switch.)
778
+ }
779
+
780
+ /** RoundHistory's selected line per item (a round that is not a version
781
+ * the card can show — a retest round, or the version under review). */
782
+ let historySel = $state<Record<string, string | null>>({})
783
+
784
+ /** RoundHistory ↔ version switcher: the line of the version the card
785
+ * shows is selected; picking an older version's line shows that version,
786
+ * anything else (or deselecting) returns to the one under review. */
787
+ function selectedRound(
788
+ item: MediaItem,
789
+ rounds: { round: string }[],
790
+ ): string | null {
791
+ const shown = activeVersion(item)
792
+ if (viewingPrevious(item))
793
+ return rounds.some((r) => r.round === shown) ? shown : null
794
+ const sel = historySel[item.id] ?? null
795
+ return sel && (!versionsOf(item).includes(sel) || sel === shown)
796
+ ? sel
797
+ : null
798
+ }
799
+
800
+ function pickRound(item: MediaItem, round: string | null) {
801
+ historySel = { ...historySel, [item.id]: round }
802
+ const cur = currentVersionOf(item)
803
+ if (round && round !== cur && versionsOf(item).includes(round))
804
+ switchVersion(item.id, round)
805
+ else if (viewingPrevious(item) && cur) switchVersion(item.id, cur)
806
+ }
807
+
808
+ // --- keyboard (jatui reviewKeys: one key table, help rendered from it) -----
809
+
810
+ const keysConfig: ReviewKeysOptions = $derived({
811
+ verdicts: VERDICTS,
812
+ onVerdict: canDecide
813
+ ? (id: string, v: string) => setVerdict(id, v as MediaVerdict)
814
+ : undefined,
815
+ onUndo: canDecide ? undoVerdict : undefined,
816
+ onNote: canDecide ? toggleNote : undefined,
817
+ onOpen: (id: string) => openZoom(id),
818
+ onRound: (id: string, delta: -1 | 1) => stepVersion(id, delta),
819
+ onView: pickView,
820
+ needsYou: canDecide ? needsYou : undefined,
821
+ onHelp: () => (helpOpen = true),
822
+ getItems: () => visualItems(rootEl),
823
+ global: false,
824
+ })
825
+
826
+ const BULK_HELP: ReviewKeyRow[] = [
827
+ {
828
+ keys: ["Shift", "A"],
829
+ description: "Approve all remaining (asks first)",
830
+ group: "Decide",
831
+ },
832
+ {
833
+ keys: ["Shift", "R"],
834
+ description: "Reject all remaining (asks for one note)",
835
+ group: "Decide",
836
+ },
837
+ ]
838
+
839
+ const EXTRA_HELP: ReviewKeyRow[] = [
840
+ {
841
+ keys: ["←", "→"],
842
+ description: "Full size: page views, then the next card",
843
+ group: "Item",
844
+ },
845
+ {
846
+ keys: [",", "."],
847
+ description: "Full size: step a video one frame",
848
+ group: "Item",
849
+ },
850
+ {
851
+ keys: ["+", "−", "0"],
852
+ description: "Full size: zoom in, out, reset",
853
+ group: "Item",
854
+ },
855
+ ]
856
+
857
+ // --- zoom view (MediaZoomLightbox, images and video) ----------------------
858
+
859
+ /** The views the zoom pages through: the shown version's image/video. */
860
+ function zoomVariants(id: string): MediaVariant[] {
861
+ const item = itemsById.get(id)
862
+ if (!item) return []
863
+ return viewsOf(item, activeVersion(item)).filter(
864
+ (v) => v.kind === "image" || v.kind === "video",
865
+ )
866
+ }
867
+
868
+ /** Full-size label: "v6 · Loop" on a versioned item, else the view label. */
869
+ function fullLabel(item: MediaItem, v: MediaVariant): string {
870
+ const label = viewLabel(item, v)
871
+ const ver = versionsOf(item).length > 0 ? versionOfVariant(item, v) : null
872
+ return ver ? `${ver} · ${label}` : label
873
+ }
874
+
875
+ function zoomViews(id: string): MediaZoomView[] {
876
+ const item = itemsById.get(id)
877
+ if (!item) return []
878
+ return zoomVariants(id).map((v) => ({
879
+ src: v.src,
880
+ kind: v.kind as "image" | "video",
881
+ label: fullLabel(item, v),
882
+ poster: v.poster,
883
+ alt: mediaAlt(item, fullLabel(item, v)),
884
+ }))
885
+ }
886
+
887
+ /** Cards the zoom view pages through: visible, with something to zoom, in
888
+ * the grid's visual order (sections in turn, rejects last in each). */
889
+ const zoomOrder = $derived(
890
+ sections.flatMap((s) =>
891
+ s.items
892
+ .filter((it) => visible(it.id) && zoomVariants(it.id).length > 0)
893
+ .sort((a, b) => sinkOrder(a.id) - sinkOrder(b.id))
894
+ .map((it) => it.id),
895
+ ),
896
+ )
897
+
898
+ function openZoom(id: string) {
899
+ const views = zoomVariants(id)
900
+ if (views.length === 0) return
901
+ zoomIndex = Math.max(
902
+ 0,
903
+ views.findIndex((v) => v.id === selected[id]),
904
+ )
905
+ zoomItem = id
906
+ }
907
+
908
+ function closeZoom() {
909
+ const id = zoomItem
910
+ cancelAdvance()
911
+ lastPick = null
912
+ zoomItem = null
913
+ // Focus the card the reviewer ended on (they may have paged away).
914
+ if (id)
915
+ tick().then(() =>
916
+ rootEl
917
+ ?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(id)}"]`)
918
+ ?.focus(),
919
+ )
920
+ }
921
+
922
+ function zoomStep(dir: 1 | -1): (() => void) | undefined {
923
+ if (!zoomItem) return undefined
924
+ const at = zoomOrder.indexOf(zoomItem)
925
+ const to = zoomOrder[at + dir]
926
+ if (at < 0 || !to) return undefined
927
+ return () => {
928
+ zoomIndex = dir > 0 ? 0 : Math.max(0, zoomVariants(to).length - 1)
929
+ zoomItem = to
930
+ }
931
+ }
932
+
933
+ /** Header ↑/↓, j/k, ←/→ past the edge, and the footer's Previous / Next:
934
+ * a deliberate move drops the pending auto-advance and the Undo cue. */
935
+ function navStep(dir: 1 | -1): (() => void) | undefined {
936
+ const go = zoomStep(dir)
937
+ if (!go) return undefined
938
+ return () => {
939
+ cancelAdvance()
940
+ lastPick = null
941
+ go()
942
+ announceCard()
943
+ }
944
+ }
945
+
946
+ // --- auto-advance after a pick (mos-bk1k8) ---------------------------------
947
+
948
+ /** Every zoomable card in the grid's visual order, ignoring the filter —
949
+ * the card just decided may have left an "undecided" filter. */
950
+ const zoomOrderAll = $derived(
951
+ sections.flatMap((s) =>
952
+ s.items
953
+ .filter((it) => zoomVariants(it.id).length > 0)
954
+ .sort((a, b) => sinkOrder(a.id) - sinkOrder(b.id))
955
+ .map((it) => it.id),
956
+ ),
957
+ )
958
+
959
+ /** The next visible card still asking a decision after `from`, wrapping
960
+ * to the top; null when every card is decided. */
961
+ function nextOpenAfter(from: string): string | null {
962
+ const order = zoomOrderAll
963
+ const at = order.indexOf(from)
964
+ for (let k = 1; k < order.length; k++) {
965
+ const id = order[(at + k + order.length) % order.length]
966
+ if (id !== from && visible(id) && needsYou(id)) return id
967
+ }
968
+ return null
969
+ }
970
+
971
+ const AUTO_NEXT_KEY = "mos.review.autoNext"
972
+ const ADVANCE_MS = 400
973
+ /** Per-viewer opt-out (browser-local UI pref, like the checklist's view
974
+ * prefs) — default ON. */
975
+ let autoNext = $state(true)
976
+ let advancing = $state(false)
977
+ let advanceTimer: ReturnType<typeof setTimeout> | null = null
978
+ /** The last advancing pick in the zoom: what Undo restores. */
979
+ let lastPick = $state<{
980
+ id: string
981
+ title: string
982
+ before: MediaDecision | undefined
983
+ } | null>(null)
984
+ /** Screen-reader line: "Card 6 of 30" after a move. */
985
+ let zoomAnnounce = $state("")
986
+
987
+ $effect(() => {
988
+ try {
989
+ if (localStorage.getItem(AUTO_NEXT_KEY) === "0") autoNext = false
990
+ } catch {
991
+ /* storage unavailable — keep the default */
992
+ }
993
+ })
994
+
995
+ function setAutoNext(on: boolean) {
996
+ autoNext = on
997
+ if (!on) cancelAdvance()
998
+ try {
999
+ if (on) localStorage.removeItem(AUTO_NEXT_KEY)
1000
+ else localStorage.setItem(AUTO_NEXT_KEY, "0")
1001
+ } catch {
1002
+ /* storage unavailable — the choice lasts this visit */
1003
+ }
1004
+ }
1005
+
1006
+ function reducedMotion(): boolean {
1007
+ try {
1008
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches
1009
+ } catch {
1010
+ return false
1011
+ }
1012
+ }
1013
+
1014
+ function cancelAdvance() {
1015
+ if (advanceTimer) clearTimeout(advanceTimer)
1016
+ advanceTimer = null
1017
+ advancing = false
1018
+ }
1019
+
1020
+ onDestroy(cancelAdvance)
1021
+
1022
+ function scheduleAdvance(from: string) {
1023
+ if (!autoNext) return
1024
+ const to = nextOpenAfter(from)
1025
+ if (!to) {
1026
+ zoomAnnounce = "Every card is decided"
1027
+ return
1028
+ }
1029
+ const go = () => {
1030
+ advanceTimer = null
1031
+ advancing = false
1032
+ if (zoomItem !== from) return
1033
+ goToCard(to)
1034
+ tick().then(focusVerdictRow)
1035
+ }
1036
+ if (reducedMotion()) go()
1037
+ else {
1038
+ advancing = true
1039
+ advanceTimer = setTimeout(go, ADVANCE_MS)
1040
+ }
1041
+ }
1042
+
1043
+ /** Jump the zoom to a card (its selected view), like opening it. */
1044
+ function goToCard(id: string) {
1045
+ const views = zoomVariants(id)
1046
+ if (views.length === 0) return
1047
+ zoomIndex = Math.max(
1048
+ 0,
1049
+ views.findIndex((v) => v.id === selected[id]),
1050
+ )
1051
+ zoomItem = id
1052
+ announceCard()
1053
+ }
1054
+
1055
+ function announceCard() {
1056
+ const id = zoomItem
1057
+ const at = id ? zoomOrder.indexOf(id) : -1
1058
+ if (at >= 0) zoomAnnounce = `Card ${at + 1} of ${zoomOrder.length}`
1059
+ }
1060
+
1061
+ /** Focus the new card's first decision control (a pick, else a verdict). */
1062
+ function focusVerdictRow() {
1063
+ const footer = document.querySelector("[data-media-zoom-footer]")
1064
+ const first = [
1065
+ ...(footer?.querySelectorAll<HTMLButtonElement>(
1066
+ "[data-pick], [data-zoom-pick], [data-verdict]",
1067
+ ) ?? []),
1068
+ ].find((b) => !b.disabled)
1069
+ first?.focus({ preventScroll: true })
1070
+ }
1071
+
1072
+ /** Undo: restore the decided card's entry exactly and go back to it. */
1073
+ function undoPick() {
1074
+ const p = lastPick
1075
+ if (!p) return
1076
+ cancelAdvance()
1077
+ lastPick = null
1078
+ const updated = { ...decisions }
1079
+ if (p.before) updated[p.id] = p.before
1080
+ else delete updated[p.id]
1081
+ decisions = updated
1082
+ flush().catch(() => {})
1083
+ if (zoomItem && zoomItem !== p.id) {
1084
+ goToCard(p.id)
1085
+ tick().then(focusVerdictRow)
1086
+ }
1087
+ }
1088
+
1089
+ function goNextOpen() {
1090
+ const to = zoomItem ? nextOpenAfter(zoomItem) : null
1091
+ if (!to) return
1092
+ cancelAdvance()
1093
+ lastPick = null
1094
+ goToCard(to)
1095
+ }
1096
+
1097
+ /** Decided units on the page, for the footer's progress line. */
1098
+ const decidedCount = $derived(
1099
+ Object.values(counts.byVerdict).reduce((n, c) => n + c, 0),
1100
+ )
1101
+
1102
+ /** A text card's full-size view: no zoom − 100% + row (jatui's lightbox
1103
+ * has no prop for it yet, so the host hides it by its data hook); with
1104
+ * words, the image stage goes too and the footer carries the reading
1105
+ * surface. */
1106
+ const TEXT_ZOOM = "[&_[data-media-zoom-controls]]:hidden"
1107
+ const TEXT_ZOOM_WORDS =
1108
+ "[&_[data-media-zoom-controls]]:hidden [&_[data-zoom-stage]]:hidden [&_[data-media-zoom-footer]]:flex-1 [&_[data-media-zoom-footer]]:content-start [&_[data-media-zoom-footer]]:overflow-y-auto"
1109
+
1110
+ /** A text card renders its words on a themed surface (mos-bk1k8). */
1111
+ function textOf(item: MediaItem): string | null {
1112
+ return item.kind === "text" && item.text ? item.text : null
1113
+ }
1114
+
1115
+ /** The zoom view's index is the card's selected view: approving in there
1116
+ * pins what the reviewer is looking at. */
1117
+ function setZoomIndex(i: number) {
1118
+ zoomIndex = i
1119
+ const id = zoomItem
1120
+ const v = id ? zoomVariants(id)[i] : undefined
1121
+ if (id && v && selected[id] !== v.id) selectVariant(id, v.id)
1122
+ }
1123
+
1124
+ /** Keys the lightbox lets bubble (verdicts, u, n, [ ]) act on its item. */
1125
+ function onZoomKeydown(e: KeyboardEvent) {
1126
+ const id = zoomItem
1127
+ if (!id || e.defaultPrevented || e.isComposing) return
1128
+ if (e.metaKey || e.ctrlKey || e.altKey || isTextTarget(e.target)) return
1129
+ const k = e.key
1130
+ const v = VERDICTS.find((o) => o.key === k)
1131
+ if (v && canDecide) setVerdict(id, v.id as MediaVerdict)
1132
+ else if (k === "u" && canDecide) undoVerdict(id)
1133
+ else if (k === "n" && canDecide) toggleNote(id)
1134
+ else if (k === "[" || k === "]") {
1135
+ stepVersion(id, k === "[" ? -1 : 1)
1136
+ zoomIndex = 0
1137
+ } else return
1138
+ e.preventDefault()
1139
+ }
1140
+
1141
+ // --- reduced motion: autoplay muted loop videos only when allowed -----------
1142
+
1143
+ $effect(() => {
1144
+ if (!rootEl) return
1145
+ let ok = true
1146
+ try {
1147
+ ok = !window.matchMedia("(prefers-reduced-motion: reduce)").matches
1148
+ } catch {
1149
+ ok = true
1150
+ }
1151
+ const videos = rootEl.querySelectorAll<HTMLVideoElement>(
1152
+ "[data-item-id] video[data-autoloop]",
1153
+ )
1154
+ videos.forEach((v) => {
1155
+ if (ok) v.play().catch(() => {})
1156
+ else v.pause()
1157
+ })
1158
+ })
1159
+
1160
+ function mediaAlt(item: MediaItem, label: string | undefined): string {
1161
+ return label ? `${item.title}: ${label}` : item.title
1162
+ }
1163
+
1164
+ /** Human label for a variant id (approved badge). */
1165
+ function variantLabel(item: MediaItem, variantId: string | null | undefined) {
1166
+ const v = item.variants.find((x) => x.id === variantId)
1167
+ return v ? fullLabel(item, v) : (variantId ?? "")
1168
+ }
1169
+
1170
+ /** The locked line for a version (viewer's own, else the publisher's). */
1171
+ function versionLine(id: string, version: string | null) {
1172
+ if (!version) return null
1173
+ return views[id]?.history.find((l) => l.round === version) ?? null
1174
+ }
1175
+ </script>
1176
+
1177
+ {#snippet cardCopy(item: MediaItem)}
1178
+ <!-- The card's plain-English copy (mos-9d62g): two short labeled lines
1179
+ written for the reader (the build lints them), plus "Open on site"
1180
+ chips — where to see the thing being judged, in a new tab. -->
1181
+ {#if item.wrong}
1182
+ <p class="text-xs leading-relaxed text-base-content/90" data-wrong>
1183
+ <span class="font-semibold">What is wrong:</span>
1184
+ {item.wrong}
1185
+ </p>
1186
+ {/if}
1187
+ {#if item.ifApproved}
1188
+ <p class="text-xs leading-relaxed text-base-content/90" data-if-approved>
1189
+ <span class="font-semibold">If you approve:</span>
1190
+ {item.ifApproved}
1191
+ </p>
1192
+ {/if}
1193
+ {#if item.links?.length}
1194
+ <div class="flex flex-wrap gap-1.5" data-links>
1195
+ {#each item.links as l (l.url)}
1196
+ <a
1197
+ href={l.url}
1198
+ target="_blank"
1199
+ rel="noopener noreferrer"
1200
+ class="btn btn-xs rounded-field border-base-content/15 text-base-content/80 hover:text-base-content"
1201
+ title="{l.label} (opens in a new tab)"
1202
+ >
1203
+ {l.label}
1204
+ <svg
1205
+ viewBox="0 0 24 24"
1206
+ class="size-3"
1207
+ fill="none"
1208
+ stroke="currentColor"
1209
+ stroke-width="2"
1210
+ stroke-linecap="round"
1211
+ stroke-linejoin="round"
1212
+ aria-hidden="true"><path d="M7 17 17 7" /><path d="M8 7h9v9" /></svg
1213
+ >
1214
+ </a>
1215
+ {/each}
1216
+ </div>
1217
+ {/if}
1218
+ {/snippet}
1219
+
1220
+ {#snippet zoomNav()}
1221
+ <!-- Beside the verdict row (mos-bk1k8): moving on is next to the choice
1222
+ just made, not the full diagonal away in the header. Same size as the
1223
+ verdict buttons; the header arrows stay too. -->
1224
+ {@const prev = navStep(-1)}
1225
+ {@const next = navStep(1)}
1226
+ {@const open = zoomItem ? nextOpenAfter(zoomItem) : null}
1227
+ <div
1228
+ class="flex flex-wrap items-center gap-1"
1229
+ role="group"
1230
+ aria-label="Move between cards"
1231
+ data-zoom-nav
1232
+ >
1233
+ <button
1234
+ type="button"
1235
+ class="btn btn-sm rounded-field gap-1"
1236
+ aria-keyshortcuts="K"
1237
+ disabled={!prev}
1238
+ onclick={() => prev?.()}
1239
+ data-zoom-prev
1240
+ >
1241
+ Previous <kbd class="kbd kbd-xs opacity-70" aria-hidden="true">k</kbd>
1242
+ </button>
1243
+ <button
1244
+ type="button"
1245
+ class="btn btn-sm rounded-field gap-1"
1246
+ aria-keyshortcuts="J"
1247
+ disabled={!next}
1248
+ onclick={() => next?.()}
1249
+ data-zoom-next
1250
+ >
1251
+ Next <kbd class="kbd kbd-xs opacity-70" aria-hidden="true">j</kbd>
1252
+ </button>
1253
+ {#if canDecide && open}
1254
+ <button
1255
+ type="button"
1256
+ class="btn btn-sm btn-ghost rounded-field"
1257
+ onclick={goNextOpen}
1258
+ data-zoom-next-open
1259
+ >
1260
+ Next open
1261
+ </button>
1262
+ {/if}
1263
+ </div>
1264
+ {/snippet}
1265
+
1266
+ {#snippet zoomStatus()}
1267
+ <!-- Progress + the pick cue (mos-bk1k8). The visible line is not live;
1268
+ the sr-only line announces "Card 6 of 30" after a move. -->
1269
+ {@const at = zoomItem ? zoomOrder.indexOf(zoomItem) : -1}
1270
+ <div
1271
+ class="flex w-full flex-wrap items-center gap-x-3 gap-y-1 text-xs text-base-content/70"
1272
+ data-zoom-status
1273
+ >
1274
+ <span class="tabular-nums" data-zoom-progress>
1275
+ {`${at >= 0 ? `card ${at + 1} of ${zoomOrder.length} · ` : ""}${decidedCount} decided`}
1276
+ </span>
1277
+ {#if lastPick}
1278
+ <span
1279
+ class="flex items-center gap-1.5 font-medium text-success"
1280
+ data-zoom-cue
1281
+ >
1282
+ {advancing
1283
+ ? "Saved · next"
1284
+ : lastPick.id === zoomItem
1285
+ ? "Saved"
1286
+ : `Saved: ${lastPick.title}`}
1287
+ <button
1288
+ type="button"
1289
+ class="btn btn-ghost btn-xs rounded-field text-base-content/80"
1290
+ onclick={undoPick}
1291
+ data-zoom-undo-pick>↶ Undo</button
1292
+ >
1293
+ </span>
1294
+ {/if}
1295
+ {#if canDecide}
1296
+ <label class="ml-auto flex cursor-pointer items-center gap-1.5">
1297
+ <input
1298
+ type="checkbox"
1299
+ class="toggle toggle-xs"
1300
+ checked={autoNext}
1301
+ onchange={(e) => setAutoNext(e.currentTarget.checked)}
1302
+ data-zoom-auto-next
1303
+ />
1304
+ Go to the next card after a pick
1305
+ </label>
1306
+ {/if}
1307
+ <span class="sr-only" aria-live="polite" data-zoom-announce
1308
+ >{zoomAnnounce}</span
1309
+ >
1310
+ </div>
1311
+ {/snippet}
1312
+
1313
+ {#snippet decisionRow(item: MediaItem, inZoom: boolean)}
1314
+ {@const cur = currentOf(item.id)}
1315
+ {@const picks = choicesOf(item)}
1316
+ {#if canDecide && !viewingPrevious(item) && picks.length > 0}
1317
+ <!-- A named-choice item (the "A vs B" comparison sheet): a Pick button
1318
+ per option; the pick saves verdict + the choice id, exactly what an
1319
+ approve saves. Needs work / Reject stay for "neither". -->
1320
+ <div
1321
+ class="flex flex-wrap gap-1.5"
1322
+ role="group"
1323
+ aria-label="Pick an option for {item.title}"
1324
+ data-choice-row
1325
+ >
1326
+ {#each picks as c (c.id)}
1327
+ {@const on = cur.verdict === "approve" && cur.variant === c.id}
1328
+ <button
1329
+ type="button"
1330
+ class="btn btn-sm rounded-field {on ? 'btn-success' : 'btn-outline'}"
1331
+ aria-pressed={on}
1332
+ data-pick={c.id}
1333
+ onclick={() => pickChoice(item.id, c.id)}
1334
+ >
1335
+ {on ? `✓ ${c.label}` : `Pick ${c.label}`}
1336
+ </button>
1337
+ {/each}
1338
+ </div>
1339
+ {/if}
1340
+ {#if inZoom && canDecide && !viewingPrevious(item) && pickableViews(item).length > 1}
1341
+ <!-- A per-variant choice item in the zoom: one Pick button for the view
1342
+ being looked at (the strip's buttons are not reachable here). -->
1343
+ {@const shown = zoomVariants(item.id)[zoomIndex] ?? selectedVariantOf(item)}
1344
+ {@const on = isPickedVariant(item, shown.id)}
1345
+ <button
1346
+ type="button"
1347
+ class="btn btn-sm rounded-field self-start {on
1348
+ ? 'btn-success'
1349
+ : 'btn-outline'}"
1350
+ aria-pressed={on}
1351
+ aria-label="{on ? 'Picked' : 'Pick this'}: {viewLabel(item, shown)}"
1352
+ data-zoom-pick
1353
+ onclick={() => pickVariant(item.id, shown.id)}
1354
+ >
1355
+ {on ? "✓ Picked" : "Pick this"}
1356
+ </button>
1357
+ {/if}
1358
+ <div
1359
+ class="flex flex-wrap items-center gap-2 [&_.join]:min-w-0 [&_.join]:flex-wrap [&_[data-verdict]]:min-w-0 [&_[data-verdict='approve']]:max-w-full [&_[data-verdict='approve']]:truncate"
1360
+ use:verdictTooltips={verdictFullLabels(item)}
1361
+ >
1362
+ <VerdictControl
1363
+ options={verdictOptions(item)}
1364
+ value={approveVisibleValue(item)}
1365
+ label="Verdict for {item.title}"
1366
+ undoLabel="Undo"
1367
+ showKeys={inZoom}
1368
+ disabled={!canDecide}
1369
+ onVerdict={(v) => setVerdict(item.id, v as MediaVerdict)}
1370
+ onUndo={() => undoVerdict(item.id)}
1371
+ >
1372
+ {#snippet trailing()}
1373
+ <!-- Inside VerdictControl's atomic Undo group: the wrap boundary
1374
+ falls before the pair, so the Note button never strands alone
1375
+ on its own row (mos-lsmve.16 F7). -->
1376
+ <NoteToggle
1377
+ open={!!notesOpen[item.id]}
1378
+ hasNote={!!cur.note}
1379
+ disabled={!canDecide}
1380
+ controls={inZoom ? `zoomnote-${item.id}` : `noteinput-${item.id}`}
1381
+ itemTitle={item.title}
1382
+ onToggle={() => toggleNote(item.id)}
1383
+ />
1384
+ {/snippet}
1385
+ </VerdictControl>
1386
+ {#if inZoom}
1387
+ {@render zoomNav()}
1388
+ {/if}
1389
+ </div>
1390
+ {#if inZoom && cur.verdict === "approve" && (cur.variant || item.evidence)}
1391
+ <!-- The approval names its variant wherever the decision is made —
1392
+ on the card the header badge carries it, in the zoom this line.
1393
+ An evidence card's approve answers the CARD: no image is named. -->
1394
+ <p class="text-xs font-medium text-success" data-zoom-approved>
1395
+ {item.evidence ? "Approved" : `Approved: ${pickLabel(item, cur.variant)}`}
1396
+ </p>
1397
+ {/if}
1398
+ <!-- The ONE composer in its per-item mode (mos-rccb5): jatui ReviewNote
1399
+ stays the inline text leg (round model, open-on-reject, debounced
1400
+ save — all unchanged); the composer adds the attach lane — voice /
1401
+ video / screen capture, file picker, paste and drag-drop — and the
1402
+ clip chips. Uploads land in the artifact's private asset store and
1403
+ ride the item's saved entry as descriptors. -->
1404
+ <ItemNote
1405
+ artifactId={ctx.artifactId}
1406
+ note={{
1407
+ id: inZoom ? `zoomnote-${item.id}` : `noteinput-${item.id}`,
1408
+ value: cur.note ?? "",
1409
+ verdict: cur.verdict ?? null,
1410
+ options: VERDICTS,
1411
+ round: views[item.id]?.round ?? null,
1412
+ saveFailed: saver.failed,
1413
+ label: `Note for ${item.title}`,
1414
+ placeholder: "What needs to change?",
1415
+ onInput: (t) => onNoteInput(item.id, t),
1416
+ onSave: noteSave,
1417
+ ...(inZoom
1418
+ ? {
1419
+ returnFocusTo: () =>
1420
+ document.querySelector<HTMLElement>("[data-media-zoom-lightbox]"),
1421
+ }
1422
+ : {}),
1423
+ }}
1424
+ bind:open={() => !!notesOpen[item.id],
1425
+ (o) => (notesOpen = { ...notesOpen, [item.id]: o })}
1426
+ clips={currentOf(item.id).attachments ?? []}
1427
+ onClipsChange={(clips) => onItemClips(item.id, clips)}
1428
+ disabled={!canDecide}
1429
+ />
1430
+ {/snippet}
1431
+
1432
+ <!-- The cards are the interactive elements; jatui reviewKeys (listening on
1433
+ this block only) acts on the focused card. -->
1434
+ <section
1435
+ bind:this={rootEl}
1436
+ class="@container rounded-box border border-base-content/10 bg-base-100"
1437
+ aria-label={params.title ?? "Media review grid"}
1438
+ use:reviewKeys={keysConfig}
1439
+ >
1440
+ {#if params.title || params.intro}
1441
+ <!-- flow-root contains the Listen control's float; the title and intro
1442
+ wrap around it, so a long title never runs under the pill. -->
1443
+ <header class="flow-root px-4 pt-4 pb-1 sm:px-5">
1444
+ <SectionTools {ctx} listenLabel={params.title?.trim() || "this review"} />
1445
+ {#if params.title}
1446
+ <h2 class="text-lg font-semibold tracking-tight">{params.title}</h2>
1447
+ {/if}
1448
+ {#if params.intro}
1449
+ {#if introHtml}
1450
+ <div
1451
+ class="prose prose-sm mt-0.5 max-w-[70ch] text-base-content/75 prose-p:my-1.5 prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5"
1452
+ data-intro-md
1453
+ >
1454
+ {@html introHtml}
1455
+ </div>
1456
+ {:else}
1457
+ <p
1458
+ class="mt-0.5 max-w-[70ch] text-sm whitespace-pre-line text-base-content/75"
1459
+ data-intro-plain
1460
+ >
1461
+ {params.intro}
1462
+ </p>
1463
+ {/if}
1464
+ {/if}
1465
+ </header>
1466
+ {/if}
1467
+
1468
+ <div
1469
+ class="flex flex-col gap-2 border-b border-base-content/10 px-4 py-2.5 sm:px-5"
1470
+ >
1471
+ <ReviewProgress
1472
+ verdicts={VERDICTS}
1473
+ counts={counts.byVerdict}
1474
+ total={counts.total}
1475
+ retest={counts.retest}
1476
+ size="xs"
1477
+ label="Review progress"
1478
+ />
1479
+ <div class="flex flex-wrap items-center gap-2">
1480
+ <ReviewFilterBar
1481
+ {filters}
1482
+ bind:active={filter}
1483
+ onHelp={() => (helpOpen = true)}
1484
+ label="Filter cards"
1485
+ />
1486
+ {#if canDecide && approvableCount(params.items) > 0}
1487
+ <!-- Joe (pe-dusk-sweep): clicking Approve 90 times is bad UX — one
1488
+ bulk approve per page, one save. Joe 10-19 (mos-u8i35): bulk
1489
+ approve OR reject, then flip the outliers. Both ask first. -->
1490
+ <div class="ml-auto flex shrink-0 flex-wrap items-center gap-2">
1491
+ <button
1492
+ type="button"
1493
+ class="btn btn-outline btn-xs rounded-field"
1494
+ aria-label="Approve all {approvableCount(
1495
+ params.items,
1496
+ )} undecided items"
1497
+ aria-keyshortcuts="Shift+A"
1498
+ data-approve-all
1499
+ onclick={confirmApproveAll}
1500
+ >
1501
+ Approve all remaining
1502
+ <span class="tabular-nums opacity-70"
1503
+ >({approvableCount(params.items)})</span
1504
+ >
1505
+ </button>
1506
+ <button
1507
+ type="button"
1508
+ class="btn btn-outline btn-error btn-xs rounded-field"
1509
+ aria-label="Reject all {approvableCount(
1510
+ params.items,
1511
+ )} undecided items"
1512
+ aria-keyshortcuts="Shift+R"
1513
+ aria-expanded={!!rejectAsk}
1514
+ data-reject-all
1515
+ onclick={() => askReject(params.items)}
1516
+ >
1517
+ Reject all remaining
1518
+ <span class="tabular-nums opacity-70"
1519
+ >({approvableCount(params.items)})</span
1520
+ >
1521
+ </button>
1522
+ </div>
1523
+ {/if}
1524
+ </div>
1525
+ </div>
1526
+
1527
+ {#if rejectAsk && canDecide && rejectCount > 0}
1528
+ <BulkRejectForm
1529
+ count={rejectCount}
1530
+ noun="item"
1531
+ onConfirm={confirmReject}
1532
+ onCancel={cancelReject}
1533
+ />
1534
+ {/if}
1535
+
1536
+ {#if lastBulk && canDecide}
1537
+ {@const rejected = lastBulk.undo.verdict === "reject"}
1538
+ <BulkUndoBar
1539
+ message="{rejected ? 'Rejected' : 'Approved'} {itemCount(
1540
+ lastBulk.count,
1541
+ )}. Flip any card with a or r."
1542
+ tone={rejected ? "error" : "success"}
1543
+ onUndo={undoBulk}
1544
+ onDismiss={() => (lastBulk = null)}
1545
+ />
1546
+ {/if}
1547
+
1548
+ {#if saver.failed}
1549
+ <div
1550
+ class="mx-4 mt-3 flex items-center gap-2 rounded-field border border-error/40 bg-error/10 px-3 py-2 text-sm sm:mx-5"
1551
+ role="alert"
1552
+ data-save-error
1553
+ >
1554
+ <span>Your last change was not saved.</span>
1555
+ <button
1556
+ type="button"
1557
+ class="btn btn-xs ml-auto rounded-field"
1558
+ onclick={() => saver.retry()}>Retry</button
1559
+ >
1560
+ </div>
1561
+ {/if}
1562
+
1563
+ <div class="px-4 pt-2 pb-5 sm:px-5">
1564
+ {#if sections.length === 0}
1565
+ <p class="py-6 text-sm text-base-content/70">No media to review yet.</p>
1566
+ {/if}
1567
+ {#each sections as section (section.id)}
1568
+ {@const anyVisible = section.items.some((it) => visible(it.id))}
1569
+ <section aria-label={section.title || "Media"} hidden={!anyVisible}>
1570
+ {#if section.title}
1571
+ <div class="mt-4 mb-2 flex flex-wrap items-center gap-2">
1572
+ <h3 class="text-sm font-semibold text-base-content/80">
1573
+ {section.title}
1574
+ </h3>
1575
+ {#if canDecide && approvableCount(section.items) > 0}
1576
+ <button
1577
+ type="button"
1578
+ class="btn btn-ghost btn-xs rounded-field text-base-content/70"
1579
+ aria-label="Approve all {approvableCount(
1580
+ section.items,
1581
+ )} undecided in {section.title}"
1582
+ data-approve-all-section={section.id}
1583
+ onclick={() => approveAll(section.items)}
1584
+ >
1585
+ Approve all
1586
+ <span class="tabular-nums">
1587
+ {approvableCount(section.items)}</span
1588
+ >
1589
+ </button>
1590
+ {/if}
1591
+ </div>
1592
+ {/if}
1593
+ <div class="grid grid-cols-1 gap-3 @xl:grid-cols-2 @5xl:grid-cols-3">
1594
+ {#each section.items as item (item.id)}
1595
+ {@const cur = currentOf(item.id)}
1596
+ {@const v = cur.verdict ?? null}
1597
+ {@const view = views[item.id]}
1598
+ {@const show = visible(item.id)}
1599
+ {@const selVariant = selectedVariantOf(item)}
1600
+ {@const versions = versionsOf(item)}
1601
+ {@const curVersion = currentVersionOf(item)}
1602
+ {@const shownVersion =
1603
+ versions.length > 0 ? versionOfVariant(item, selVariant) : null}
1604
+ {@const previous =
1605
+ shownVersion != null && shownVersion !== curVersion}
1606
+ {@const itemViews = viewsOf(item, shownVersion)}
1607
+ {@const history = historyRounds(
1608
+ view?.history ?? [],
1609
+ mediaRounds.roundLabel,
1610
+ )}
1611
+ {@const retest = isRetest(item)}
1612
+ <!-- The card is the keyboard focus TARGET for reviewKeys — an
1613
+ intentional roving-focus pattern (article tabindex=0). -->
1614
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
1615
+ <article
1616
+ data-item-id={item.id}
1617
+ tabindex="0"
1618
+ class="relative flex flex-col overflow-hidden rounded-box border bg-base-100 transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary motion-reduce:transition-none {v ===
1619
+ 'approve'
1620
+ ? 'border-success ring-1 ring-success/40'
1621
+ : v === 'redo'
1622
+ ? 'border-warning/60'
1623
+ : v === 'reject'
1624
+ ? 'border-error/40 opacity-50 focus-within:opacity-100 hover:opacity-100'
1625
+ : 'border-base-content/10'}"
1626
+ style:order={sinkOrder(item.id)}
1627
+ aria-label="{item.title}, {item.informational
1628
+ ? 'context only, no decision needed'
1629
+ : v
1630
+ ? VERDICT_WORD[v]
1631
+ : retest
1632
+ ? 'to retest'
1633
+ : 'undecided'}"
1634
+ hidden={!show}
1635
+ >
1636
+ <!-- Media area: the WHOLE asset, letterboxed (never cropped).
1637
+ An evidence card (mos-9d62g) shows its variants as an
1638
+ EXAMPLES gallery instead — every image visible with its
1639
+ numbered caption; the verdict approves the CARD, so no
1640
+ image is named, nothing is pinned. -->
1641
+ {#if textOf(item)}
1642
+ <!-- A text card's words on a themed surface (mos-bk1k8) —
1643
+ never the image's white slab in dark mode. -->
1644
+ <button
1645
+ type="button"
1646
+ class="block w-full cursor-zoom-in bg-base-200 p-2 text-left"
1647
+ aria-label="Open {item.title} full size"
1648
+ onclick={() => openZoom(item.id)}
1649
+ data-text-card
1650
+ >
1651
+ <p
1652
+ class="line-clamp-[12] rounded-field border border-base-content/10 bg-base-100 p-3 text-sm leading-relaxed whitespace-pre-line text-base-content"
1653
+ >
1654
+ {textOf(item)}
1655
+ </p>
1656
+ </button>
1657
+ {:else if item.evidence}
1658
+ <div class="bg-base-200 p-2" data-evidence>
1659
+ <p
1660
+ class="px-0.5 pb-1 text-2xs font-medium tracking-wide text-base-content/55 uppercase"
1661
+ data-evidence-label
1662
+ >
1663
+ Examples{itemViews.length > 1
1664
+ ? ` (${itemViews.length})`
1665
+ : ""}
1666
+ </p>
1667
+ <div
1668
+ class="grid grid-cols-2 gap-1.5"
1669
+ role="group"
1670
+ aria-label="{item.title} examples"
1671
+ data-evidence-gallery
1672
+ >
1673
+ {#each itemViews as vr, i (vr.id)}
1674
+ {@const label = viewLabel(item, vr)}
1675
+ <div
1676
+ class="flex flex-col gap-0.5"
1677
+ data-evidence-item={vr.id}
1678
+ >
1679
+ {#if vr.kind === "audio"}
1680
+ <div class="p-1">
1681
+ <!-- Audio reads as a deliberate row: framed like
1682
+ the other variants (rounded, bordered) — the
1683
+ native control's chrome is the browser's; the
1684
+ frame is ours. -->
1685
+ <audio
1686
+ controls
1687
+ src={vr.src}
1688
+ preload="metadata"
1689
+ aria-label={mediaAlt(item, label)}
1690
+ class="w-full rounded-field border border-base-content/10 bg-base-100"
1691
+ ></audio>
1692
+ </div>
1693
+ {:else}
1694
+ <button
1695
+ type="button"
1696
+ class="relative block w-full cursor-zoom-in overflow-hidden rounded-field border border-base-content/10 bg-base-200"
1697
+ aria-label="Open {mediaAlt(item, label)} full size"
1698
+ data-evidence={vr.id}
1699
+ onclick={() => {
1700
+ selectVariant(item.id, vr.id)
1701
+ openZoom(item.id)
1702
+ }}
1703
+ >
1704
+ {#if vr.kind === "image"}
1705
+ <img
1706
+ src={vr.src}
1707
+ alt={mediaAlt(item, label)}
1708
+ loading="lazy"
1709
+ class="aspect-[4/3] w-full object-cover"
1710
+ />
1711
+ {:else if vr.poster}
1712
+ <img
1713
+ src={vr.poster}
1714
+ alt={mediaAlt(item, label)}
1715
+ loading="lazy"
1716
+ class="aspect-[4/3] w-full object-cover"
1717
+ />
1718
+ {:else}
1719
+ <span
1720
+ class="grid aspect-[4/3] w-full place-items-center text-2xs text-base-content/70"
1721
+ aria-hidden="true">{vr.kind}</span
1722
+ >
1723
+ {/if}
1724
+ </button>
1725
+ {/if}
1726
+ <span
1727
+ class="block truncate px-0.5 text-2xs leading-4 text-base-content/80"
1728
+ title={label}
1729
+ >
1730
+ {i + 1}. {label}
1731
+ </span>
1732
+ </div>
1733
+ {/each}
1734
+ </div>
1735
+ </div>
1736
+ {:else}
1737
+ <div class="relative bg-base-200">
1738
+ {#if selVariant.kind === "image"}
1739
+ <button
1740
+ type="button"
1741
+ class="block w-full cursor-zoom-in"
1742
+ aria-label="Open {mediaAlt(
1743
+ item,
1744
+ fullLabel(item, selVariant),
1745
+ )} full size"
1746
+ onclick={() => openZoom(item.id)}
1747
+ >
1748
+ <img
1749
+ src={selVariant.src}
1750
+ alt={mediaAlt(item, fullLabel(item, selVariant))}
1751
+ loading="lazy"
1752
+ class="aspect-[4/3] w-full object-contain"
1753
+ />
1754
+ </button>
1755
+ {:else if selVariant.kind === "video"}
1756
+ <button
1757
+ type="button"
1758
+ class="group relative block w-full cursor-zoom-in"
1759
+ aria-label="Open {mediaAlt(
1760
+ item,
1761
+ fullLabel(item, selVariant),
1762
+ )} full size (zoom, frame-step, speed)"
1763
+ onclick={() => openZoom(item.id)}
1764
+ >
1765
+ <video
1766
+ data-autoloop
1767
+ src={selVariant.src}
1768
+ poster={selVariant.poster}
1769
+ muted
1770
+ loop
1771
+ playsinline
1772
+ preload="metadata"
1773
+ aria-hidden="true"
1774
+ tabindex="-1"
1775
+ class="pointer-events-none aspect-[4/3] w-full object-contain"
1776
+ ></video>
1777
+ <span
1778
+ class="absolute right-2 bottom-2 rounded-field bg-base-100/90 px-1.5 py-0.5 text-2xs font-medium text-base-content shadow-sm group-hover:bg-base-100"
1779
+ aria-hidden="true">⤢ zoom</span
1780
+ >
1781
+ </button>
1782
+ {:else if selVariant.kind === "audio"}
1783
+ <div class="p-3">
1784
+ <!-- Audio framed like the other media variants (see
1785
+ the evidence gallery row). -->
1786
+ <audio
1787
+ controls
1788
+ src={selVariant.src}
1789
+ preload="metadata"
1790
+ aria-label={mediaAlt(item, fullLabel(item, selVariant))}
1791
+ class="w-full rounded-field border border-base-content/10 bg-base-100"
1792
+ ></audio>
1793
+ </div>
1794
+ {/if}
1795
+ </div>
1796
+ {/if}
1797
+ {#if versions.length > 0}
1798
+ <!-- The version row — the one under review first, marked.
1799
+ [ / ] step through it. -->
1800
+ <div
1801
+ class="flex items-center gap-1.5 overflow-x-auto px-2.5 pt-2.5"
1802
+ role="group"
1803
+ aria-label="{item.title} versions"
1804
+ data-version-row
1805
+ >
1806
+ <span class="shrink-0 text-2xs text-base-content/60"
1807
+ >Version</span
1808
+ >
1809
+ <div class="join shrink-0">
1810
+ {#each versions as ver (ver)}
1811
+ {@const on = shownVersion === ver}
1812
+ <button
1813
+ type="button"
1814
+ class="btn join-item btn-xs {on
1815
+ ? 'btn-primary'
1816
+ : 'btn-ghost border-base-content/15'}"
1817
+ aria-pressed={on}
1818
+ data-version={ver}
1819
+ title={ver === curVersion
1820
+ ? `${ver}, the version under review`
1821
+ : `${ver}, a previous version (read-only)`}
1822
+ onclick={() => switchVersion(item.id, ver)}
1823
+ >{ver}{#if ver === curVersion}<span
1824
+ class="ml-1 font-normal opacity-80">reviewing</span
1825
+ >{/if}</button
1826
+ >
1827
+ {/each}
1828
+ </div>
1829
+ </div>
1830
+ {/if}
1831
+ {#if itemViews.length > 1 && !item.evidence}
1832
+ <!-- One snapping filmstrip below the media (never wrapped
1833
+ rows): the views of the shown version, labelled. An
1834
+ evidence card has the gallery above instead. -->
1835
+ <div
1836
+ class="flex snap-x gap-1.5 overflow-x-auto px-2.5 pt-2.5 pb-1"
1837
+ role="group"
1838
+ aria-label="{item.title}{shownVersion
1839
+ ? ` ${shownVersion}`
1840
+ : ''} views"
1841
+ >
1842
+ {#each itemViews as vr, i (vr.id)}
1843
+ {@const thumb =
1844
+ vr.kind === "image"
1845
+ ? vr.src
1846
+ : vr.kind === "video"
1847
+ ? vr.poster
1848
+ : undefined}
1849
+ {@const on = selVariant.id === vr.id}
1850
+ {@const vlabel = viewLabel(item, vr)}
1851
+ {@const picked = isPickedVariant(item, vr.id)}
1852
+ <div
1853
+ class="flex w-[4.5rem] shrink-0 snap-start flex-col gap-1"
1854
+ >
1855
+ <button
1856
+ type="button"
1857
+ class="relative w-full overflow-hidden rounded-field border-2 text-left transition-colors motion-reduce:transition-none {on
1858
+ ? 'border-primary'
1859
+ : 'border-transparent opacity-80 hover:opacity-100'}"
1860
+ aria-pressed={on}
1861
+ title="{vlabel} (key {i + 1})"
1862
+ onclick={() => selectVariant(item.id, vr.id)}
1863
+ >
1864
+ {#if thumb}
1865
+ <img
1866
+ src={thumb}
1867
+ alt=""
1868
+ loading="lazy"
1869
+ class="aspect-[4/3] w-full bg-base-200 object-cover"
1870
+ />
1871
+ {:else}
1872
+ <span
1873
+ class="grid aspect-[4/3] w-full place-items-center bg-base-200 text-2xs text-base-content/70"
1874
+ aria-hidden="true">{vr.kind}</span
1875
+ >
1876
+ {/if}
1877
+ {#if i < 9}
1878
+ <span
1879
+ class="absolute top-0.5 left-0.5 rounded-field bg-base-100/90 px-1 text-2xs leading-4 font-semibold tabular-nums text-base-content"
1880
+ aria-hidden="true">{i + 1}</span
1881
+ >
1882
+ {/if}
1883
+ {#if picked}
1884
+ <!-- The approval is pinned to THIS variant — visible
1885
+ even when the reviewer browses another one. -->
1886
+ <span
1887
+ class="absolute top-0.5 right-0.5 rounded-field bg-base-100/90 px-1 text-2xs leading-4 font-semibold text-success"
1888
+ aria-hidden="true">✓</span
1889
+ >
1890
+ {/if}
1891
+ <span
1892
+ class="block truncate bg-base-100 px-1 text-2xs leading-4 text-base-content/80"
1893
+ title={vlabel}>{vlabel}</span
1894
+ >
1895
+ </button>
1896
+ {#if canDecide && !previous && pickableViews(item).length > 1}
1897
+ <!-- A choice item picks per variant: the action
1898
+ itself names what it approves (mos-lwbbt). -->
1899
+ <button
1900
+ type="button"
1901
+ class="btn btn-xs rounded-field {picked
1902
+ ? 'btn-success'
1903
+ : 'btn-outline'}"
1904
+ aria-pressed={picked}
1905
+ aria-label="{picked
1906
+ ? 'Picked'
1907
+ : 'Pick this'}: {vlabel}"
1908
+ data-pick={vr.id}
1909
+ onclick={() => pickVariant(item.id, vr.id)}
1910
+ >
1911
+ {picked ? "✓ Picked" : "Pick this"}
1912
+ </button>
1913
+ {/if}
1914
+ </div>
1915
+ {/each}
1916
+ </div>
1917
+ {/if}
1918
+ {#if item.variants.length > 1 && !item.evidence}
1919
+ <p
1920
+ class="px-2.5 pt-1 text-xs leading-snug break-words text-base-content/70 tabular-nums"
1921
+ aria-live="polite"
1922
+ data-caption
1923
+ >
1924
+ {captionFor(item, selVariant, shownVersion)}
1925
+ </p>
1926
+ {/if}
1927
+
1928
+ <div class="flex flex-1 flex-col gap-1.5 p-3">
1929
+ <div class="flex flex-wrap items-baseline gap-1.5">
1930
+ <h4 class="text-sm leading-snug font-semibold">
1931
+ {item.title}
1932
+ </h4>
1933
+ {#if v === "approve" && (cur.variant || item.evidence)}
1934
+ <span
1935
+ class="badge badge-sm badge-success font-medium h-auto max-w-full py-0.5 text-left leading-snug whitespace-normal"
1936
+ data-approved-badge
1937
+ >
1938
+ {item.evidence
1939
+ ? "Approved"
1940
+ : `Approved: ${pickLabel(item, cur.variant)}`}
1941
+ </span>
1942
+ {/if}
1943
+ {#if item.informational}
1944
+ <span class="badge badge-sm badge-ghost" data-informational>
1945
+ no decision needed
1946
+ </span>
1947
+ {/if}
1948
+ {#each item.tags ?? [] as t (t)}
1949
+ <span class="badge badge-ghost badge-sm">{t}</span>
1950
+ {/each}
1951
+ </div>
1952
+ {#if item.team || (retest && canDecide)}
1953
+ <TeamChip team={item.team} retest={retest && canDecide} />
1954
+ {/if}
1955
+ {#if item.subtitle}
1956
+ <p
1957
+ class="line-clamp-4 text-xs leading-relaxed {v === 'redo'
1958
+ ? 'italic text-base-content'
1959
+ : 'text-base-content/75'}"
1960
+ title={item.subtitle}
1961
+ >
1962
+ {item.subtitle}
1963
+ </p>
1964
+ {/if}
1965
+ {@render cardCopy(item)}
1966
+ {#if item.meta && Object.keys(item.meta).length > 0}
1967
+ <p class="text-xs text-base-content/70 tabular-nums">
1968
+ {Object.entries(item.meta)
1969
+ .map(([k, val]) => `${k}: ${val}`)
1970
+ .join(" · ")}
1971
+ </p>
1972
+ {/if}
1973
+
1974
+ <div class="mt-auto flex flex-col gap-1.5 pt-1.5">
1975
+ {#if !item.informational}
1976
+ {#if previous}
1977
+ <!-- An older version is read-only: the verdict left on it
1978
+ then, and the way back. -->
1979
+ <div
1980
+ class="flex flex-col gap-1.5 rounded-field border border-base-content/10 bg-base-200 p-2 text-xs"
1981
+ role="note"
1982
+ data-previous-version
1983
+ >
1984
+ <p>
1985
+ <span class="font-semibold"
1986
+ >Previous version ({shownVersion})</span
1987
+ >
1988
+ is read-only. Verdicts go on {curVersion}.
1989
+ </p>
1990
+ <p class="text-base-content/80">
1991
+ {historyLine(versionLine(item.id, shownVersion))}
1992
+ </p>
1993
+ <button
1994
+ type="button"
1995
+ class="btn btn-xs self-start rounded-field"
1996
+ onclick={() =>
1997
+ curVersion && switchVersion(item.id, curVersion)}
1998
+ >Back to {curVersion} (reviewing)</button
1999
+ >
2000
+ </div>
2001
+ {:else if canDecide && zoomItem !== item.id}
2002
+ {@render decisionRow(item, false)}
2003
+ {:else if canDecide}
2004
+ <p class="text-xs text-base-content/70">
2005
+ Deciding in the full-size view.
2006
+ </p>
2007
+ {:else if verdictOf(item.id) || currentOf(item.id).note}
2008
+ <!-- mos-guqgs: a saveless surface (public share, print)
2009
+ renders the saved decision as an ANSWER — the same
2010
+ control, visibly disabled — never an editable form,
2011
+ never interactive-looking over a dead save. -->
2012
+ {@render decisionRow(item, false)}
2013
+ {/if}
2014
+ {/if}
2015
+ {#if !item.informational}
2016
+ <RoundHistory
2017
+ rounds={history}
2018
+ verdicts={VERDICTS}
2019
+ bind:selected={() => selectedRound(item, history),
2020
+ (r) => pickRound(item, r)}
2021
+ toggleLabel={(n) =>
2022
+ n === 1 ? "1 earlier round" : `${n} earlier rounds`}
2023
+ />
2024
+ {/if}
2025
+ </div>
2026
+ </div>
2027
+ </article>
2028
+ {/each}
2029
+ </div>
2030
+ </section>
2031
+ {/each}
2032
+
2033
+ {#if params.items.length > 0 && sections.every((s) => !s.items.some( (it) => visible(it.id), ))}
2034
+ <p class="py-6 text-sm text-base-content/70">
2035
+ Nothing in this filter.
2036
+ <button
2037
+ type="button"
2038
+ class="link link-hover ml-1"
2039
+ onclick={() => (filter = "all")}>Show all</button
2040
+ >
2041
+ </p>
2042
+ {/if}
2043
+ </div>
2044
+ </section>
2045
+
2046
+ <ReviewShortcuts
2047
+ bind:open={helpOpen}
2048
+ config={keysConfig}
2049
+ extraRows={canDecide ? [...BULK_HELP, ...EXTRA_HELP] : EXTRA_HELP}
2050
+ footnote="Keys act on the focused card. u (or Undo) clears this round's verdict; the note stays. Enter opens the card full size, where the same verdict keys work: after a bulk, a, x or r flips the card on screen. Shift+A and Shift+R decide every undecided card in the current filter; cards you already decided stay."
2051
+ />
2052
+
2053
+ {#if zoomItem}
2054
+ {@const zItem = itemsById.get(zoomItem)}
2055
+ {#if zItem}
2056
+ {@const at = zoomOrder.indexOf(zItem.id)}
2057
+ {@const words = textOf(zItem)}
2058
+ {#snippet zoomFooter(_f: MediaZoomFooterContext)}
2059
+ {#if words}
2060
+ <div
2061
+ class="mx-auto w-full max-w-[70ch] rounded-box border border-base-content/10 bg-base-100 p-4 text-base leading-relaxed whitespace-pre-line text-base-content"
2062
+ data-zoom-text
2063
+ >
2064
+ {words}
2065
+ </div>
2066
+ {/if}
2067
+ {@render zoomStatus()}
2068
+ {#if canDecide && viewingPrevious(zItem) && !zItem.informational}
2069
+ <p class="text-xs text-base-content/80">
2070
+ Previous version ({activeVersion(zItem)}) is read-only. Verdicts go on
2071
+ {currentVersionOf(zItem)}.
2072
+ </p>
2073
+ {@render zoomNav()}
2074
+ {:else if canDecide && !zItem.informational}
2075
+ <div class="flex w-full flex-col gap-1.5">
2076
+ {@render cardCopy(zItem)}
2077
+ {@render decisionRow(zItem, true)}
2078
+ </div>
2079
+ {:else if verdictOf(zItem.id) || currentOf(zItem.id).note}
2080
+ <!-- mos-guqgs: the saveless surface's saved decision, disabled. -->
2081
+ <div class="flex w-full flex-col gap-1.5">
2082
+ {@render cardCopy(zItem)}
2083
+ {@render decisionRow(zItem, true)}
2084
+ </div>
2085
+ {:else}
2086
+ {@render zoomNav()}
2087
+ {/if}
2088
+ {/snippet}
2089
+ <!-- Verdict keys the lightbox lets bubble act on the item shown; the
2090
+ lightbox itself is the dialog and owns focus. -->
2091
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2092
+ <div onkeydown={onZoomKeydown}>
2093
+ <MediaZoomLightbox
2094
+ items={zoomViews(zItem.id)}
2095
+ bind:index={() => zoomIndex, setZoomIndex}
2096
+ title={zItem.title}
2097
+ position={at >= 0 ? `card ${at + 1} of ${zoomOrder.length}` : ""}
2098
+ onClose={closeZoom}
2099
+ onNextItem={navStep(1)}
2100
+ onPrevItem={navStep(-1)}
2101
+ footer={zoomFooter}
2102
+ showHint={zItem.kind !== "text"}
2103
+ zIndex={60}
2104
+ class={zItem.kind === "text"
2105
+ ? words
2106
+ ? TEXT_ZOOM_WORDS
2107
+ : TEXT_ZOOM
2108
+ : ""}
2109
+ />
2110
+ </div>
2111
+ {/if}
2112
+ {/if}