@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,1616 @@
1
+ <script lang="ts">
2
+ /**
3
+ * BlocksArtifactView (origin mos-0ninw; jatui-vsuzi) — a whole blocks page:
4
+ * header chrome, the live-data poller, the BlockRenderer and the review
5
+ * moment (Finish review / Done reviewing).
6
+ *
7
+ * Everything host-specific arrives through ONE object, `adapter`
8
+ * (types.ts HostAdapter): the crumb and desk/next slots, comments, the
9
+ * composer and its upload leg, the review send + delivery poll, history,
10
+ * live data, Listen and the block services. A missing piece degrades to
11
+ * read-only, loudly — never to a control that looks live and is not.
12
+ *
13
+ * Key this component by artifact id in the host ({#key artifactId}): the
14
+ * poller's lifecycle is per ARTIFACT, and a SvelteKit host reuses
15
+ * same-route components across an in-app navigation.
16
+ *
17
+ * Server-rendered data is the initial blob (no poll flash); the poller
18
+ * replaces it from the first tick onward.
19
+ */
20
+ import ContextMenu from "../components/ContextMenu.svelte"
21
+ import ReviewProgress from "../components/review/ReviewProgress.svelte"
22
+ import { motionFade } from "../motion/transitions"
23
+ import { showConfirm } from "../stores/confirmDialog"
24
+ import { flushSync, tick } from "svelte"
25
+ import BlockRenderer from "./BlockRenderer.svelte"
26
+ import FoldedRounds from "./FoldedRounds.svelte"
27
+ import Glyph, { type GlyphName } from "./Glyph.svelte"
28
+ import HistoryScrubber from "./HistoryScrubber.svelte"
29
+ import NoteComposer from "./NoteComposer.svelte"
30
+ import { provideHostAdapter } from "./adapter.js"
31
+ import { blockTasksOf } from "./contractBlocks.js"
32
+ import {
33
+ createLiveDataPoller,
34
+ dataAgeLine,
35
+ dataAgeTitle,
36
+ } from "./liveDataPoller.js"
37
+ import { createPageReviewKeys, isPageChromeTarget } from "./pageReviewKeys.js"
38
+ import { blockDef } from "./registry.js"
39
+ import { createOpenNav, revealUnit, type JumpUnit } from "./openNav.js"
40
+ import { AUTO_SEND_DELAY_MS, createAutoSend } from "./autoSend.js"
41
+ import {
42
+ changedSinceSend,
43
+ decisionCardsOf,
44
+ itemVerdictsOf,
45
+ countReviewUnits,
46
+ pageReviewProgress,
47
+ sentAnswerLine,
48
+ takesBlockVerdict,
49
+ verdictsFromBlocks,
50
+ isItemReviewBlock,
51
+ openReviewUnits,
52
+ type OpenReviewUnit,
53
+ } from "./review.js"
54
+ import type {
55
+ BlocksSpec,
56
+ FoldedRound,
57
+ HostAdapter,
58
+ NoteAttachment,
59
+ SentAnswer,
60
+ SentItemsMap,
61
+ VerdictEntry,
62
+ } from "./types.js"
63
+
64
+ interface Props {
65
+ artifactId: string
66
+ version: number
67
+ title: string
68
+ summary: string | null
69
+ byline: string
70
+ spec: BlocksSpec | null
71
+ /** The host's services (types.ts HostAdapter). {} = read-only. */
72
+ adapter?: HostAdapter
73
+ /** The server-rendered live-data blob ({} until a data publish). */
74
+ data?: Record<string, unknown>
75
+ /** ISO string or null — when the data last changed. */
76
+ dataUpdatedAt?: string | null
77
+ /**
78
+ * Write <title>, description and robots into <svelte:head>. Default
79
+ * true (a page of its own); false when the view is embedded in a host
80
+ * page that owns its head.
81
+ */
82
+ head?: boolean
83
+ /** Robots meta for the head (a share surface adds noarchive). */
84
+ robotsContent?: string
85
+ /**
86
+ * Inline chrome on the title line (mos-3q1o3): the host's Details
87
+ * control renders right after the page title. Omitted on the share
88
+ * surface.
89
+ */
90
+ titleActions?: import("svelte").Snippet
91
+ /**
92
+ * The linked task's chip (mos-t9wr6), leading the byline row. Omitted on
93
+ * the share surface — a share link never shows the task.
94
+ */
95
+ taskLine?: import("svelte").Snippet
96
+ /**
97
+ * The host's Listen player (mos-3q1o3, mos-8fvt9): a pill at the right
98
+ * end of the meta line; opened, its bar takes a full-width row.
99
+ */
100
+ player?: import("svelte").Snippet
101
+ /**
102
+ * Controls for the frame this page sits in (mos-8fvt9): inside the desk
103
+ * drawer, "Open in new tab" + Close join the right end of the top row
104
+ * instead of a drawer bar of their own. Omitted everywhere else.
105
+ */
106
+ frameActions?: import("svelte").Snippet
107
+ /**
108
+ * Where this page's answers really go (mos-guqgs): /a/<slug>. When the
109
+ * surface has NO save plumbing but the spec carries answer-taking
110
+ * content, a read-only notice renders with this link — the "Open to
111
+ * answer" affordance. Null hides the link.
112
+ */
113
+ answerUrl?: string | null
114
+ /**
115
+ * The host's own wording for the read-only notice. `kind` is
116
+ * "read-only" (no save plumbing — a share link) or "unsent" (picks save
117
+ * but no review leg sends them). Default: jatui's neutral copy.
118
+ */
119
+ readOnlyNotice?: import("svelte").Snippet<
120
+ [{ kind: "read-only" | "unsent"; answerUrl: string | null }]
121
+ >
122
+ /**
123
+ * This page's folded earlier rounds: each with its items and every
124
+ * viewer's verdicts/notes, locked. Empty when the page superseded
125
+ * nothing (or the host's read failed).
126
+ */
127
+ foldedRounds?: FoldedRound[]
128
+ /** Start the folded rounds expanded (a host redirected an old slug here). */
129
+ foldedRoundsOpen?: boolean
130
+ /** Per-block state plumbing (omitted on the read-only share surface). */
131
+ savedBlocks?: Record<string, unknown> | null
132
+ onSaveBlock?: (payload: {
133
+ kind: "blocks"
134
+ blocks: Record<string, unknown>
135
+ }) => Promise<void>
136
+ /**
137
+ * Comments + the review moment (mos-kib2f). Omitted on the read-only
138
+ * share surface: no session → no drawer, no Done-reviewing button.
139
+ */
140
+ comments?: {
141
+ enabled: boolean
142
+ counts: Record<string, number>
143
+ /** This viewer already recorded review_completed for this version. */
144
+ reviewed: boolean
145
+ }
146
+ /**
147
+ * The jat task this page serves (mos-wywa3). When set, decision cards
148
+ * and reviewable blocks (takesBlockVerdict) collect answers and the
149
+ * "Send answers" section at the foot of the page sends them as ONE
150
+ * answer to the task. Null → the plain "Done reviewing" moment.
151
+ */
152
+ taskId?: string | null
153
+ /** Who the answer reaches (the publishing agent), for the send label. */
154
+ answerTo?: string | null
155
+ /**
156
+ * The server-seeded done state (mos-7j6r5): THIS viewer's last Send on
157
+ * THIS version, read by the page load from the review_completed event.
158
+ * Null when they never sent — a first visit. This is what makes a
159
+ * reload remember: without it the page offered "Finish review" again
160
+ * after every refresh, reading as still open when the task was done.
161
+ */
162
+ reviewAnswer?: SentAnswer | null
163
+ /**
164
+ * The linked task is closed/done (jat state, read server-side). With
165
+ * every item decided, the page shows the completed state — "Decided ·
166
+ * task closed" — even for a viewer who did not send.
167
+ */
168
+ taskClosed?: boolean
169
+ }
170
+
171
+ let {
172
+ artifactId,
173
+ version,
174
+ title,
175
+ summary,
176
+ byline,
177
+ spec,
178
+ adapter = {},
179
+ data = {},
180
+ dataUpdatedAt = null,
181
+ head = true,
182
+ robotsContent = "noindex, nofollow",
183
+ titleActions = undefined,
184
+ taskLine = undefined,
185
+ player = undefined,
186
+ frameActions = undefined,
187
+ answerUrl = null,
188
+ readOnlyNotice = undefined,
189
+ foldedRounds = [],
190
+ foldedRoundsOpen = false,
191
+ savedBlocks = null,
192
+ onSaveBlock = undefined,
193
+ comments = undefined,
194
+ taskId = null,
195
+ answerTo = null,
196
+ reviewAnswer = null,
197
+ taskClosed = false,
198
+ }: Props = $props()
199
+
200
+ // The adapter is read ONCE, at mount (key the view by artifact to swap
201
+ // hosts): every component below reads it from the context.
202
+ // svelte-ignore state_referenced_locally
203
+ const host: HostAdapter = adapter
204
+ provideHostAdapter(host)
205
+ const reviewService = host.review
206
+
207
+ // The title's right-click menu (MOS: "Copy reference for an agent", copy
208
+ // link / slug). Keyboard users need the same actions elsewhere in the host.
209
+ let titleMenu = $state({ open: false, x: 0, y: 0 })
210
+ const titleMenuItems = $derived(host.titleMenu?.items ?? [])
211
+ function openTitleMenu(e: MouseEvent) {
212
+ if (titleMenuItems.length === 0) return
213
+ e.preventDefault()
214
+ titleMenu = { open: true, x: e.clientX, y: e.clientY }
215
+ }
216
+
217
+ /** Finished in THIS tab: the next actions take focus once they render. */
218
+ let deskFocus = $state(false)
219
+
220
+ // svelte-ignore state_referenced_locally
221
+ const live = createLiveDataPoller({
222
+ fetch: host.liveData
223
+ ? () => host.liveData!.fetch({ artifactId })
224
+ : undefined,
225
+ intervalMs: host.liveData?.intervalMs,
226
+ initialData: data,
227
+ initialUpdatedAt: dataUpdatedAt,
228
+ })
229
+ $effect(() => {
230
+ live.start()
231
+ return () => live.stop()
232
+ })
233
+
234
+ /** Save-status line for the blocks header. */
235
+ let blocksStatus: "idle" | "saving" | "saved" | "failed" = $state("idle")
236
+
237
+ /**
238
+ * Does this spec carry anything a viewer ANSWERS (mos-guqgs)? Decision
239
+ * cards, item-review blocks (media-picker / diff-review / checklist),
240
+ * actions blocks, and the verdict-taking work-product blocks. A page with
241
+ * none of these is a pure read everywhere — no read-only notice needed.
242
+ */
243
+ const answerContent = $derived.by(() => {
244
+ if (!spec) return false
245
+ if (decisionCardsOf(spec).size > 0) return true
246
+ return spec.blocks.some(
247
+ (b) =>
248
+ isItemReviewBlock(b.type) ||
249
+ b.type === "actions" ||
250
+ takesBlockVerdict(b.type, b.params),
251
+ )
252
+ })
253
+ /**
254
+ * The read-only contract (mos-guqgs): a mount with no save plumbing that
255
+ * still carries answer-taking content must SAY SO — a visible notice with
256
+ * an "Open to answer" affordance, never interactive-looking controls over
257
+ * a save that will not happen.
258
+ */
259
+ const readOnly = $derived(!onSaveBlock && answerContent)
260
+
261
+ /** The host can record "Done reviewing" (HostAdapter.review.markReviewed). */
262
+ const canMarkReviewed = Boolean(reviewService?.markReviewed)
263
+
264
+ /** The "Done reviewing" moment: recorded once per (viewer, version). */
265
+ let reviewed = $state(false)
266
+ $effect(() => {
267
+ reviewed = comments?.reviewed ?? false
268
+ })
269
+ let reviewing = $state(false)
270
+
271
+ /**
272
+ * POST review_completed (session-only server-side; keys are refused).
273
+ *
274
+ * mos-3fl0h: with cards still open this is never silent. The press read
275
+ * as "this section is done" and the page answered "Review complete ✓" for
276
+ * the whole page. Now it asks first, and "Go to next open" walks to the
277
+ * first open card instead (the same jump the n key does).
278
+ */
279
+ /** The last "Done reviewing" failure, shown beside the control. */
280
+ let reviewError = $state<string | null>(null)
281
+
282
+ async function doneReviewing() {
283
+ // No host leg → nothing to record. Never a silent no-op behind a
284
+ // control: the button and the auto-send are both gated on this too.
285
+ if (reviewing || reviewed || !reviewService?.markReviewed) return
286
+ if (commentsReview && undecided > 0) {
287
+ const ok = await showConfirm({
288
+ title: "Finish with items still open?",
289
+ body:
290
+ undecided === 1
291
+ ? "1 card still has no pick. Finish anyway?"
292
+ : `${undecided} cards still have no pick. Finish anyway?`,
293
+ confirmLabel: "Finish anyway",
294
+ cancelLabel: "Go to next open",
295
+ danger: false,
296
+ timeoutMs: 0,
297
+ })
298
+ if (!ok) {
299
+ // Let the dialog close and hand focus back before the jump moves it.
300
+ await tick()
301
+ openNav.jump(1)
302
+ return
303
+ }
304
+ }
305
+ reviewing = true
306
+ reviewError = null
307
+ try {
308
+ // The host reads the viewer's saved state: land pending saves first.
309
+ await renderer?.flush()
310
+ await reviewService.markReviewed({ artifactId, version, taskId })
311
+ reviewed = true
312
+ deskFocus = true
313
+ } catch (err) {
314
+ reviewError =
315
+ err instanceof Error && err.message
316
+ ? err.message
317
+ : "Could not record that you finished. Try again."
318
+ } finally {
319
+ reviewing = false
320
+ }
321
+ }
322
+
323
+ // ---- Finish review (mos-wywa3): one answer to the linked jat task.
324
+ // Needs the host's send: without it the page never offers a Send it
325
+ // cannot make.
326
+ const reviewMode = $derived(
327
+ Boolean(taskId && onSaveBlock && comments?.enabled && reviewService?.send),
328
+ )
329
+ let renderer:
330
+ | {
331
+ flush: () => Promise<void>
332
+ failedSaveBlocks?: () => string[]
333
+ revealBlock?: (blockId: string) => boolean
334
+ }
335
+ | undefined = $state()
336
+ /**
337
+ * The renderer's live state, reported through onLocalChange. Null until
338
+ * the renderer mounts and reports; derivations read `effectiveBlocks`,
339
+ * which falls back to the server's saved map — the SAME map the renderer
340
+ * seeds itself from — so every count (progress, changed-after-send, the
341
+ * done state) is correct from the first paint, not only after hydration
342
+ * (mos-7j6r5: a reload must render its seeded state on the server too).
343
+ */
344
+ let localBlocks = $state<Record<string, unknown> | null>(null)
345
+ const effectiveBlocks = $derived(localBlocks ?? savedBlocks ?? {})
346
+ let finishText = $state("")
347
+ /** The Finish note's attachments — descriptors the host's upload leg
348
+ * already stored. */
349
+ let finishClips = $state<NoteAttachment[]>([])
350
+ let finishing = $state(false)
351
+ let finishError = $state<string | null>(null)
352
+ /**
353
+ * This viewer's answer was sent (mos-7j6r5): seeded from the server's
354
+ * `reviewAnswer` on load, so a reload remembers; updated in place by a
355
+ * live send so the tab never flips back to the pre-send state.
356
+ */
357
+ let answered = $state(reviewAnswer != null)
358
+ /** Who the server says got the answer (the page owner), once sent. */
359
+ let sentTo = $state<string | null>(reviewAnswer?.sentTo ?? null)
360
+ /** When it was sent (ISO), seeded or set by the live send. */
361
+ let sentAt = $state<string | null>(reviewAnswer?.sentAt ?? null)
362
+ /**
363
+ * Delivery (mos-bi8t0): the Send is QUEUED for the owner until the drain
364
+ * puts it in the owner's mailbox and stamps the event. "Sent to … ✓" waits
365
+ * for that stamp (or for jat recording the answer on the task); until then
366
+ * the page says "Sending to …" and polls the event. An answer saved before
367
+ * this field existed (no eventId) reads as sent, the way it always did.
368
+ */
369
+ let sentEventId = $state<string | null>(reviewAnswer?.eventId ?? null)
370
+ let deliveredAt = $state<string | null>(reviewAnswer?.deliveredAt ?? null)
371
+ let deliverable = $state<boolean>(reviewAnswer?.deliverable ?? true)
372
+ let deliveredNow = $state(false)
373
+ // A host with no deliveryStatus leg cannot report a delivery, so a Send
374
+ // reads as delivered — never a "Sending…" spinner nothing will ever stop.
375
+ const delivered = $derived(
376
+ deliveredNow ||
377
+ deliveredAt !== null ||
378
+ sentEventId === null ||
379
+ !reviewService?.deliveryStatus,
380
+ )
381
+ /** After this long without delivery, the page says so plainly. */
382
+ const DELIVERY_STALL_MS = 90_000
383
+ const DELIVERY_POLL_MS = 5_000
384
+ let nowMs = $state(Date.now())
385
+ const deliveryStalled = $derived(
386
+ answered &&
387
+ !delivered &&
388
+ (!deliverable ||
389
+ (sentAt !== null &&
390
+ nowMs - new Date(sentAt).getTime() > DELIVERY_STALL_MS)),
391
+ )
392
+ /** The verdicts as sent — the changed-after-send diff reads them. */
393
+ let sentVerdicts = $state<Record<string, VerdictEntry> | null>(
394
+ reviewAnswer?.verdicts ?? null,
395
+ )
396
+ let sentItems = $state<SentItemsMap | null>(reviewAnswer?.items ?? null)
397
+ /** The quiet done state's "Send again" reopens the composer. */
398
+ let composerOpen = $state(false)
399
+ /** The sent-summary block, focused after a send so keyboard focus lands
400
+ * on the state that replaced the composer (never dropped to <body>). */
401
+ let sentSummaryEl = $state<HTMLElement | null>(null)
402
+ // Re-seed whenever the server's answer changes (invalidateAll): the
403
+ // server read reflects every send this tab made, so the seed and a live
404
+ // send can never disagree for long.
405
+ $effect(() => {
406
+ const seed = reviewAnswer ?? null
407
+ answered = seed !== null
408
+ sentTo = seed?.sentTo ?? null
409
+ sentAt = seed?.sentAt ?? null
410
+ sentEventId = seed?.eventId ?? null
411
+ deliveredAt = seed?.deliveredAt ?? null
412
+ deliverable = seed?.deliverable ?? true
413
+ deliveredNow = false
414
+ sentVerdicts = seed?.verdicts ?? null
415
+ sentItems = seed?.items ?? null
416
+ composerOpen = false
417
+ })
418
+ const recipient = $derived(sentTo ?? answerTo ?? "the page owner")
419
+
420
+ // Poll the Send's delivery stamp while it is queued (mos-bi8t0). Stops
421
+ // the moment it lands, when the Send has no owner to reach, when the
422
+ // linked task is closed (mos-m3nfp — the answers were acted on; nobody
423
+ // is coming to stamp anything), or on unmount.
424
+ $effect(() => {
425
+ if (!answered || delivered || !sentEventId || !deliverable) return
426
+ if (taskClosed) return
427
+ const poll = reviewService?.deliveryStatus
428
+ if (!poll) return
429
+ const eventId = sentEventId
430
+ let stopped = false
431
+ const check = async () => {
432
+ nowMs = Date.now()
433
+ try {
434
+ const body = await poll({ artifactId, eventId })
435
+ if (stopped) return
436
+ if (body?.delivered && !stopped) {
437
+ deliveredAt = body.deliveredAt ?? new Date().toISOString()
438
+ }
439
+ } catch {
440
+ // Offline for a beat: the next tick retries.
441
+ }
442
+ }
443
+ const timer = window.setInterval(check, DELIVERY_POLL_MS)
444
+ void check()
445
+ return () => {
446
+ stopped = true
447
+ window.clearInterval(timer)
448
+ }
449
+ })
450
+
451
+ /**
452
+ * A drafted note the host keeps in the viewer's state (MOS: a cast from
453
+ * the desk under the reserved `desk:note` key — HostAdapter.review
454
+ * .draftNote). The send panel pre-fills from it and says where it came
455
+ * from; editing is free (drafts are editable).
456
+ */
457
+ const deskDraft = $derived(
458
+ reviewService?.draftNote?.(effectiveBlocks as Record<string, unknown>) ??
459
+ null,
460
+ )
461
+ /** The desk note this tab already sent (its text rode the answer): the
462
+ * same draft must not show again as pending in a re-send composer — a
463
+ * NEW cast after the send reads as different text and shows. */
464
+ let consumedDeskNote = $state<string | null>(null)
465
+ const pendingDeskDraft = $derived(
466
+ deskDraft && deskDraft.note !== consumedDeskNote ? deskDraft : null,
467
+ )
468
+ let deskDraftShown = $state(false)
469
+ $effect(() => {
470
+ // Pre-fill once, when the draft first arrives and the box is empty —
471
+ // never fight the reviewer's own typing.
472
+ if (!deskDraftShown && pendingDeskDraft && !finishText) {
473
+ finishText = pendingDeskDraft.note
474
+ deskDraftShown = true
475
+ }
476
+ })
477
+
478
+ /** The verdicts to send, built from the viewer's local state map — the
479
+ * same reading the server runs for a desk Send (mos-yra79). */
480
+ const verdicts = $derived(verdictsFromBlocks(spec, effectiveBlocks))
481
+ /** Reviewable units: items inside item-review blocks, not
482
+ * the blocks that hold them — the same units the census counts — plus
483
+ * every other block's verdict and decision pick. */
484
+ const units = $derived(countReviewUnits(spec, effectiveBlocks))
485
+ const progress = $derived(pageReviewProgress(units))
486
+ const decidedCount = $derived(
487
+ units.approved + units.needsWork + units.rejected + units.chose,
488
+ )
489
+ const undecided = $derived(Math.max(0, units.total - decidedCount))
490
+ /**
491
+ * The plain "Done reviewing" moment over a page with things to decide
492
+ * (mos-3fl0h): no linked task, but decision cards or verdict blocks the
493
+ * viewer can answer. The header carries the same live progress, the
494
+ * still-open chip and the n / shift+N walk as Finish review.
495
+ */
496
+ const commentsReview = $derived(
497
+ !reviewMode && Boolean(comments?.enabled && onSaveBlock) && units.total > 0,
498
+ )
499
+ /** Either review moment: the open-item walk is live in both. */
500
+ const walkOpen = $derived(reviewMode || commentsReview)
501
+
502
+ /**
503
+ * Picks save, but nothing sends or records them: the page carries
504
+ * answerable content and save plumbing, yet the host gave no review leg
505
+ * this page can use (no `review.send` for a linked task, no
506
+ * `review.markReviewed` for Done reviewing). The viewer is told, instead
507
+ * of answering into a page nobody reads.
508
+ */
509
+ const answersGoNowhere = $derived(
510
+ Boolean(onSaveBlock) &&
511
+ answerContent &&
512
+ !reviewMode &&
513
+ !(canMarkReviewed && comments?.enabled),
514
+ )
515
+
516
+ // ---- The seeded done state (mos-7j6r5) --------------------------------
517
+ // The item verdicts the viewer would send NOW, read by the SAME function
518
+ // the server ran at send time — so a difference below is a real changed
519
+ // verdict, never a derivation mismatch. And the diff itself: what changed
520
+ // since this viewer last sent (the "Send changes (n)" count).
521
+ const currentItems = $derived.by(() => {
522
+ if (!spec) return {} as SentItemsMap
523
+ return itemVerdictsOf(effectiveBlocks, spec)
524
+ })
525
+ const changed = $derived(
526
+ changedSinceSend(sentVerdicts, verdicts, sentItems, currentItems),
527
+ )
528
+ /** The quiet done state's one-line "what was sent". */
529
+ const sentSummaryLine = $derived(sentAnswerLine(sentVerdicts, sentItems))
530
+
531
+ /** The chip's date: "Oct 13" (the year joins when it is not this one). */
532
+ function shortDay(iso: string): string {
533
+ const d = new Date(iso)
534
+ if (!Number.isFinite(d.getTime())) return ""
535
+ const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" }
536
+ if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"
537
+ return d.toLocaleDateString(undefined, opts)
538
+ }
539
+
540
+ // ---- Still-open units (mos-cs31v) -------------------------------------
541
+ // The census counts units; openReviewUnits names them, in page order. The
542
+ // header chip, the n / shift+N walk and the Finish panel's jump cards all
543
+ // read this one list, so they can never disagree with the count.
544
+ const OPEN_CARDS_MAX = 10
545
+ const openUnits = $derived(openReviewUnits(spec, effectiveBlocks))
546
+ const openCount = $derived(openUnits.length)
547
+
548
+ // ---- Header rows (mos-8fvt9) ------------------------------------------
549
+ /** Row 1's right side: save status + the review moment's one control. */
550
+ const showReviewControl = $derived(
551
+ (onSaveBlock !== undefined && blocksStatus !== "idle") ||
552
+ reviewMode ||
553
+ Boolean(comments?.enabled),
554
+ )
555
+ /** The still-open chip (Finish review mode) — the meter carries its own. */
556
+ const showOpenChip = $derived(
557
+ walkOpen && !answered && openCount > 0 && !commentsReview,
558
+ )
559
+ /** Row 2's right side: the progress meter and/or the still-open walk. */
560
+ const showProgress = $derived(showOpenChip || commentsReview)
561
+ const firstOpenCards = $derived(openUnits.slice(0, OPEN_CARDS_MAX))
562
+
563
+ /** What a card calls a unit: its own label, else the block registry's
564
+ * name for the type, else the block id (a plain verdict block may carry
565
+ * no params title). */
566
+ function unitLabel(u: Pick<OpenReviewUnit, "label" | "blockId"> & { type?: string }): string {
567
+ return u.label || (u.type ? blockDef(u.type)?.title : undefined) || u.blockId
568
+ }
569
+
570
+ /** The placeholder tile's glyph, per block kind. */
571
+ function openIcon(type: string): GlyphName {
572
+ switch (type) {
573
+ case "checklist":
574
+ return "list-checks"
575
+ case "media-picker":
576
+ return "images"
577
+ case "diff-review":
578
+ return "file-diff"
579
+ case "callout":
580
+ return "circle-help"
581
+ default:
582
+ return "badge-check"
583
+ }
584
+ }
585
+
586
+ /** The screen-reader line announcing a jump ("focus moves to the item,
587
+ * announced"): cleared, then set a beat later, so the SAME destination
588
+ * twice still announces. */
589
+ let jumpAnnouncement = $state("")
590
+ function announceJump(unit: JumpUnit) {
591
+ const label = unitLabel(unit)
592
+ const rest =
593
+ openCount === 1 ? "the last open item" : `${openCount} items still open`
594
+ jumpAnnouncement = ""
595
+ window.setTimeout(() => {
596
+ jumpAnnouncement = label ? `Jumped to ${label}. ${rest}.` : rest + "."
597
+ }, 60)
598
+ }
599
+
600
+ /** The jump cursor (openNav.ts): next/previous over the open list, and a
601
+ * direct jump for a Finish card click. One shared instance — its cursor
602
+ * outlives re-renders and inherits forward when the item under it is
603
+ * decided. */
604
+ const openNav = createOpenNav({
605
+ getUnits: () => openUnits,
606
+ onJump: (unit) => {
607
+ // A card in a collapsed group is hidden, not gone: open its group
608
+ // and let the DOM catch up before the jump looks for it.
609
+ if (renderer?.revealBlock?.(unit.blockId)) flushSync()
610
+ revealUnit(unit)
611
+ announceJump(unit)
612
+ },
613
+ })
614
+
615
+ /** n / shift+N walk the still-open items (mos-cs31v) — page-level keys
616
+ * under the same "not inside a block" rule as j / f / ? : inside a block
617
+ * the block's own n (open a note) wins. Dead outside a review moment. */
618
+ function openNavKeys(e: KeyboardEvent): boolean {
619
+ if (e.defaultPrevented || e.isComposing) return false
620
+ if (e.ctrlKey || e.metaKey || e.altKey) return false
621
+ if (!isPageChromeTarget(e.target)) return false
622
+ const dir =
623
+ e.key === "n" && !e.shiftKey ? 1 : e.key === "N" && e.shiftKey ? -1 : 0
624
+ if (dir === 0 || !walkOpen || answered) return false
625
+ e.preventDefault()
626
+ openNav.jump(dir)
627
+ return true
628
+ }
629
+
630
+ /**
631
+ * One final action (mos-lfkys): the "Send answers" section sits at the
632
+ * foot of the page, right under the last card, so answering the last
633
+ * decision puts the send button in reach. The header button only brings
634
+ * you there — it is never a second step of its own.
635
+ */
636
+ let sendButton: HTMLButtonElement | undefined = $state()
637
+ function goToSend() {
638
+ const target = document.getElementById("send-answers")
639
+ if (!target) return
640
+ const reduce = window.matchMedia?.(
641
+ "(prefers-reduced-motion: reduce)",
642
+ ).matches
643
+ target.scrollIntoView({
644
+ behavior: reduce ? "auto" : "smooth",
645
+ block: "center",
646
+ })
647
+ sendButton?.focus({ preventScroll: true })
648
+ }
649
+
650
+ async function finishReview() {
651
+ if (finishing) return
652
+ finishing = true
653
+ finishError = null
654
+ try {
655
+ await renderer?.flush()
656
+ // mos-lwbbt (pe-dusk-sweep): a save that failed (burst cap, offline)
657
+ // left the answers ONLY in this tab — the server has none of them.
658
+ // Sending now would answer the task with nothing and overwrite the
659
+ // viewer's saved state with an empty fold. Refuse until every block's
660
+ // save lands; the Retry lives on the failed block itself.
661
+ const failed = renderer?.failedSaveBlocks?.() ?? []
662
+ if (failed.length > 0) {
663
+ throw new Error(
664
+ "Some answers are not saved yet — use Retry on the highlighted block(s), then send again.",
665
+ )
666
+ }
667
+ if (!reviewService) throw new Error("Answers can't be sent from here.")
668
+ const body = await reviewService.send({
669
+ artifactId,
670
+ version,
671
+ taskId: taskId ?? null,
672
+ blockTasks: Object.fromEntries(blockTasksOf(spec)),
673
+ verdicts: $state.snapshot(verdicts) as Record<string, VerdictEntry>,
674
+ text: finishText.trim() || undefined,
675
+ ...(finishClips.length > 0
676
+ ? { attachments: $state.snapshot(finishClips) as NoteAttachment[] }
677
+ : {}),
678
+ })
679
+ if (!body?.answered) {
680
+ throw new Error(
681
+ body?.error ?? "Your answers could not be sent. Send again.",
682
+ )
683
+ }
684
+ sentTo = body.sentTo ?? null
685
+ sentAt = new Date().toISOString()
686
+ nowMs = Date.now()
687
+ // A host that tracks no delivery sends no delivery block: delivered.
688
+ sentEventId = body.delivery?.eventId ?? null
689
+ deliveredNow = body.delivery ? body.delivery.delivered === true : true
690
+ deliveredAt = null
691
+ deliverable = body.delivery?.deliverable ?? true
692
+ // The tab knows what it just sent — seed the diff from it (deep-copied
693
+ // out of the renderer's $state map) so the quiet state reads
694
+ // "unchanged" immediately, without waiting for a reload.
695
+ sentVerdicts = $state.snapshot(verdicts) as Record<string, VerdictEntry>
696
+ sentItems = $state.snapshot(currentItems) as SentItemsMap
697
+ answered = true
698
+ reviewed = true
699
+ deskFocus = true
700
+ composerOpen = false
701
+ // The answer went out with its note; a "Send again" starts a NEW
702
+ // answer, not a re-edit of the old one (the context line above the
703
+ // composer says what went before).
704
+ finishText = ""
705
+ finishClips = []
706
+ consumedDeskNote = deskDraft?.note ?? null
707
+ // Keyboard focus follows the state change: the composer just
708
+ // unmounted, so land focus ON the sent summary (announced to screen
709
+ // readers by its aria-live status line).
710
+ await tick()
711
+ sentSummaryEl?.focus()
712
+ } catch (err) {
713
+ finishError = err instanceof Error ? err.message : String(err)
714
+ } finally {
715
+ finishing = false
716
+ }
717
+ }
718
+
719
+ // ---- Auto-send when complete (mos-yra79) ------------------------------
720
+ // Deciding the LAST open item sends the page by itself after a short
721
+ // grace, through the page's own Send (Finish review) or Done reviewing.
722
+ // A page that loads already complete, or one partly decided, stays a
723
+ // draft. Undo (or Esc) keeps it a draft; typing restarts the grace.
724
+ const autoSend = createAutoSend({
725
+ onSend: () => (reviewMode ? finishReview() : doneReviewing()),
726
+ })
727
+ $effect(() => {
728
+ const complete = units.total > 0 && undecided === 0
729
+ const eligible = reviewMode
730
+ ? !answered && !finishing
731
+ : commentsReview && canMarkReviewed && !reviewed && !reviewing
732
+ autoSend.update(complete, eligible)
733
+ })
734
+ $effect(() => () => autoSend.stop())
735
+
736
+ /**
737
+ * Review keys from page load (mos-lsmve.13): with focus on <body>, j / f /
738
+ * ? / gg go to the first review block. Verdict keys are never routed.
739
+ */
740
+ const pageKeys = createPageReviewKeys({
741
+ getBlocks: () =>
742
+ (spec?.blocks ?? []).filter((b) => isItemReviewBlock(b.type)),
743
+ })
744
+
745
+ /** Measured view width, exposed as --artifact-page-w for wide blocks. */
746
+ let pageWidth = $state(0)
747
+ </script>
748
+
749
+ <svelte:head>
750
+ {#if head}
751
+ <title>{title}</title>
752
+ {#if summary}
753
+ <meta name="description" content={summary} />
754
+ {/if}
755
+ <!-- Never let an internal page be indexed or previewed by a link unfurler. -->
756
+ <meta name="robots" content={robotsContent} />
757
+ {/if}
758
+ </svelte:head>
759
+
760
+ <svelte:window
761
+ oninput={() => autoSend.bump()}
762
+ onkeydown={(e) => {
763
+ if (autoSend.pending && e.key === "Escape" && !e.defaultPrevented) {
764
+ e.preventDefault()
765
+ autoSend.undo()
766
+ return
767
+ }
768
+ if (pageKeys.handleKeydown(e)) return
769
+ openNavKeys(e)
770
+ }}
771
+ />
772
+
773
+ <!-- data-artifact-page + --artifact-page-w: wide blocks (a table with many
774
+ columns) may break out of the max-w-3xl reading column up to this view's
775
+ own measured width, never the viewport's (mos-8cbma). Measured, not a
776
+ CSS container: container-type would trap the drawers' position:fixed. -->
777
+ <div
778
+ class="bg-base-100 flex min-h-screen flex-col"
779
+ data-artifact-page
780
+ bind:clientWidth={pageWidth}
781
+ style:--artifact-page-w={pageWidth ? `${pageWidth}px` : undefined}
782
+ >
783
+ {#snippet openArrows()}
784
+ <button
785
+ type="button"
786
+ class="btn btn-ghost btn-sm rounded-field px-1.5"
787
+ aria-label="Previous open item (shift+N)"
788
+ title="Previous open item (shift+N)"
789
+ onclick={() => openNav.jump(-1)}
790
+ >
791
+ <Glyph name="chevron-up" size={16} />
792
+ </button>
793
+ <button
794
+ type="button"
795
+ class="btn btn-ghost btn-sm rounded-field px-1.5"
796
+ aria-label="Next open item (N)"
797
+ title="Next open item (N)"
798
+ onclick={() => openNav.jump(1)}
799
+ >
800
+ <Glyph name="chevron-down" size={16} />
801
+ </button>
802
+ {/snippet}
803
+
804
+ <header
805
+ class="border-base-content/10 bg-base-100 sticky top-[var(--rail-shell-top,0px)] z-10 border-b px-4 py-2.5 sm:px-6"
806
+ >
807
+ <!-- mos-8fvt9 (Joe 2026-10-20, "20% of the screen is chrome"): two rows,
808
+ three with the meta line. Each row is crumb/title/meta on the left
809
+ and its controls on the right:
810
+ 1. crumb ............ save status · review control · frame actions
811
+ 2. title + Details .................. progress meter / open chip
812
+ 3. task chip · byline · data age .................... Listen pill
813
+ An expanded Listen player takes a new full-width row under row 3;
814
+ collapsed it takes none. Every row is flex-wrap with a min left
815
+ width, so on a phone the right side drops under its left side,
816
+ still right-aligned. DOM order is the reading order: crumb → review
817
+ → frame actions → title → details → progress → meta → player. -->
818
+ <div class="mx-auto max-w-3xl">
819
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
820
+ <div class="min-w-[min(100%,8rem)] flex-1">
821
+ {#if host.crumb}
822
+ {@render host.crumb()}
823
+ {/if}
824
+ </div>
825
+ {#if showReviewControl || frameActions}
826
+ <div
827
+ class="ml-auto flex max-w-full min-w-0 items-center justify-end gap-2"
828
+ data-header-end
829
+ >
830
+ {#if showReviewControl}
831
+ <div
832
+ class="flex max-w-full min-w-0 flex-wrap items-center justify-end gap-2"
833
+ data-review-control
834
+ >
835
+ {#if onSaveBlock && blocksStatus !== "idle"}
836
+ <span
837
+ class="shrink-0 text-xs tabular-nums {blocksStatus ===
838
+ 'failed'
839
+ ? 'font-medium text-warning'
840
+ : 'text-base-content/40'}"
841
+ aria-live="polite"
842
+ >
843
+ {blocksStatus === "saving"
844
+ ? "Saving…"
845
+ : blocksStatus === "saved"
846
+ ? "Saved ✓"
847
+ : "Not saved. Retry on the block"}
848
+ </span>
849
+ {/if}
850
+ {#if reviewMode}
851
+ {#if answered && changed.total > 0}
852
+ <!-- Changed after sending (mos-7j6r5): the ONLY state where the
853
+ header carries a primary action again — the count is what
854
+ changed, and the panel below holds the composer. -->
855
+ <button
856
+ type="button"
857
+ class="btn btn-primary btn-sm rounded-field shrink-0"
858
+ aria-controls="send-answers"
859
+ onclick={goToSend}
860
+ >
861
+ Send changes
862
+ <span class="tabular-nums opacity-80"
863
+ >({changed.total})</span
864
+ >
865
+ </button>
866
+ {:else if answered && taskClosed && !delivered}
867
+ <!-- mos-m3nfp: the linked task closed with this viewer's answers
868
+ in — they were acted on. Never the delivery warning for a
869
+ review that is already over. -->
870
+ <span
871
+ class="border-success/30 bg-success/10 text-success inline-flex min-w-0 shrink-0 items-center gap-1.5 rounded-field border px-2.5 py-1 text-xs"
872
+ role="status"
873
+ title="The linked task ({taskId}) is closed. Your answers were received"
874
+ >
875
+ <span class="max-w-[9rem] truncate sm:max-w-none">
876
+ Answers received · task closed
877
+ </span>
878
+ </span>
879
+ {:else if answered && !delivered}
880
+ <!-- Queued, not yet with the owner (mos-bi8t0). A page no agent
881
+ owns (mos-m3nfp) says so plainly instead of warning about a
882
+ delivery that will never happen. -->
883
+ <span
884
+ class="inline-flex min-w-0 shrink-0 items-center gap-1.5 rounded-field border px-2.5 py-1 text-xs {!deliverable ||
885
+ !deliveryStalled
886
+ ? 'border-base-content/15 bg-base-200 text-base-content/70'
887
+ : 'border-warning/40 bg-warning/10 text-warning-content'}"
888
+ role="status"
889
+ title={!deliverable
890
+ ? "No agent owns this page. Nobody was notified"
891
+ : deliveryStalled
892
+ ? `Not delivered to ${recipient} yet. Your answers are saved.`
893
+ : `Sending to ${recipient}`}
894
+ >
895
+ {#if deliverable && !deliveryStalled}
896
+ <span class="loading loading-spinner loading-xs"></span>
897
+ {/if}
898
+ <span class="max-w-[9rem] truncate sm:max-w-none">
899
+ {!deliverable
900
+ ? "No agent owns this page"
901
+ : deliveryStalled
902
+ ? "Saved, not delivered yet"
903
+ : `Sending to ${recipient}…`}
904
+ </span>
905
+ </span>
906
+ {:else if answered}
907
+ <!-- Done (mos-7j6r5): a quiet status chip, never a primary
908
+ button — the review is over until the viewer changes a
909
+ verdict. Seeded from the server, so a reload keeps it. -->
910
+ <span
911
+ class="border-success/30 bg-success/10 text-success inline-flex min-w-0 shrink-0 items-center gap-1.5 rounded-field border px-2.5 py-1 text-xs"
912
+ role="status"
913
+ title="Sent to {recipient} ({taskId})"
914
+ >
915
+ <span class="max-w-[9rem] truncate sm:max-w-none">
916
+ Sent to {recipient} ✓
917
+ </span>
918
+ {#if sentAt}
919
+ <span class="text-success-content/70 tabular-nums">
920
+ · {shortDay(sentAt)}
921
+ </span>
922
+ {/if}
923
+ </span>
924
+ {:else if taskClosed && units.total > 0 && undecided === 0}
925
+ <!-- The task closed with every item decided (mos-7j6r5): the
926
+ completed state, even for a viewer who never sent. -->
927
+ <span
928
+ class="border-success/30 bg-success/10 text-success shrink-0 rounded-field border px-2.5 py-1 text-xs"
929
+ title="The linked task ({taskId}) is closed — your decisions stay saved on this page"
930
+ >
931
+ Decided · task closed
932
+ </span>
933
+ {:else}
934
+ <button
935
+ type="button"
936
+ class="btn btn-primary btn-sm rounded-field shrink-0"
937
+ aria-controls="send-answers"
938
+ onclick={goToSend}
939
+ >
940
+ Finish review
941
+ {#if units.total > 0}
942
+ <span class="tabular-nums opacity-80"
943
+ >{decidedCount}/{units.total}</span
944
+ >
945
+ {/if}
946
+ </button>
947
+ {/if}
948
+ {:else if comments?.enabled}
949
+ {#if reviewed && commentsReview && undecided > 0}
950
+ <!-- Recorded with items still open (an earlier press, or Finish
951
+ anyway): say so. "Review complete" is for a page with nothing
952
+ left to decide. -->
953
+ <span
954
+ class="border-base-content/15 bg-base-200 text-base-content/70 shrink-0 rounded-field border px-2.5 py-1 text-xs"
955
+ title="You marked this version done with items still open. Your picks still save"
956
+ data-reviewed-open
957
+ >
958
+ Marked done · {undecided} open
959
+ </span>
960
+ {:else if reviewed}
961
+ <span
962
+ class="border-success/30 bg-success/10 text-success shrink-0 rounded-field border px-2.5 py-1 text-xs"
963
+ title="Recorded for this version — republishing starts a fresh page"
964
+ >
965
+ Review complete ✓
966
+ </span>
967
+ {:else if reviewService?.markReviewed}
968
+ <button
969
+ type="button"
970
+ class="btn btn-outline btn-sm rounded-field shrink-0"
971
+ disabled={reviewing}
972
+ title={commentsReview && undecided > 0
973
+ ? `${undecided} still open. Tell the publisher you are done reviewing this version`
974
+ : "Tell the publisher you are done reviewing this version"}
975
+ onclick={doneReviewing}
976
+ >
977
+ {reviewing ? "Recording…" : "Done reviewing"}
978
+ </button>
979
+ {/if}
980
+ {#if reviewError && !reviewed}
981
+ <span
982
+ role="alert"
983
+ class="text-error max-w-[16rem] text-xs"
984
+ data-review-error>{reviewError}</span
985
+ >
986
+ {/if}
987
+ {#if reviewed && host.nextActions}
988
+ <!-- The cursor is still on the Done reviewing press, so
989
+ the next actions land right beside it. -->
990
+ {@render host.nextActions({
991
+ compact: true,
992
+ autofocus: deskFocus,
993
+ nextKey: false,
994
+ })}
995
+ {/if}
996
+ {/if}
997
+ </div>
998
+ {/if}
999
+ {#if frameActions}
1000
+ <div class="-mr-2 flex shrink-0 items-center" data-frame-actions>
1001
+ {@render frameActions()}
1002
+ </div>
1003
+ {/if}
1004
+ </div>
1005
+ {/if}
1006
+ </div>
1007
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
1008
+ <div class="flex min-w-[min(100%,16rem)] flex-1 items-start gap-2">
1009
+ <h1
1010
+ class="min-w-0 line-clamp-2 text-lg font-semibold break-words sm:text-xl"
1011
+ title={titleMenuItems.length > 0
1012
+ ? "Right-click for more actions"
1013
+ : undefined}
1014
+ oncontextmenu={openTitleMenu}
1015
+ >
1016
+ {title}
1017
+ </h1>
1018
+ {#if titleActions}
1019
+ <div class="flex shrink-0 items-center gap-2" data-title-actions>
1020
+ {@render titleActions()}
1021
+ </div>
1022
+ {/if}
1023
+ </div>
1024
+ {#if showProgress}
1025
+ <!-- mos-8fvt9: the progress meter and the still-open arrows sit
1026
+ on the title row, under the review control (Joe's mockup). -->
1027
+ <div
1028
+ class="ml-auto flex shrink-0 items-center justify-end gap-2"
1029
+ data-review-progress
1030
+ >
1031
+ {#if showOpenChip}
1032
+ <!-- Still-open chip (mos-cs31v): the count is live, and the arrows
1033
+ walk the open items without leaving the header. -->
1034
+ <div
1035
+ class="flex shrink-0 items-center gap-0.5"
1036
+ role="group"
1037
+ aria-label="Still-open items"
1038
+ >
1039
+ <span
1040
+ class="border-warning/30 bg-warning/10 text-warning rounded-field border px-2 py-1 text-xs font-medium tabular-nums"
1041
+ aria-live="polite"
1042
+ aria-atomic="true"
1043
+ >
1044
+ {openCount} open
1045
+ </span>
1046
+ {@render openArrows()}
1047
+ </div>
1048
+ {/if}
1049
+ {#if commentsReview}
1050
+ <!-- Live progress (mos-3fl0h): one card's pick is one step, and the
1051
+ header says how many are left — never only a page-wide
1052
+ button that reads as done after the first section. -->
1053
+ <div class="flex shrink-0 items-center gap-2" data-review-meter>
1054
+ <progress
1055
+ class="progress {undecided === 0
1056
+ ? 'progress-success'
1057
+ : 'progress-primary'} h-1.5 w-14 sm:w-20"
1058
+ value={decidedCount}
1059
+ max={units.total}
1060
+ aria-label="Review progress"
1061
+ ></progress>
1062
+ {#if !answered && openCount > 0}
1063
+ <!-- mos-7h9kt: the count itself goes to the next open item —
1064
+ "2 of 3 decided" over a page whose open item sits far
1065
+ below the fold read as "nothing left to press". -->
1066
+ <button
1067
+ type="button"
1068
+ class="text-base-content/70 hover:text-base-content rounded-field text-xs tabular-nums underline-offset-2 hover:underline"
1069
+ title="Go to the next open item (N)"
1070
+ data-review-count
1071
+ onclick={() => openNav.jump(1)}
1072
+ >
1073
+ <span aria-live="polite" aria-atomic="true"
1074
+ >{decidedCount} of {units.total} decided</span
1075
+ >
1076
+ </button>
1077
+ {:else}
1078
+ <span
1079
+ class="text-base-content/70 text-xs tabular-nums"
1080
+ aria-live="polite"
1081
+ aria-atomic="true"
1082
+ data-review-count
1083
+ >
1084
+ {decidedCount} of {units.total} decided
1085
+ </span>
1086
+ {/if}
1087
+ {#if !answered && openCount > 0}
1088
+ <div
1089
+ class="flex items-center gap-0.5"
1090
+ role="group"
1091
+ aria-label="Still-open items"
1092
+ >
1093
+ {@render openArrows()}
1094
+ </div>
1095
+ {/if}
1096
+ </div>
1097
+ {/if}
1098
+ </div>
1099
+ {/if}
1100
+ </div>
1101
+ {#if summary}
1102
+ <p class="text-base-content/70 mt-0.5 line-clamp-2 text-sm">
1103
+ {summary}
1104
+ </p>
1105
+ {/if}
1106
+ <div
1107
+ class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1"
1108
+ data-meta-row
1109
+ >
1110
+ <div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
1111
+ {#if taskLine}
1112
+ {@render taskLine()}
1113
+ {/if}
1114
+ <p class="text-base-content/50 text-xs">{byline}</p>
1115
+ {#if dataAgeLine(live)}
1116
+ <p
1117
+ class="text-base-content/40 text-xs tabular-nums"
1118
+ title={dataAgeTitle(live)}
1119
+ >
1120
+ {dataAgeLine(live)}
1121
+ </p>
1122
+ {/if}
1123
+ </div>
1124
+ {#if player}
1125
+ <!-- Collapsed: a pill at the end of the meta line. Expanded: the
1126
+ bar takes a full-width row of its own (see <style>). -->
1127
+ <div class="meta-player ml-auto min-w-0" data-artifact-player>
1128
+ {@render player()}
1129
+ </div>
1130
+ {/if}
1131
+ </div>
1132
+ </div>
1133
+ </header>
1134
+ {#if titleMenuItems.length > 0}
1135
+ <ContextMenu
1136
+ bind:open={titleMenu.open}
1137
+ x={titleMenu.x}
1138
+ y={titleMenu.y}
1139
+ header={host.titleMenu?.header}
1140
+ items={titleMenuItems}
1141
+ />
1142
+ {/if}
1143
+
1144
+ <main class="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6">
1145
+ {#if readOnly || answersGoNowhere}
1146
+ <!-- Never look answerable over an answer that goes nowhere: a mount
1147
+ with no save plumbing (a share link), or one that saves picks but
1148
+ has no host leg to send or record them, says so here. The host
1149
+ may word it itself (readOnlyNotice). -->
1150
+ <aside
1151
+ role="note"
1152
+ class="border-base-content/15 bg-base-200 rounded-box mb-4 border p-3 text-sm"
1153
+ data-read-only-notice={readOnly ? "read-only" : "unsent"}
1154
+ >
1155
+ {#if readOnlyNotice}
1156
+ {@render readOnlyNotice({
1157
+ kind: readOnly ? "read-only" : "unsent",
1158
+ answerUrl,
1159
+ })}
1160
+ {:else if readOnly}
1161
+ <p class="font-medium">Read-only view</p>
1162
+ <p class="text-base-content/70 mt-0.5">
1163
+ Answers aren't saved on this view.
1164
+ {#if answerUrl}
1165
+ <a href={answerUrl} class="link link-primary">Open to answer</a>.
1166
+ {:else}
1167
+ Open the page to answer.
1168
+ {/if}
1169
+ </p>
1170
+ {:else}
1171
+ <p class="font-medium">Your picks save, but go nowhere yet</p>
1172
+ <p class="text-base-content/70 mt-0.5">
1173
+ They stay on this page for you. Nobody is notified from here.
1174
+ {#if answerUrl}
1175
+ <a href={answerUrl} class="link link-primary">Open to answer</a>.
1176
+ {/if}
1177
+ </p>
1178
+ {/if}
1179
+ </aside>
1180
+ {/if}
1181
+ {#if foldedRounds.length > 0}
1182
+ <!-- mos-guqgs scope 3: the rounds this page superseded, locked. -->
1183
+ <FoldedRounds rounds={foldedRounds} initiallyOpen={foldedRoundsOpen} />
1184
+ {/if}
1185
+ {#if spec && comments && host.history}
1186
+ <!-- History scrubber: session surfaces only (the host's service);
1187
+ renders nothing without a trail. -->
1188
+ <HistoryScrubber
1189
+ {artifactId}
1190
+ service={host.history}
1191
+ currentData={live.data}
1192
+ currentVersion={version}
1193
+ />
1194
+ {/if}
1195
+ {#if spec}
1196
+ <BlockRenderer
1197
+ bind:this={renderer}
1198
+ review={reviewMode || commentsReview}
1199
+ onLocalChange={(b) => (localBlocks = b)}
1200
+ {spec}
1201
+ {artifactId}
1202
+ {version}
1203
+ {savedBlocks}
1204
+ data={live.data}
1205
+ {onSaveBlock}
1206
+ readonly={!onSaveBlock}
1207
+ comments={comments?.enabled
1208
+ ? { enabled: true, counts: comments.counts }
1209
+ : undefined}
1210
+ onStatus={(s) => (blocksStatus = s)}
1211
+ />
1212
+ {:else}
1213
+ <!-- Malformed stored spec: a visible card, never a crash. -->
1214
+ <div
1215
+ role="alert"
1216
+ class="border-error/30 bg-error/10 text-error-content rounded-box border p-4 text-sm"
1217
+ >
1218
+ <p class="font-medium">This page's blocks spec could not be read.</p>
1219
+ <p class="text-error-content/80 mt-1">
1220
+ The stored version is malformed. Republishing the page fixes it.
1221
+ </p>
1222
+ </div>
1223
+ {/if}
1224
+ {#if spec && reviewMode}
1225
+ <section
1226
+ id="send-answers"
1227
+ class="border-base-content/10 bg-base-200 rounded-box mt-8 border p-4"
1228
+ aria-label="Send answers"
1229
+ >
1230
+ {#if answered && changed.total === 0 && !composerOpen}
1231
+ <!-- Done (mos-7j6r5): the sent summary — what was sent, when, to
1232
+ whom — never an empty composer waiting under an answer that
1233
+ already went. "Send again" stays a secondary here. -->
1234
+ <div
1235
+ class="flex flex-col gap-1.5"
1236
+ data-sent-summary
1237
+ bind:this={sentSummaryEl}
1238
+ tabindex="-1"
1239
+ >
1240
+ {#if delivered}
1241
+ <p
1242
+ class="text-success font-medium"
1243
+ role="status"
1244
+ aria-live="polite"
1245
+ >
1246
+ Sent to {recipient} ✓
1247
+ </p>
1248
+ {:else if taskClosed}
1249
+ <!-- mos-m3nfp: the linked task closed — the answers were acted
1250
+ on. Says received, not "not delivered"; no Send again (the
1251
+ review is over; the changed-verdict path still offers one
1252
+ when this viewer edits after the fact). -->
1253
+ <p
1254
+ class="text-success font-medium"
1255
+ role="status"
1256
+ aria-live="polite"
1257
+ >
1258
+ Answers received. The linked task is closed.
1259
+ </p>
1260
+ <p class="text-base-content/60 text-xs">
1261
+ Your decisions stay saved on this page.
1262
+ </p>
1263
+ {:else if deliveryStalled}
1264
+ <!-- The fallback (mos-bi8t0): the owner has not picked the
1265
+ answer up. Say so — never a "Sent ✓" it did not earn. -->
1266
+ <div
1267
+ class="alert alert-warning rounded-box text-sm"
1268
+ role="status"
1269
+ aria-live="polite"
1270
+ data-delivery-stalled
1271
+ >
1272
+ <div class="flex flex-col gap-1">
1273
+ <p class="font-medium">
1274
+ {deliverable
1275
+ ? `Not delivered to ${recipient} yet.`
1276
+ : "No agent owns this page, so no one was notified."}
1277
+ </p>
1278
+ <p>
1279
+ Your answers are saved on this page.
1280
+ {deliverable
1281
+ ? `They go to ${recipient} as soon as delivery runs. This page updates when they arrive.`
1282
+ : "Tell the person who sent you this page that you finished."}
1283
+ </p>
1284
+ </div>
1285
+ </div>
1286
+ {:else}
1287
+ <p
1288
+ class="text-base-content/70 inline-flex items-center gap-2 font-medium"
1289
+ role="status"
1290
+ aria-live="polite"
1291
+ >
1292
+ <span class="loading loading-spinner loading-xs"></span>
1293
+ Sending to {recipient}…
1294
+ </p>
1295
+ {/if}
1296
+ {#if sentSummaryLine}
1297
+ <p class="text-base-content/70 text-xs">{sentSummaryLine}</p>
1298
+ {/if}
1299
+ <p class="text-base-content/50 text-xs">
1300
+ {#if sentAt}{shortDay(sentAt)} ·
1301
+ {/if}<span class="font-mono">{taskId}</span>
1302
+ </p>
1303
+ {#if !taskClosed || host.nextActions}
1304
+ <div class="mt-2 flex flex-wrap items-end gap-2">
1305
+ {#if !taskClosed}
1306
+ <!-- Send again lives on every live review (a real stall
1307
+ keeps it, mos-bi8t0); a CLOSED task's review is over,
1308
+ so it steps aside here (mos-m3nfp). -->
1309
+ <button
1310
+ type="button"
1311
+ class="btn btn-outline btn-sm rounded-field"
1312
+ onclick={() => (composerOpen = true)}
1313
+ >
1314
+ Send again
1315
+ </button>
1316
+ {/if}
1317
+ {#if host.nextActions}
1318
+ <!-- On the right, where the Send press left the cursor. -->
1319
+ {@render host.nextActions({
1320
+ compact: false,
1321
+ autofocus: deskFocus,
1322
+ nextKey: true,
1323
+ })}
1324
+ {/if}
1325
+ </div>
1326
+ {/if}
1327
+ </div>
1328
+ {:else if !answered && taskClosed && units.total > 0 && undecided === 0}
1329
+ <!-- The task closed with every item decided (mos-7j6r5): the
1330
+ review moment is over even though this viewer never sent. -->
1331
+ <div class="flex flex-col gap-1.5" data-task-closed>
1332
+ <p class="text-success font-medium" role="status">
1333
+ Decided · task closed
1334
+ </p>
1335
+ <p class="text-base-content/60 text-xs">
1336
+ The linked task is closed. Your decisions stay saved on this page.
1337
+ </p>
1338
+ <p class="text-base-content/50 font-mono text-xs">{taskId}</p>
1339
+ {#if host.nextActions}
1340
+ <div class="mt-2 flex">
1341
+ {@render host.nextActions({
1342
+ compact: false,
1343
+ autofocus: false,
1344
+ nextKey: true,
1345
+ })}
1346
+ </div>
1347
+ {/if}
1348
+ </div>
1349
+ {:else}
1350
+ {#if answered}
1351
+ <!-- Re-send context (mos-7j6r5): what went before, quietly,
1352
+ above the composer that will send the changes as a NEW
1353
+ answer. -->
1354
+ <p class="text-base-content/60 text-xs" data-sent-context>
1355
+ {delivered ? "Sent to" : "Not yet delivered to"}
1356
+ {recipient}{sentAt
1357
+ ? ` on ${shortDay(sentAt)}`
1358
+ : ""}{sentSummaryLine ? ` (${sentSummaryLine})` : ""}. Your
1359
+ changes send as a new answer.
1360
+ </p>
1361
+ {/if}
1362
+ {#if units.total > 0}
1363
+ {#if progress.lines.length === 0}
1364
+ <ReviewProgress
1365
+ {...progress}
1366
+ size="sm"
1367
+ label="Review progress across the page"
1368
+ />
1369
+ {:else}
1370
+ <!-- Mixed vocabularies (mos-lsmve.14): no one set of words is
1371
+ on the page, so the bar sums by tone and each block keeps
1372
+ its own sentence — never "approved" for a Pass or an Accept. -->
1373
+ <ReviewProgress
1374
+ {...progress}
1375
+ size="sm"
1376
+ hideSentence
1377
+ label="Review progress across the page: {progress.headline}"
1378
+ />
1379
+ <p
1380
+ class="text-base-content/70 mt-1.5 text-xs tabular-nums"
1381
+ aria-live="polite"
1382
+ >
1383
+ {progress.headline}
1384
+ </p>
1385
+ <ul
1386
+ class="mt-2 flex flex-col gap-2"
1387
+ aria-label="Progress by block"
1388
+ >
1389
+ {#each progress.lines as line (line.key)}
1390
+ <li class="flex flex-col gap-0.5">
1391
+ <span
1392
+ class="text-base-content/80 truncate text-xs font-medium"
1393
+ >{line.title}</span
1394
+ >
1395
+ <ReviewProgress
1396
+ verdicts={line.verdicts}
1397
+ counts={line.counts}
1398
+ total={line.total}
1399
+ retest={line.retest}
1400
+ decidedWord={line.decidedWord}
1401
+ size="xs"
1402
+ live={false}
1403
+ label="{line.title} review progress"
1404
+ />
1405
+ </li>
1406
+ {/each}
1407
+ </ul>
1408
+ {/if}
1409
+ {/if}
1410
+ {#if openUnits.length > 0}
1411
+ <!-- Still-open jump cards (mos-cs31v): nobody scrolls back up
1412
+ hunting "1 still open" — the open items are HERE, one click
1413
+ from being in front of you. -->
1414
+ <div class="@container mt-3">
1415
+ <ul
1416
+ class="grid grid-cols-2 gap-2 @xl:grid-cols-3"
1417
+ aria-label="Still-open items"
1418
+ >
1419
+ {#each firstOpenCards as u (u.key)}
1420
+ <li>
1421
+ <button
1422
+ type="button"
1423
+ class="border-base-content/10 bg-base-100 hover:border-primary focus-visible:border-primary flex w-full items-center gap-2 rounded-box border p-1.5 text-left transition-colors"
1424
+ onclick={() => openNav.jumpTo(u)}
1425
+ aria-label="Jump to {unitLabel(u)}"
1426
+ >
1427
+ {#if u.thumb}
1428
+ <img
1429
+ src={u.thumb}
1430
+ alt=""
1431
+ loading="lazy"
1432
+ class="bg-base-content/5 h-10 w-10 shrink-0 rounded-field object-cover"
1433
+ />
1434
+ {:else}
1435
+ <span
1436
+ class="bg-base-content/5 text-base-content/40 flex h-10 w-10 shrink-0 items-center justify-center rounded-field"
1437
+ aria-hidden="true"
1438
+ >
1439
+ <Glyph name={openIcon(u.type)} size={16} />
1440
+ </span>
1441
+ {/if}
1442
+ <span class="min-w-0 flex-1">
1443
+ <span class="block truncate text-xs font-medium">
1444
+ {unitLabel(u)}
1445
+ </span>
1446
+ {#if u.context && u.label}
1447
+ <span
1448
+ class="text-base-content/50 text-2xs block truncate"
1449
+ >
1450
+ {u.context}
1451
+ </span>
1452
+ {/if}
1453
+ {#if u.retest}
1454
+ <span class="text-warning text-2xs block font-medium">
1455
+ to retest
1456
+ </span>
1457
+ {/if}
1458
+ </span>
1459
+ </button>
1460
+ </li>
1461
+ {/each}
1462
+ {#if openUnits.length > OPEN_CARDS_MAX}
1463
+ <li class="col-span-2 flex items-center gap-2 @xl:col-span-3">
1464
+ <p class="text-base-content/60 text-xs">
1465
+ and {openUnits.length - OPEN_CARDS_MAX} more…
1466
+ </p>
1467
+ <button
1468
+ type="button"
1469
+ class="btn btn-outline btn-xs rounded-field ml-auto"
1470
+ onclick={() => openNav.jump(1)}
1471
+ >
1472
+ Next open
1473
+ </button>
1474
+ </li>
1475
+ {/if}
1476
+ </ul>
1477
+ </div>
1478
+ {/if}
1479
+ {#if pendingDeskDraft}
1480
+ <!-- The desk's draft (mos-chsj7 D2): what will be sent, said up
1481
+ front — the note pre-fills below and edits freely. -->
1482
+ <p
1483
+ class="border-info/30 bg-info/10 text-info-content mt-3 rounded-field border px-3 py-2 text-xs"
1484
+ data-desk-draft
1485
+ >
1486
+ {pendingDeskDraft.label}
1487
+ </p>
1488
+ {/if}
1489
+ <!-- The ONE composer (mos-rccb5): the Finish note carries text,
1490
+ voice/video/screen recordings and files (pasted, dropped or
1491
+ picked), stored in the artifact's private asset store and sent
1492
+ with the ONE answer. No submit row of its own — Send answers
1493
+ below is the page's one final action and drives the send. The
1494
+ desk-draft prefill (above) writes `finishText`, which the
1495
+ composer binds as its draft text — D2's semantics unchanged. -->
1496
+ <NoteComposer
1497
+ {artifactId}
1498
+ class="mt-3"
1499
+ bind:text={finishText}
1500
+ bind:clips={finishClips}
1501
+ rows={2}
1502
+ maxLength={4000}
1503
+ placeholder="Anything else to say? (optional)"
1504
+ label="Note with your answers (optional)"
1505
+ autofocus={composerOpen}
1506
+ />
1507
+ {#if finishError}
1508
+ <div
1509
+ role="alert"
1510
+ class="border-error/30 bg-error/10 text-error-content rounded-box mt-2 border p-2 text-sm"
1511
+ >
1512
+ {finishError}
1513
+ </div>
1514
+ {/if}
1515
+ <div
1516
+ class="mt-3 flex flex-wrap items-center justify-end gap-x-3 gap-y-2"
1517
+ >
1518
+ <p class="text-base-content/60 mr-auto min-w-0 text-xs">
1519
+ {#if undecided > 0}
1520
+ {undecided} still open. Send now or keep going.
1521
+ {:else if deliverable}
1522
+ Goes to {recipient}
1523
+ {:else}
1524
+ <!-- mos-m3nfp: never promise a recipient nobody can be. -->
1525
+ No agent owns this page. Answers save here
1526
+ {/if}
1527
+ <span class="text-base-content/40 font-mono">· {taskId}</span>
1528
+ </p>
1529
+ <button
1530
+ type="button"
1531
+ bind:this={sendButton}
1532
+ class="btn btn-sm rounded-field {undecided > 0
1533
+ ? 'btn-outline'
1534
+ : 'btn-primary'}"
1535
+ disabled={finishing}
1536
+ onclick={finishReview}
1537
+ >
1538
+ {finishing
1539
+ ? "Sending…"
1540
+ : changed.total > 0
1541
+ ? `Send changes (${changed.total})`
1542
+ : answered
1543
+ ? "Send again"
1544
+ : finishError
1545
+ ? "Send again"
1546
+ : "Send answers"}
1547
+ </button>
1548
+ </div>
1549
+ {/if}
1550
+ </section>
1551
+ {/if}
1552
+ </main>
1553
+
1554
+ {#if autoSend.pending}
1555
+ <!-- Auto-send (mos-yra79): polite, never steals focus; Undo and Send
1556
+ now are both reachable by Tab, and Esc undoes. The live text is
1557
+ announced once; the ticking seconds are visual only. -->
1558
+ <div
1559
+ class="fixed inset-x-0 bottom-4 z-40 flex justify-center px-4"
1560
+ transition:motionFade={{ duration: "fast" }}
1561
+ data-auto-send
1562
+ >
1563
+ <div
1564
+ class="bg-base-content text-base-100 rounded-box flex max-w-full flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 text-sm shadow-lg"
1565
+ >
1566
+ <p role="status" aria-live="polite">
1567
+ <span class="font-semibold">All decided.</span>
1568
+ <span class="sr-only"
1569
+ >Your answers send in {AUTO_SEND_DELAY_MS / 1000} seconds. Press Undo
1570
+ to keep them as a draft.</span
1571
+ >
1572
+ <span aria-hidden="true"
1573
+ >Sending in <span class="tabular-nums">{autoSend.remaining}s</span
1574
+ ></span
1575
+ >
1576
+ </p>
1577
+ <button
1578
+ type="button"
1579
+ class="btn btn-sm btn-ghost text-base-100 rounded-field"
1580
+ onclick={() => autoSend.undo()}
1581
+ data-auto-send-undo
1582
+ >
1583
+ Undo
1584
+ </button>
1585
+ <button
1586
+ type="button"
1587
+ class="btn btn-sm btn-primary rounded-field"
1588
+ onclick={() => autoSend.sendNow()}
1589
+ >
1590
+ Send now
1591
+ </button>
1592
+ </div>
1593
+ </div>
1594
+ {/if}
1595
+
1596
+ <!-- Jump announcements (mos-cs31v): the header chip's count is live, and
1597
+ focus lands ON the item, but the jump itself says where it landed —
1598
+ a card whose own label announces nothing still reports the jump. -->
1599
+ <p class="sr-only" role="status" aria-live="polite">
1600
+ {jumpAnnouncement}
1601
+ </p>
1602
+ </div>
1603
+
1604
+ <style>
1605
+ /* mos-8fvt9: the page's Listen pill rides the meta line; once opened,
1606
+ jatui's bar (.lc-bar) replaces the pill and the cell takes a new
1607
+ full-width row of the header (flex-basis 100% forces the wrap). */
1608
+ .meta-player {
1609
+ margin-block: -0.25rem;
1610
+ }
1611
+ .meta-player:has(:global(.lc-bar)) {
1612
+ flex-basis: 100%;
1613
+ margin-block: 0.25rem 0;
1614
+ margin-left: 0;
1615
+ }
1616
+ </style>