@joewinke/jatui 0.36.3 → 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 (271) 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/dist/components/Button.svelte +8 -0
  267. package/dist/components/TextInput.svelte +39 -6
  268. package/dist/components/TextInput.svelte.d.ts +15 -7
  269. package/dist/components/basic/Checkbox.svelte +52 -14
  270. package/dist/components/basic/Checkbox.svelte.d.ts +11 -1
  271. package/package.json +11 -2
@@ -0,0 +1,1055 @@
1
+ <script lang="ts">
2
+ /**
3
+ * DiffReviewBlock — the Redline review archetype (docs/review-ux/
4
+ * proposal.md): per-section before/after diffs with accept / modify /
5
+ * reject (mos-a3ivi). Built for contract and copy reviews (a BAA/MSA pass):
6
+ * each section is a card showing the current text against the proposed
7
+ * text through jatui's HunkDiffView (word-level highlights; side by side
8
+ * when the block is wide, unified on a phone), and the reviewer decides it.
9
+ *
10
+ * The review chrome is the shared jatui review set (mos-lsmve.6, M2):
11
+ * VerdictControl + ReviewNote + RoundHistory per section, ReviewProgress +
12
+ * ReviewFilterBar on top, reviewKeys() for the one keyboard map and
13
+ * ReviewShortcuts rendered from the same key table. "Modify" opens an
14
+ * inline editor seeded with the proposed text; the reviewer's rewrite
15
+ * saves as `edited` and renders as a second diff (proposed → yours) so the
16
+ * agent reading feedback sees exactly what changed.
17
+ *
18
+ * State: `{ [itemId]: { verdict, note, edited, at, round?, history? } }`
19
+ * through ctx.saveState (see decisions.ts) — the flat fields are the
20
+ * current round; a section the team marks ready again (`team.readySince`,
21
+ * D7) opens a new round and the previous verdict + note lock into
22
+ * history. Without saveState (public link, print, tests) the block stays
23
+ * interactive and nothing persists; a failed save shows in the block with
24
+ * a Retry; on the read-only route the verdict chrome is hidden and the
25
+ * diffs stand alone.
26
+ */
27
+ import HunkDiffView from "../../../components/HunkDiffView.svelte"
28
+ import ReviewFilterBar from "../../../components/review/ReviewFilterBar.svelte"
29
+ import ReviewProgress from "../../../components/review/ReviewProgress.svelte"
30
+ import ReviewShortcuts from "../../../components/review/ReviewShortcuts.svelte"
31
+ import RoundHistory from "../../../components/review/RoundHistory.svelte"
32
+ import VerdictControl from "../../../components/review/VerdictControl.svelte"
33
+ import { buildReviewFilters } from "../../../components/review/reviewSummary"
34
+ import { isTypingTarget } from "../../../utils/keyboardShortcutRegistry"
35
+ import {
36
+ reviewKeys,
37
+ type NeedsYou,
38
+ type ReviewKeyRow,
39
+ type ReviewKeysOptions,
40
+ type VerdictOption,
41
+ } from "../../../actions/reviewKeys"
42
+ import { showConfirm } from "../../../stores/confirmDialog"
43
+ import ItemNote from "../../review/ItemNote.svelte"
44
+ import type { NoteAttachment } from "../../review/attachments.js"
45
+ import { diffLines } from "diff"
46
+ import { tick, untrack } from "svelte"
47
+ import type { BlockProps } from "../../types.js"
48
+ import BulkRejectForm from "../../review/BulkRejectForm.svelte"
49
+ import BulkUndoBar from "../../review/BulkUndoBar.svelte"
50
+ import { decideMany, undoMany, type BulkUndo } from "../../review/bulk.js"
51
+ import NoteToggle from "../../review/NoteToggle.svelte"
52
+ import TeamChip from "../../review/TeamChip.svelte"
53
+ import { historyRounds } from "../../review/roundLines.js"
54
+ import type { RoundView } from "../../review/rounds.js"
55
+ import { createStateSaver } from "../../review/saver.js"
56
+ import { teamRound } from "../../review/team.js"
57
+ import {
58
+ nextUndecided,
59
+ sortRanks,
60
+ visualItems,
61
+ } from "../../review/visualOrder.js"
62
+ import {
63
+ EDITED_MAX_CHARS,
64
+ diffRounds,
65
+ isEmptyDecision,
66
+ parseDecisions,
67
+ type DiffDecision,
68
+ type DiffVerdict,
69
+ } from "./decisions.js"
70
+ import type { DiffReviewItem, DiffReviewParams } from "./types.js"
71
+ import SectionTools from "../../SectionTools.svelte"
72
+
73
+ let { params, ctx }: BlockProps<DiffReviewParams> = $props()
74
+
75
+ const VERDICTS: VerdictOption[] = [
76
+ { id: "approve", label: "Accept", tone: "success", key: "a" },
77
+ {
78
+ id: "modify",
79
+ label: "Modify",
80
+ tone: "warning",
81
+ // m (mos-u8i35, Joe's flip keys); x stays as an alias, see onBlockKey.
82
+ key: "m",
83
+ opensNote: true,
84
+ },
85
+ { id: "reject", label: "Reject", tone: "error", key: "r", opensNote: true },
86
+ ]
87
+
88
+ /** Stored entries — seeded ONCE from the viewer's saved state (the host
89
+ * passes state at render, never streams it). */
90
+ let decisions = $state<Record<string, DiffDecision>>(
91
+ untrack(() => parseDecisions(ctx.state)),
92
+ )
93
+ let filter = $state("all")
94
+ let notesOpen = $state<Record<string, boolean>>({})
95
+ let editorsOpen = $state<Record<string, boolean>>({})
96
+ /** The verdict a section's ReviewNote reacts to, when it must NOT follow
97
+ * the saved one: typing a rewrite turns the section into a Modify, and
98
+ * that must not yank the caret out of the editor into the note. */
99
+ let noteVerdictPin = $state<Record<string, DiffVerdict | null>>({})
100
+ let helpOpen = $state(false)
101
+ let rootEl = $state<HTMLElement | null>(null)
102
+ let width = $state(0)
103
+
104
+ /**
105
+ * mos-guqgs: deciding requires a save channel, not merely a non-readonly
106
+ * ctx. `!ctx.readonly` left the share link and any saveless mount (where
107
+ * readonly was never passed) with live verdict controls over a save that
108
+ * could not happen. Same contract as checklist / compare / callout.
109
+ */
110
+ const canDecide = $derived(Boolean(ctx.saveState) && !ctx.readonly)
111
+ const saver = createStateSaver(() => ctx.saveState)
112
+
113
+ const unified = $derived(
114
+ params.layout === "unified"
115
+ ? true
116
+ : params.layout === "split"
117
+ ? false
118
+ : width < 720,
119
+ )
120
+
121
+ /** End every non-empty side with a newline so the last line diffs cleanly
122
+ * ("foo" vs "foo\n" would otherwise read as a change). */
123
+ function norm(text: string): string {
124
+ return text && !text.endsWith("\n") ? `${text}\n` : text
125
+ }
126
+
127
+ function lineStats(a: string, b: string) {
128
+ let removed = 0
129
+ let added = 0
130
+ for (const c of diffLines(norm(a), norm(b))) {
131
+ if (c.removed) removed += c.count ?? 1
132
+ else if (c.added) added += c.count ?? 1
133
+ }
134
+ return { removed, added }
135
+ }
136
+
137
+ const itemsById = $derived(
138
+ new Map<string, DiffReviewItem>(params.items.map((i) => [i.id, i])),
139
+ )
140
+
141
+ const sections = $derived.by(() => {
142
+ const named = (params.groups ?? [])
143
+ .map((g) => ({
144
+ id: g.id,
145
+ title: g.title,
146
+ items: params.items.filter((it) => it.group === g.id),
147
+ }))
148
+ .filter((g) => g.items.length > 0)
149
+ const listed = new Set(named.flatMap((g) => g.items.map((i) => i.id)))
150
+ const rest = params.items.filter((it) => !listed.has(it.id))
151
+ // Nothing published is ever hidden: ungrouped items get a catch-all.
152
+ return rest.length > 0
153
+ ? [
154
+ ...named,
155
+ {
156
+ id: "__other__",
157
+ title: named.length > 0 ? "Other" : "",
158
+ items: rest,
159
+ },
160
+ ]
161
+ : named
162
+ })
163
+
164
+ /** Each section's current round + locked history (pure read). The only
165
+ * round a section has is the team's readySince (D7). */
166
+ const views = $derived.by(() => {
167
+ const out: Record<string, RoundView> = {}
168
+ for (const item of params.items)
169
+ out[item.id] = diffRounds.view(decisions[item.id], teamRound(item.team))
170
+ return out
171
+ })
172
+
173
+ function currentOf(id: string): DiffDecision {
174
+ return (views[id]?.current ?? {}) as DiffDecision
175
+ }
176
+ function verdictOf(id: string): DiffVerdict | null {
177
+ return currentOf(id).verdict ?? null
178
+ }
179
+
180
+ /** A new round is open and this viewer has not decided it. */
181
+ function isRetest(item: DiffReviewItem): boolean {
182
+ if (verdictOf(item.id)) return false
183
+ return item.team?.status === "ready" || !!views[item.id]?.stale
184
+ }
185
+
186
+ const counts = $derived.by(() => {
187
+ const byVerdict: Record<string, number> = {}
188
+ let retest = 0
189
+ for (const item of params.items) {
190
+ const v = verdictOf(item.id)
191
+ if (v) byVerdict[v] = (byVerdict[v] ?? 0) + 1
192
+ else if (isRetest(item)) retest++
193
+ }
194
+ return { byVerdict, retest, total: params.items.length }
195
+ })
196
+
197
+ /** The chips read as states, "Accepted 40 / Rejected 12" (mos-u8i35): the
198
+ * reviewer scans one group and flips the outliers. */
199
+ const FILTER_VERDICTS: VerdictOption[] = VERDICTS.map((o) => ({
200
+ ...o,
201
+ label:
202
+ o.id === "approve"
203
+ ? "Accepted"
204
+ : o.id === "modify"
205
+ ? "Modified"
206
+ : "Rejected",
207
+ }))
208
+
209
+ const filters = $derived(
210
+ buildReviewFilters({
211
+ verdicts: FILTER_VERDICTS,
212
+ counts: counts.byVerdict,
213
+ total: counts.total,
214
+ retest: counts.retest,
215
+ }),
216
+ )
217
+
218
+ function visible(id: string): boolean {
219
+ if (filter === "all") return true
220
+ const v = verdictOf(id)
221
+ if (filter === "undecided") return !v
222
+ if (filter === "retest") {
223
+ const item = itemsById.get(id)
224
+ return !!item && isRetest(item)
225
+ }
226
+ return v === filter
227
+ }
228
+
229
+ function needsYou(id: string): NeedsYou {
230
+ const item = itemsById.get(id)
231
+ if (!item || verdictOf(id)) return false
232
+ return isRetest(item) ? "retest" : "undecided"
233
+ }
234
+
235
+ // --- saving -----------------------------------------------------------------
236
+
237
+ /** Apply one change under the section's current round (the first save in
238
+ * a new round locks the old one into history). Local only. */
239
+ function update(item: DiffReviewItem, patch: Record<string, unknown>) {
240
+ const next = diffRounds.save(
241
+ decisions[item.id],
242
+ { ...patch, at: new Date().toISOString() },
243
+ teamRound(item.team),
244
+ ) as DiffDecision
245
+ const updated = { ...decisions }
246
+ if (isEmptyDecision(next)) delete updated[item.id]
247
+ else updated[item.id] = next
248
+ decisions = updated
249
+ }
250
+
251
+ function flush(): Promise<void> {
252
+ return saver.save($state.snapshot(decisions) as Record<string, unknown>)
253
+ }
254
+
255
+ function persist(item: DiffReviewItem, patch: Record<string, unknown>) {
256
+ update(item, patch)
257
+ // A failure shows as the block's alert (saver.failed), not a throw.
258
+ flush().catch(() => {})
259
+ }
260
+
261
+ function unpinNote(id: string) {
262
+ if (!(id in noteVerdictPin)) return
263
+ const { [id]: _drop, ...rest } = noteVerdictPin
264
+ noteVerdictPin = rest
265
+ }
266
+
267
+ /** VerdictControl never re-sends the active verdict (mos-yntqm); Undo
268
+ * clears. A new Reject / Modify opens + focuses the note (ReviewNote,
269
+ * mos-calzz) — the note stays optional, the verdict is already saved.
270
+ * Modify also opens its rewrite editor; the note takes the caret (the
271
+ * ruling), the seeded editor waits one `e` away. A repeat KEY on a
272
+ * note-worthy verdict re-opens the note. */
273
+ function setVerdict(id: string, verdict: DiffVerdict) {
274
+ const item = itemsById.get(id)
275
+ if (!item || !canDecide) return
276
+ if (verdict === "modify") editorsOpen = { ...editorsOpen, [id]: true }
277
+ if (verdictOf(id) === verdict) {
278
+ unpinNote(id)
279
+ if (VERDICTS.find((o) => o.id === verdict)?.opensNote) openNote(id)
280
+ return
281
+ }
282
+ unpinNote(id)
283
+ // A line flipped to Reject after a bulk reject borrows that bulk's
284
+ // reason, so Esc can keep the reject with no extra typing (mos-u8i35).
285
+ if (verdict === "reject" && sharedRejectNote && !currentOf(id).note)
286
+ persist(item, { verdict, note: sharedRejectNote })
287
+ else persist(item, { verdict })
288
+ }
289
+
290
+ /** Clear the verdict; the note and any rewrite stay (a remark or a draft
291
+ * is not a verdict, and typing is never thrown away). */
292
+ function undoVerdict(id: string) {
293
+ const item = itemsById.get(id)
294
+ if (!item || !canDecide || !verdictOf(id)) return
295
+ unpinNote(id)
296
+ persist(item, { verdict: undefined })
297
+ }
298
+
299
+ function onNoteInput(id: string, text: string) {
300
+ const item = itemsById.get(id)
301
+ if (item) update(item, { note: text.slice(0, 2000) || undefined })
302
+ }
303
+
304
+ /** The note's uploaded clips (mos-rccb5): each upload adds its descriptor
305
+ * to the item's saved entry — the bytes are already in the artifact's
306
+ * private asset store by the time this fires. */
307
+ function onItemClips(id: string, clips: NoteAttachment[]) {
308
+ const item = itemsById.get(id)
309
+ if (item) persist(item, { attachments: clips })
310
+ }
311
+
312
+ /** ReviewNote's debounced save: rejects so the note shows "Not saved". */
313
+ const noteSave = $derived(ctx.saveState ? () => flush() : undefined)
314
+
315
+ /** Typing a rewrite IS a modify: the verdict follows the edit. Text equal
316
+ * to the proposal is no rewrite at all, so `edited` drops. */
317
+ function setEdited(item: DiffReviewItem, text: string) {
318
+ const prev = verdictOf(item.id)
319
+ if (prev !== "modify" && !(item.id in noteVerdictPin))
320
+ noteVerdictPin = { ...noteVerdictPin, [item.id]: prev }
321
+ persist(item, {
322
+ verdict: "modify",
323
+ edited:
324
+ norm(text) === norm(item.after)
325
+ ? undefined
326
+ : text.slice(0, EDITED_MAX_CHARS),
327
+ })
328
+ }
329
+
330
+ function resetEdit(item: DiffReviewItem) {
331
+ if (currentOf(item.id).edited === undefined) return
332
+ persist(item, { edited: undefined })
333
+ }
334
+
335
+ function noteVerdictOf(id: string): string | null {
336
+ return id in noteVerdictPin ? noteVerdictPin[id] : verdictOf(id)
337
+ }
338
+
339
+ function openNote(id: string) {
340
+ notesOpen = { ...notesOpen, [id]: true }
341
+ tick().then(() => document.getElementById(`noteinput-${id}`)?.focus())
342
+ }
343
+
344
+ function toggleNote(id: string) {
345
+ if (!canDecide) return
346
+ if (notesOpen[id]) notesOpen = { ...notesOpen, [id]: false }
347
+ else openNote(id)
348
+ }
349
+
350
+ function openEditor(id: string) {
351
+ if (!canDecide) return
352
+ editorsOpen = { ...editorsOpen, [id]: true }
353
+ tick().then(() => {
354
+ rootEl
355
+ ?.querySelector<HTMLTextAreaElement>(`#edit-${CSS.escape(id)}`)
356
+ ?.focus()
357
+ })
358
+ }
359
+
360
+ function closeEditor(id: string) {
361
+ editorsOpen = { ...editorsOpen, [id]: false }
362
+ rootEl
363
+ ?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(id)}"]`)
364
+ ?.focus()
365
+ }
366
+
367
+ /** a from the keyboard: accept, then move on to the next UNDECIDED
368
+ * section (mos-lsmve.12), or the plain next one when none is left. "Next"
369
+ * is captured BEFORE deciding: under the Undecided filter this section is
370
+ * about to hide. Reject and Modify open the note instead of advancing
371
+ * (mos-calzz). */
372
+ function keyVerdict(id: string, v: DiffVerdict) {
373
+ if (v !== "approve") return setVerdict(id, v)
374
+ const items = visualItems(rootEl)
375
+ const at = items.findIndex((el) => el.dataset.itemId === id)
376
+ const nextId = nextUndecided(
377
+ items.map((el) => el.dataset.itemId ?? ""),
378
+ id,
379
+ (other) => !!verdictOf(other),
380
+ )
381
+ const next =
382
+ items.find((el) => el.dataset.itemId === nextId) ??
383
+ (at >= 0 ? items[(at + 1) % items.length] : null)
384
+ setVerdict(id, v)
385
+ if (next && next.dataset.itemId !== id) tick().then(() => next.focus())
386
+ }
387
+
388
+ // --- bulk accept / reject (mos-nv4eb, mos-u8i35) --------------------------
389
+
390
+ /** Lines a bulk may decide: still undecided, not a retest (a line the
391
+ * team reopened deserves eyes — media-picker's Approve all rule), and
392
+ * shown under the active filter chip. A decided line is never touched. */
393
+ function bulkTargets(items: readonly DiffReviewItem[]): DiffReviewItem[] {
394
+ return items.filter(
395
+ (it) => !verdictOf(it.id) && !isRetest(it) && visible(it.id),
396
+ )
397
+ }
398
+
399
+ const remaining = $derived(bulkTargets(params.items))
400
+ const hasRisk = $derived(params.items.some((it) => it.risk))
401
+ const lowRisk = $derived(remaining.filter((it) => it.risk === "low"))
402
+
403
+ /** The last bulk, for its Undo (cleared by Undo, dismiss or the next bulk). */
404
+ let lastBulk = $state<{
405
+ undo: BulkUndo<DiffDecision>
406
+ count: number
407
+ } | null>(null)
408
+ /** The open bulk-reject form: which lines it will reject, and the control
409
+ * that opened it (focus goes back there on Cancel). */
410
+ let rejectAsk = $state<{ ids: string[]; opener: HTMLElement | null } | null>(
411
+ null,
412
+ )
413
+ /** The reason the last bulk reject gave, reused by a later single flip. */
414
+ let sharedRejectNote = $state<string | null>(null)
415
+
416
+ /** Decide every target in ONE save — the same save path as a single
417
+ * click, batched. Decisions stay drafts until Finish review. */
418
+ function decideTargets(
419
+ targets: readonly DiffReviewItem[],
420
+ verdict: "approve" | "reject",
421
+ note?: string,
422
+ ) {
423
+ if (!canDecide || targets.length === 0) return
424
+ for (const it of targets) unpinNote(it.id)
425
+ const { decisions: next, undo } = decideMany(
426
+ decisions,
427
+ targets.map((it) => ({ id: it.id, round: teamRound(it.team) })),
428
+ { verdict, note, at: new Date().toISOString(), save: diffRounds.save },
429
+ )
430
+ decisions = next
431
+ lastBulk = { undo, count: targets.length }
432
+ flush().catch(() => {})
433
+ }
434
+
435
+ function lineCount(n: number): string {
436
+ return n === 1 ? "1 line" : `${n} lines`
437
+ }
438
+
439
+ /** Page-level bulk accept (all remaining, or all low-risk): asks first. */
440
+ async function confirmAccept(targets: readonly DiffReviewItem[]) {
441
+ if (!canDecide || targets.length === 0) return
442
+ const ids = targets.map((it) => it.id)
443
+ const ok = await showConfirm({
444
+ title: `Accept ${lineCount(targets.length)}?`,
445
+ body: "You can still change any line after. Lines you already decided stay as they are.",
446
+ confirmLabel: "Accept",
447
+ cancelLabel: "Cancel",
448
+ danger: false,
449
+ timeoutMs: 0,
450
+ })
451
+ if (!ok) return
452
+ // Re-check after the dialog: a line decided meanwhile is not overwritten.
453
+ decideTargets(
454
+ bulkTargets(params.items).filter((it) => ids.includes(it.id)),
455
+ "approve",
456
+ )
457
+ }
458
+
459
+ /** Bulk reject (page or section): the inline form asks ONE shared note. */
460
+ function askReject(targets: readonly DiffReviewItem[]) {
461
+ if (!canDecide || targets.length === 0) return
462
+ const active = document.activeElement
463
+ rejectAsk = {
464
+ ids: targets.map((it) => it.id),
465
+ opener: active instanceof HTMLElement ? active : null,
466
+ }
467
+ }
468
+
469
+ const rejectCount = $derived(
470
+ rejectAsk
471
+ ? bulkTargets(params.items).filter((it) => rejectAsk!.ids.includes(it.id))
472
+ .length
473
+ : 0,
474
+ )
475
+
476
+ function confirmReject(note: string) {
477
+ if (!rejectAsk) return
478
+ const ids = rejectAsk.ids
479
+ rejectAsk = null
480
+ sharedRejectNote = note
481
+ decideTargets(
482
+ bulkTargets(params.items).filter((it) => ids.includes(it.id)),
483
+ "reject",
484
+ note,
485
+ )
486
+ // The lines are decided now: start the flip pass at the first one.
487
+ tick().then(() =>
488
+ visualItems(rootEl)
489
+ .find((el) => !el.hidden)
490
+ ?.focus(),
491
+ )
492
+ }
493
+
494
+ function cancelReject() {
495
+ const opener = rejectAsk?.opener
496
+ rejectAsk = null
497
+ tick().then(() => opener?.isConnected && opener.focus())
498
+ }
499
+
500
+ function undoBulk() {
501
+ if (!lastBulk || !canDecide) return
502
+ decisions = undoMany(decisions, lastBulk.undo)
503
+ lastBulk = null
504
+ flush().catch(() => {})
505
+ }
506
+
507
+ /** The card a key acts on: the one holding focus, inside this block. */
508
+ function focusedItemId(target: EventTarget | null): string | null {
509
+ const el =
510
+ target instanceof Element
511
+ ? target.closest<HTMLElement>("[data-item-id]")
512
+ : null
513
+ return el && rootEl?.contains(el) ? (el.dataset.itemId ?? null) : null
514
+ }
515
+
516
+ /** Block keys beyond the shared review map (mos-nv4eb, mos-u8i35):
517
+ * - Shift+A / Shift+R: accept / reject all remaining (both ask first);
518
+ * - x: Modify, kept as an alias of m for the old shared slot;
519
+ * - Enter in a REJECT line's note: save it and go back to the line, so a
520
+ * flip pass is r, type, Enter, j.
521
+ * Never with Ctrl / Meta / Alt; the keys never fire while typing (the
522
+ * note's Enter is the one deliberate exception). */
523
+ function onBlockKey(e: KeyboardEvent) {
524
+ if (e.ctrlKey || e.metaKey || e.altKey) return
525
+ if (e.defaultPrevented || e.isComposing || !canDecide) return
526
+ if (isTypingTarget(e.target)) {
527
+ const t = e.target as HTMLElement
528
+ if (e.key !== "Enter" || e.shiftKey || !t.id.startsWith("noteinput-"))
529
+ return
530
+ const id = t.id.slice("noteinput-".length)
531
+ if (verdictOf(id) !== "reject") return
532
+ e.preventDefault()
533
+ notesOpen = { ...notesOpen, [id]: false }
534
+ flush().catch(() => {})
535
+ rootEl
536
+ ?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(id)}"]`)
537
+ ?.focus()
538
+ return
539
+ }
540
+ if (e.shiftKey && (e.key === "A" || e.key === "R")) {
541
+ if (remaining.length === 0) return
542
+ e.preventDefault()
543
+ if (e.key === "A") void confirmAccept(remaining)
544
+ else askReject(remaining)
545
+ return
546
+ }
547
+ if (e.key === "x" && !e.shiftKey) {
548
+ const id = focusedItemId(e.target)
549
+ if (!id) return
550
+ e.preventDefault()
551
+ keyVerdict(id, "modify")
552
+ }
553
+ }
554
+
555
+ // On the block only, like the review map (`global: false`).
556
+ $effect(() => {
557
+ const el = rootEl
558
+ if (!el) return
559
+ el.addEventListener("keydown", onBlockKey)
560
+ return () => el.removeEventListener("keydown", onBlockKey)
561
+ })
562
+
563
+ const bulkKeyRows: ReviewKeyRow[] = $derived(
564
+ canDecide
565
+ ? [
566
+ {
567
+ keys: ["Shift", "A"],
568
+ description: "Accept all remaining lines (asks first)",
569
+ group: "Decide",
570
+ },
571
+ {
572
+ keys: ["Shift", "R"],
573
+ description: "Reject all remaining lines (asks for one note)",
574
+ group: "Decide",
575
+ },
576
+ {
577
+ keys: ["Enter"],
578
+ description: "In a reject note: save it and go back to the line",
579
+ group: "Decide",
580
+ },
581
+ ]
582
+ : [],
583
+ )
584
+
585
+ const keysConfig: ReviewKeysOptions = $derived({
586
+ verdicts: VERDICTS,
587
+ onVerdict: canDecide
588
+ ? (id: string, v: string) => keyVerdict(id, v as DiffVerdict)
589
+ : undefined,
590
+ onUndo: canDecide ? undoVerdict : undefined,
591
+ onNote: canDecide ? toggleNote : undefined,
592
+ onEdit: canDecide ? openEditor : undefined,
593
+ onOpen: canDecide ? (id: string) => openEditor(id) : undefined,
594
+ needsYou: canDecide ? needsYou : undefined,
595
+ onHelp: () => (helpOpen = true),
596
+ getItems: () => visualItems(rootEl),
597
+ global: false,
598
+ })
599
+
600
+ const VERDICT_WORD: Record<DiffVerdict, string> = {
601
+ approve: "accepted",
602
+ modify: "modified",
603
+ reject: "rejected",
604
+ }
605
+
606
+ const RISK_CLASS = {
607
+ low: "badge-ghost",
608
+ medium: "badge-warning",
609
+ high: "badge-error",
610
+ } as const
611
+
612
+ /** Visual sort: undecided first, then modified, accepted, rejected —
613
+ * through the frozen `ranks` snapshot, never live verdicts. */
614
+ const ORDER: Record<DiffVerdict, number> = {
615
+ modify: 1,
616
+ approve: 2,
617
+ reject: 3,
618
+ }
619
+
620
+ /** Frozen sort (mos-lsmve.12, ../../review/visualOrder): re-taken only on
621
+ * a filter change, so a section decided mid-session stays put. */
622
+ function snapshotRanks() {
623
+ return sortRanks(
624
+ params.items.map((it) => it.id),
625
+ verdictOf,
626
+ ORDER,
627
+ )
628
+ }
629
+ let ranks = $state<Record<string, number>>(untrack(snapshotRanks))
630
+ $effect(() => {
631
+ void filter
632
+ untrack(() => (ranks = snapshotRanks()))
633
+ })
634
+ </script>
635
+
636
+ <!-- The section cards are the interactive elements; jatui reviewKeys
637
+ (listening on this block only) acts on the focused card. -->
638
+ <section
639
+ bind:this={rootEl}
640
+ bind:clientWidth={width}
641
+ class="@container rounded-box border border-base-content/10 bg-base-100"
642
+ aria-label={params.title ?? "Diff review"}
643
+ use:reviewKeys={keysConfig}
644
+ >
645
+ {#if params.title || params.intro}
646
+ <header class="px-4 pt-4 pb-1 sm:px-5">
647
+ {#if params.title}
648
+ <div class="flex items-center gap-2">
649
+ <h2 class="min-w-0 text-lg font-semibold tracking-tight">
650
+ {params.title}
651
+ </h2>
652
+ <SectionTools {ctx} inline />
653
+ </div>
654
+ {/if}
655
+ {#if params.intro}
656
+ <p
657
+ class="mt-0.5 max-w-[70ch] text-sm whitespace-pre-line text-base-content/75"
658
+ >
659
+ {params.intro}
660
+ </p>
661
+ {/if}
662
+ </header>
663
+ {/if}
664
+
665
+ <div
666
+ class="flex flex-col gap-2 border-b border-base-content/10 px-4 py-2.5 sm:px-5"
667
+ >
668
+ <ReviewProgress
669
+ verdicts={VERDICTS}
670
+ counts={counts.byVerdict}
671
+ total={counts.total}
672
+ retest={counts.retest}
673
+ size="xs"
674
+ label="Review progress"
675
+ />
676
+ <div class="flex flex-wrap items-center gap-2">
677
+ <ReviewFilterBar
678
+ {filters}
679
+ bind:active={filter}
680
+ onHelp={() => (helpOpen = true)}
681
+ label="Filter sections"
682
+ />
683
+ {#if canDecide && remaining.length > 0}
684
+ <!-- Joe 2026-10-19: 52 lines clicked one by one. Low-risk first
685
+ (the safest bulk), then everything left. Both ask first. -->
686
+ <div class="ml-auto flex shrink-0 flex-wrap items-center gap-2">
687
+ {#if hasRisk && lowRisk.length > 0}
688
+ <button
689
+ type="button"
690
+ class="btn btn-ghost btn-xs rounded-field"
691
+ data-accept-low-risk
692
+ onclick={() => confirmAccept(lowRisk)}
693
+ >
694
+ Accept all low-risk
695
+ <span class="tabular-nums opacity-70">({lowRisk.length})</span>
696
+ </button>
697
+ {/if}
698
+ <button
699
+ type="button"
700
+ class="btn btn-outline btn-xs rounded-field"
701
+ aria-keyshortcuts="Shift+A"
702
+ data-accept-all
703
+ onclick={() => confirmAccept(remaining)}
704
+ >
705
+ Accept all remaining
706
+ <span class="tabular-nums opacity-70">({remaining.length})</span>
707
+ </button>
708
+ <!-- Joe 2026-10-19: "bulk accept OR reject, then flip the
709
+ outliers either way." Asks one shared note first. -->
710
+ <button
711
+ type="button"
712
+ class="btn btn-outline btn-error btn-xs rounded-field"
713
+ aria-keyshortcuts="Shift+R"
714
+ aria-expanded={!!rejectAsk}
715
+ data-reject-all
716
+ onclick={() => askReject(remaining)}
717
+ >
718
+ Reject all remaining
719
+ <span class="tabular-nums opacity-70">({remaining.length})</span>
720
+ </button>
721
+ </div>
722
+ {/if}
723
+ </div>
724
+ </div>
725
+
726
+ {#if rejectAsk && canDecide && rejectCount > 0}
727
+ <BulkRejectForm
728
+ count={rejectCount}
729
+ noun="line"
730
+ onConfirm={confirmReject}
731
+ onCancel={cancelReject}
732
+ />
733
+ {/if}
734
+
735
+ {#if lastBulk && canDecide}
736
+ {@const rejected = lastBulk.undo.verdict === "reject"}
737
+ <BulkUndoBar
738
+ message="{rejected ? 'Rejected' : 'Accepted'} {lineCount(
739
+ lastBulk.count,
740
+ )}. Flip any line with a or r."
741
+ tone={rejected ? "error" : "success"}
742
+ onUndo={undoBulk}
743
+ onDismiss={() => (lastBulk = null)}
744
+ />
745
+ {/if}
746
+
747
+ {#if saver.failed}
748
+ <div
749
+ 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"
750
+ role="alert"
751
+ data-save-error
752
+ >
753
+ <span>Your last change was not saved.</span>
754
+ <button
755
+ type="button"
756
+ class="btn btn-xs ml-auto rounded-field"
757
+ onclick={() => saver.retry()}>Retry</button
758
+ >
759
+ </div>
760
+ {/if}
761
+
762
+ <div class="px-4 pt-2 pb-5 sm:px-5">
763
+ {#if params.items.length === 0}
764
+ <p class="py-6 text-sm text-base-content/70">No changes to review.</p>
765
+ {/if}
766
+ {#each sections as section (section.id)}
767
+ {@const anyVisible = section.items.some((it) => visible(it.id))}
768
+ <section aria-label={section.title || "Sections"} hidden={!anyVisible}>
769
+ {#if section.title}
770
+ {@const sectionTargets = canDecide ? bulkTargets(section.items) : []}
771
+ <div class="mt-4 mb-2 flex flex-wrap items-center gap-2">
772
+ <h3 class="text-sm font-semibold text-base-content/80">
773
+ {section.title}
774
+ </h3>
775
+ {#if sectionTargets.length > 0}
776
+ <button
777
+ type="button"
778
+ class="btn btn-ghost btn-xs rounded-field text-base-content/70"
779
+ aria-label="Accept {lineCount(
780
+ sectionTargets.length,
781
+ )} left in {section.title}"
782
+ data-accept-section={section.id}
783
+ onclick={() => decideTargets(sectionTargets, "approve")}
784
+ >
785
+ Accept section
786
+ <span class="tabular-nums">({sectionTargets.length})</span>
787
+ </button>
788
+ <button
789
+ type="button"
790
+ class="btn btn-ghost btn-xs rounded-field text-error"
791
+ aria-label="Reject {lineCount(
792
+ sectionTargets.length,
793
+ )} left in {section.title}"
794
+ data-reject-section={section.id}
795
+ onclick={() => askReject(sectionTargets)}
796
+ >
797
+ Reject section
798
+ <span class="tabular-nums">({sectionTargets.length})</span>
799
+ </button>
800
+ {/if}
801
+ </div>
802
+ {/if}
803
+ <div class="flex flex-col gap-3">
804
+ {#each section.items as item (item.id)}
805
+ {@const d = currentOf(item.id)}
806
+ {@const v = d.verdict ?? null}
807
+ {@const stats = lineStats(item.before, item.after)}
808
+ {@const hasEdit = d.edited !== undefined}
809
+ {@const retest = isRetest(item)}
810
+ {@const history = historyRounds(
811
+ views[item.id]?.history ?? [],
812
+ diffRounds.roundLabel,
813
+ )}
814
+ <!-- The card is the j/k focus target (roving focus, as in
815
+ media-picker). -->
816
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
817
+ <article
818
+ data-item-id={item.id}
819
+ tabindex="0"
820
+ class="flex flex-col gap-2 rounded-box border bg-base-100 p-3 transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary motion-reduce:transition-none sm:p-4 {v ===
821
+ 'approve'
822
+ ? 'border-success ring-1 ring-success/40'
823
+ : v === 'modify'
824
+ ? 'border-warning/60'
825
+ : v === 'reject'
826
+ ? 'border-error/40 opacity-60 focus-within:opacity-100 hover:opacity-100'
827
+ : 'border-base-content/10'}"
828
+ style:order={ranks[item.id] ?? 0}
829
+ aria-label="{item.title}, {v
830
+ ? VERDICT_WORD[v]
831
+ : retest
832
+ ? 'to retest'
833
+ : 'undecided'}"
834
+ hidden={!visible(item.id)}
835
+ >
836
+ <div class="flex flex-wrap items-baseline gap-1.5">
837
+ <h4 class="text-sm leading-snug font-semibold">
838
+ {item.title}
839
+ </h4>
840
+ {#if item.risk}
841
+ <span class="badge badge-sm {RISK_CLASS[item.risk]}">
842
+ {item.risk} risk
843
+ </span>
844
+ {/if}
845
+ {#each item.tags ?? [] as t (t)}
846
+ <span class="badge badge-ghost badge-sm">{t}</span>
847
+ {/each}
848
+ {#if item.team || (retest && canDecide)}
849
+ <TeamChip team={item.team} retest={retest && canDecide} />
850
+ {/if}
851
+ {#if v}
852
+ <span
853
+ class="badge badge-sm font-medium {v === 'approve'
854
+ ? 'badge-success'
855
+ : v === 'modify'
856
+ ? 'badge-warning'
857
+ : 'badge-error'}"
858
+ >
859
+ {VERDICT_WORD[v]}
860
+ </span>
861
+ {/if}
862
+ </div>
863
+ {#if item.rationale}
864
+ <p
865
+ class="max-w-[80ch] text-xs leading-relaxed whitespace-pre-line text-base-content/75"
866
+ >
867
+ {item.rationale}
868
+ </p>
869
+ {/if}
870
+
871
+ <div
872
+ class="overflow-hidden rounded-box border border-base-content/10"
873
+ >
874
+ <div
875
+ class="flex items-center gap-2 border-b border-base-content/10 px-3 py-1.5 text-2xs text-base-content/70"
876
+ >
877
+ <span class="badge badge-error badge-sm font-mono"
878
+ >-{stats.removed}</span
879
+ >
880
+ <span class="badge badge-success badge-sm font-mono"
881
+ >+{stats.added}</span
882
+ >
883
+ <span class="ml-auto">
884
+ {item.before
885
+ ? item.after
886
+ ? "current → proposed"
887
+ : "proposed: delete this section"
888
+ : "proposed: new section"}
889
+ </span>
890
+ </div>
891
+ <HunkDiffView
892
+ oldText={norm(item.before)}
893
+ newText={norm(item.after)}
894
+ {unified}
895
+ />
896
+ </div>
897
+
898
+ {#if hasEdit}
899
+ <div
900
+ class="overflow-hidden rounded-box border border-warning/50"
901
+ >
902
+ <div
903
+ class="border-b border-base-content/10 px-3 py-1.5 text-2xs font-medium text-base-content/80"
904
+ >
905
+ Your rewrite (proposed → yours)
906
+ {#if v !== "modify"}
907
+ <span class="font-normal text-base-content/70"
908
+ >· draft, not applied unless Modify is chosen</span
909
+ >
910
+ {/if}
911
+ </div>
912
+ <HunkDiffView
913
+ oldText={norm(item.after)}
914
+ newText={norm(d.edited ?? "")}
915
+ {unified}
916
+ />
917
+ </div>
918
+ {/if}
919
+
920
+ <!-- mos-guqgs: the verdict row renders on every surface, but
921
+ VISIBLY DISABLED where nothing can save — the old gate hid
922
+ the row on read-only mounts yet left it fully live on
923
+ saveless ones (readonly=false, saveState=undefined), the
924
+ exact interactive-but-unsaved bug. Same contract as
925
+ checklist / compare / callout: no saveState, dead controls. -->
926
+ <div class="flex flex-wrap items-center gap-2">
927
+ <VerdictControl
928
+ options={VERDICTS}
929
+ value={v}
930
+ label="Verdict for {item.title}"
931
+ disabled={!canDecide}
932
+ onVerdict={(id) => setVerdict(item.id, id as DiffVerdict)}
933
+ onUndo={() => undoVerdict(item.id)}
934
+ />
935
+ <NoteToggle
936
+ open={!!notesOpen[item.id]}
937
+ hasNote={!!d.note}
938
+ disabled={!canDecide}
939
+ controls="noteinput-{item.id}"
940
+ itemTitle={item.title}
941
+ onToggle={() => toggleNote(item.id)}
942
+ />
943
+ </div>
944
+
945
+ {#if canDecide}
946
+ {#if editorsOpen[item.id]}
947
+ <div class="flex flex-col gap-1.5">
948
+ <label
949
+ class="text-xs font-medium text-base-content/80"
950
+ for="edit-{item.id}"
951
+ >
952
+ Your version of “{item.title}”
953
+ </label>
954
+ <textarea
955
+ id="edit-{item.id}"
956
+ class="textarea textarea-sm min-h-32 w-full resize-y rounded-field border-base-content/15 bg-base-200 text-sm leading-relaxed"
957
+ rows={Math.min(
958
+ 16,
959
+ Math.max(
960
+ 4,
961
+ (d.edited ?? item.after).split("\n").length + 1,
962
+ ),
963
+ )}
964
+ maxlength={EDITED_MAX_CHARS}
965
+ value={d.edited ?? item.after}
966
+ oninput={(e) => setEdited(item, e.currentTarget.value)}
967
+ onkeydown={(e) => {
968
+ if (e.key === "Escape") {
969
+ e.preventDefault()
970
+ closeEditor(item.id)
971
+ }
972
+ }}
973
+ ></textarea>
974
+ <div class="flex items-center gap-2">
975
+ <button
976
+ type="button"
977
+ class="btn btn-ghost btn-sm rounded-field"
978
+ disabled={!hasEdit}
979
+ onclick={() => resetEdit(item)}
980
+ >
981
+ Reset to proposed
982
+ </button>
983
+ <button
984
+ type="button"
985
+ class="btn btn-sm ml-auto rounded-field"
986
+ onclick={() => closeEditor(item.id)}
987
+ >
988
+ Done
989
+ </button>
990
+ </div>
991
+ </div>
992
+ {:else if v === "modify"}
993
+ <button
994
+ type="button"
995
+ class="btn btn-ghost btn-sm self-start rounded-field px-2"
996
+ onclick={() => openEditor(item.id)}
997
+ >
998
+ {hasEdit ? "Edit your rewrite" : "Edit the proposed text"}
999
+ </button>
1000
+ {/if}
1001
+
1002
+ <!-- The ONE composer in its per-item mode (mos-rccb5): the
1003
+ ReviewNote text leg is unchanged; the attach lane rides
1004
+ under it. -->
1005
+ <ItemNote
1006
+ artifactId={ctx.artifactId}
1007
+ note={{
1008
+ id: `noteinput-${item.id}`,
1009
+ value: d.note ?? "",
1010
+ verdict: noteVerdictOf(item.id),
1011
+ options: VERDICTS,
1012
+ round: views[item.id]?.round ?? null,
1013
+ label: `Note for ${item.title}`,
1014
+ placeholder: "Why, or what should change?",
1015
+ onInput: (t) => onNoteInput(item.id, t),
1016
+ onSave: noteSave,
1017
+ }}
1018
+ bind:open={() => !!notesOpen[item.id],
1019
+ (o) => (notesOpen = { ...notesOpen, [item.id]: o })}
1020
+ clips={currentOf(item.id).attachments ?? []}
1021
+ onClipsChange={(clips) => onItemClips(item.id, clips)}
1022
+ disabled={!canDecide}
1023
+ />
1024
+ {/if}
1025
+ <RoundHistory
1026
+ rounds={history}
1027
+ verdicts={VERDICTS}
1028
+ toggleLabel={(n) =>
1029
+ n === 1 ? "1 earlier round" : `${n} earlier rounds`}
1030
+ />
1031
+ </article>
1032
+ {/each}
1033
+ </div>
1034
+ </section>
1035
+ {/each}
1036
+
1037
+ {#if params.items.length > 0 && !params.items.some((it) => visible(it.id))}
1038
+ <p class="py-6 text-sm text-base-content/70">
1039
+ Nothing in this filter.
1040
+ <button
1041
+ type="button"
1042
+ class="link link-hover ml-1"
1043
+ onclick={() => (filter = "all")}>Show all</button
1044
+ >
1045
+ </p>
1046
+ {/if}
1047
+ </div>
1048
+ </section>
1049
+
1050
+ <ReviewShortcuts
1051
+ bind:open={helpOpen}
1052
+ config={keysConfig}
1053
+ extraRows={bulkKeyRows}
1054
+ footnote="Keys act on the focused section. Accept moves on to the next undecided one. Shift+A accepts and Shift+R rejects every undecided line in the current filter; lines you already decided stay. Then flip the outliers: j / k to move, a, r or m to change one line (x also means Modify). Decided sections sort to the end when you change the filter or reload. u (or Undo) clears the verdict; the note and your rewrite stay."
1055
+ />