@north-light/crouter 0.3.198 → 0.3.199

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 (314) hide show
  1. package/dist/api/client.d.ts +18 -18
  2. package/dist/api/client.js +29 -37
  3. package/dist/api/dto/broker-ops.d.ts +3 -1
  4. package/dist/api/dto/broker.d.ts +13 -2
  5. package/dist/api/dto/broker.js +4 -4
  6. package/dist/api/dto/human.d.ts +6 -5
  7. package/dist/api/dto/inbox.d.ts +81 -34
  8. package/dist/api/dto/nodes.d.ts +8 -2
  9. package/dist/api/dto/profiles.d.ts +23 -0
  10. package/dist/api/dto/profiles.js +2 -2
  11. package/dist/api/dto/worktree.d.ts +3 -5
  12. package/dist/api/routes.d.ts +3 -5
  13. package/dist/api/routes.js +4 -6
  14. package/dist/builtin-memory/00-runtime-base.md +6 -6
  15. package/dist/builtin-memory/01-spine/01-no-manager.md +1 -1
  16. package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +6 -6
  18. package/dist/builtin-memory/05-kinds/design/00-base.md +1 -1
  19. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +2 -2
  20. package/dist/builtin-memory/05-kinds/review/00-base.md +2 -2
  21. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  22. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -1
  23. package/dist/builtin-memory/insights/capture.md +2 -2
  24. package/dist/builtin-memory/insights/init.md +3 -3
  25. package/dist/builtin-memory/internal/INDEX.md +1 -1
  26. package/dist/builtin-memory/internal/memory-loading.md +1 -1
  27. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  28. package/dist/builtin-memory/internal/plugins.md +59 -2
  29. package/dist/builtin-memory/internal/storage-tiers.md +3 -1
  30. package/dist/builtin-memory/spec/roadmap.md +2 -2
  31. package/dist/builtin-memory/testing.md +1 -1
  32. package/dist/clients/attach/chrome/bash-jobs.js +3 -3
  33. package/dist/clients/attach/render/chat-view.d.ts +26 -1
  34. package/dist/clients/attach/render/chat-view.js +61 -11
  35. package/dist/clients/attach/render/page-block.d.ts +34 -0
  36. package/dist/clients/attach/render/page-block.js +226 -0
  37. package/dist/clients/attach/session/whip.d.ts +2 -0
  38. package/dist/clients/attach/session/whip.js +2 -0
  39. package/dist/clients/attach/viewer.js +1419 -993
  40. package/dist/clients/conversation/projection.js +18 -3
  41. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +9 -6
  42. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +13 -21
  43. package/dist/clients/inbox/controller.js +10 -16
  44. package/dist/clients/inbox/page-adapter.d.ts +1 -3
  45. package/dist/clients/inbox/page-adapter.js +3 -4
  46. package/dist/clients/inbox/resolve.d.ts +1 -1
  47. package/dist/clients/inbox/resolve.js +3 -3
  48. package/dist/clients/inbox/review/document-surface.js +3 -1
  49. package/dist/clients/inbox/review/state.d.ts +2 -1
  50. package/dist/clients/inbox/review/state.js +5 -16
  51. package/dist/clients/inbox/tui/input.js +37 -19
  52. package/dist/clients/inbox/tui/panel.js +5 -31
  53. package/dist/clients/inbox/tui/render.d.ts +11 -0
  54. package/dist/clients/inbox/tui/render.js +71 -27
  55. package/dist/clients/inbox/tui/slots.d.ts +3 -1
  56. package/dist/clients/inbox/tui/slots.js +16 -12
  57. package/dist/clients/inbox/tui/types.d.ts +2 -7
  58. package/dist/clients/inbox/tui.js +2 -2
  59. package/dist/commands/__tests__/human.test.js +71 -114
  60. package/dist/commands/__tests__/node-message.test.js +14 -0
  61. package/dist/commands/attention.js +4 -4
  62. package/dist/commands/canvas-snapshot.js +1 -1
  63. package/dist/commands/canvas.js +1 -1
  64. package/dist/commands/dashboard.js +1 -1
  65. package/dist/commands/human/{html.d.ts → components.d.ts} +1 -1
  66. package/dist/commands/human/components.js +77 -0
  67. package/dist/commands/human/feedback.d.ts +2 -0
  68. package/dist/commands/human/feedback.js +108 -0
  69. package/dist/commands/human/prompts.d.ts +13 -5
  70. package/dist/commands/human/prompts.js +160 -97
  71. package/dist/commands/human/queue.js +57 -88
  72. package/dist/commands/human/shared.d.ts +2 -12
  73. package/dist/commands/human/shared.js +17 -11
  74. package/dist/commands/human.js +29 -21
  75. package/dist/commands/memory/origin.js +2 -2
  76. package/dist/commands/node/bash.js +4 -4
  77. package/dist/commands/node/inspect.js +3 -3
  78. package/dist/commands/node/lifecycle.js +1 -1
  79. package/dist/commands/node/message.js +2 -2
  80. package/dist/commands/node-worktree.js +8 -8
  81. package/dist/commands/pkg/browse.js +1 -1
  82. package/dist/commands/pkg/plugin-manage.js +129 -10
  83. package/dist/commands/profile/delete.js +41 -15
  84. package/dist/commands/profile.js +1 -1
  85. package/dist/commands/push.js +1 -1
  86. package/dist/commands/surface/node/focus.js +29 -14
  87. package/dist/commands/surface-edit.js +3 -3
  88. package/dist/commands/surface-inbox.d.ts +2 -0
  89. package/dist/commands/{human/inbox.js → surface-inbox.js} +14 -18
  90. package/dist/commands/surface.js +2 -1
  91. package/dist/commands/sys/doctor.js +60 -3
  92. package/dist/commands/sys/feedback.js +79 -25
  93. package/dist/commands/sys/settings.js +1 -1
  94. package/dist/core/__tests__/cron-broker-capacity.test.js +1 -1
  95. package/dist/core/__tests__/extension-abort.test.js +5 -2
  96. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -2
  97. package/dist/core/__tests__/fixtures/fake-engine.js +47 -3
  98. package/dist/core/__tests__/human-cancel-guard.test.js +31 -10
  99. package/dist/core/__tests__/human-deliver.test.js +31 -26
  100. package/dist/core/__tests__/human-node-not-supervised.test.js +1 -1
  101. package/dist/core/__tests__/plugin-page-components.test.js +157 -0
  102. package/dist/core/__tests__/seam/dormancy-release.test.js +40 -0
  103. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -2
  104. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +12 -6
  105. package/dist/core/__tests__/serial/live-mutation.test.js +1 -1
  106. package/dist/core/__tests__/serial/tmux-surface.test.js +4 -2
  107. package/dist/core/__tests__/serial/worktree.test.js +24 -198
  108. package/dist/core/__tests__/session-cycles.test.js +22 -0
  109. package/dist/core/__tests__/stop-guard.test.js +4 -4
  110. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +19 -4
  111. package/dist/core/bash-jobs.d.ts +6 -0
  112. package/dist/core/bash-jobs.js +10 -0
  113. package/dist/core/canvas/__tests__/attention.test.js +8 -5
  114. package/dist/core/canvas/__tests__/render-remote.test.js +5 -5
  115. package/dist/core/canvas/attention.js +8 -6
  116. package/dist/core/canvas/browse/model.d.ts +8 -2
  117. package/dist/core/canvas/browse/model.js +11 -4
  118. package/dist/core/canvas/canvas.d.ts +7 -0
  119. package/dist/core/canvas/canvas.js +37 -0
  120. package/dist/core/canvas/crons.d.ts +9 -0
  121. package/dist/core/canvas/crons.js +51 -0
  122. package/dist/core/canvas/node-order.d.ts +22 -1
  123. package/dist/core/canvas/node-order.js +26 -1
  124. package/dist/core/canvas/render-source.d.ts +5 -0
  125. package/dist/core/canvas/render-source.js +1 -0
  126. package/dist/core/canvas/types.d.ts +9 -3
  127. package/dist/core/command-plugins/bundle.d.ts +6 -1
  128. package/dist/core/command-plugins/bundle.js +26 -13
  129. package/dist/core/command.js +2 -1
  130. package/dist/core/config.d.ts +47 -1
  131. package/dist/core/config.js +137 -4
  132. package/dist/core/help.js +1 -1
  133. package/dist/core/human/__tests__/page-catalog.test.js +4 -0
  134. package/dist/core/human/__tests__/page-tickets.test.js +65 -52
  135. package/dist/core/human/__tests__/page.test.js +56 -98
  136. package/dist/core/human/__tests__/serial/inbox-core.test.js +6 -6
  137. package/dist/core/human/answer-text.js +3 -3
  138. package/dist/core/human/answer.d.ts +6 -4
  139. package/dist/core/human/answer.js +4 -10
  140. package/dist/core/human/component-docs.d.ts +11 -7
  141. package/dist/core/human/component-docs.js +361 -88
  142. package/dist/core/human/convention.d.ts +17 -3
  143. package/dist/core/human/convention.js +36 -5
  144. package/dist/core/human/feedback-companion.d.ts +20 -0
  145. package/dist/core/human/feedback-companion.js +98 -0
  146. package/dist/core/human/feedback.d.ts +65 -0
  147. package/dist/core/human/feedback.js +116 -0
  148. package/dist/core/human/page-catalog.d.ts +40 -5
  149. package/dist/core/human/page-catalog.js +102 -30
  150. package/dist/core/human/page-errors.d.ts +4 -0
  151. package/dist/core/human/page-errors.js +7 -0
  152. package/dist/core/human/page-eval.d.ts +20 -0
  153. package/dist/core/human/page-eval.js +123 -0
  154. package/dist/core/human/page-schema.d.ts +23 -191
  155. package/dist/core/human/page-schema.js +60 -89
  156. package/dist/core/human/page-synth.d.ts +1 -1
  157. package/dist/core/human/page-synth.js +15 -18
  158. package/dist/core/human/page.d.ts +12 -27
  159. package/dist/core/human/page.js +142 -474
  160. package/dist/core/human/root.d.ts +6 -2
  161. package/dist/core/human/root.js +12 -4
  162. package/dist/core/human/scan.d.ts +3 -4
  163. package/dist/core/human/scan.js +16 -15
  164. package/dist/core/human/summary.d.ts +1 -2
  165. package/dist/core/human/summary.js +2 -2
  166. package/dist/core/human/tickets.d.ts +20 -14
  167. package/dist/core/human/tickets.js +91 -31
  168. package/dist/core/human/types.d.ts +10 -2
  169. package/dist/core/preview-registry.js +8 -10
  170. package/dist/core/profiles/default-binding.d.ts +4 -0
  171. package/dist/core/profiles/default-binding.js +24 -0
  172. package/dist/core/profiles/deletion-reservation.d.ts +8 -0
  173. package/dist/core/profiles/deletion-reservation.js +38 -0
  174. package/dist/core/profiles/manifest.d.ts +3 -0
  175. package/dist/core/profiles/manifest.js +12 -0
  176. package/dist/core/profiles/select.js +2 -2
  177. package/dist/core/review/realize.js +3 -3
  178. package/dist/core/review/store.d.ts +6 -0
  179. package/dist/core/review/store.js +31 -0
  180. package/dist/core/runtime/bearings.d.ts +0 -4
  181. package/dist/core/runtime/bearings.js +4 -62
  182. package/dist/core/runtime/bin-contributions.d.ts +44 -0
  183. package/dist/core/runtime/bin-contributions.js +186 -0
  184. package/dist/core/runtime/broker/client-registry.d.ts +4 -0
  185. package/dist/core/runtime/broker/client-registry.js +13 -1
  186. package/dist/core/runtime/broker/extension-abort.d.ts +1 -1
  187. package/dist/core/runtime/broker/extension-abort.js +4 -2
  188. package/dist/core/runtime/broker/fault-retry.js +4 -0
  189. package/dist/core/runtime/broker/frame-dispatch.d.ts +3 -1
  190. package/dist/core/runtime/broker/frame-dispatch.js +5 -6
  191. package/dist/core/runtime/broker/read-ops.d.ts +1 -1
  192. package/dist/core/runtime/broker/read-ops.js +2 -2
  193. package/dist/core/runtime/broker/rebind.d.ts +1 -0
  194. package/dist/core/runtime/broker/rebind.js +6 -1
  195. package/dist/core/runtime/broker-extension-render.js +19 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +14 -0
  197. package/dist/core/runtime/broker.d.ts +1 -1
  198. package/dist/core/runtime/broker.js +17 -6
  199. package/dist/core/runtime/front-door-env.d.ts +5 -0
  200. package/dist/core/runtime/front-door-env.js +16 -0
  201. package/dist/core/runtime/front-door.d.ts +1 -5
  202. package/dist/core/runtime/front-door.js +2 -5
  203. package/dist/core/runtime/host.d.ts +1 -1
  204. package/dist/core/runtime/invocation.d.ts +6 -0
  205. package/dist/core/runtime/invocation.js +10 -0
  206. package/dist/core/runtime/kickoff.js +34 -0
  207. package/dist/core/runtime/launch.d.ts +2 -6
  208. package/dist/core/runtime/node-read.js +4 -1
  209. package/dist/core/runtime/nodes.js +2 -0
  210. package/dist/core/runtime/promote.d.ts +1 -0
  211. package/dist/core/runtime/promote.js +3 -1
  212. package/dist/core/runtime/session-cycles.d.ts +6 -0
  213. package/dist/core/runtime/session-cycles.js +15 -1
  214. package/dist/core/runtime/session-visibility.d.ts +17 -8
  215. package/dist/core/runtime/session-visibility.js +19 -10
  216. package/dist/core/runtime/spawn-env.d.ts +9 -1
  217. package/dist/core/runtime/spawn-env.js +62 -2
  218. package/dist/core/runtime/stop-guard.d.ts +2 -2
  219. package/dist/core/runtime/stop-guard.js +2 -2
  220. package/dist/core/runtime/stop-signals.d.ts +1 -1
  221. package/dist/core/runtime/stop-signals.js +2 -2
  222. package/dist/core/runtime/tmux-bindings.js +1 -1
  223. package/dist/core/session-model/session-state.d.ts +4 -3
  224. package/dist/core/session-model/session-state.js +14 -1
  225. package/dist/core/user-settings.d.ts +21 -2
  226. package/dist/core/user-settings.js +32 -11
  227. package/dist/core/worktree.d.ts +15 -45
  228. package/dist/core/worktree.js +59 -261
  229. package/dist/daemon/api/__tests__/seam/profile-delete.test.d.ts +1 -0
  230. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +386 -0
  231. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  232. package/dist/daemon/api/handlers/broker-ops.js +9 -5
  233. package/dist/daemon/api/handlers/feedback-comments.d.ts +2 -0
  234. package/dist/daemon/api/handlers/feedback-comments.js +207 -0
  235. package/dist/daemon/api/handlers/human.js +24 -20
  236. package/dist/daemon/api/handlers/inbox.d.ts +10 -0
  237. package/dist/daemon/api/handlers/inbox.js +39 -115
  238. package/dist/daemon/api/handlers/profiles.js +11 -29
  239. package/dist/daemon/api/map.d.ts +6 -1
  240. package/dist/daemon/api/map.js +23 -0
  241. package/dist/daemon/api/server.js +3 -1
  242. package/dist/daemon/companion-retire.d.ts +2 -0
  243. package/dist/daemon/companion-retire.js +33 -0
  244. package/dist/daemon/cron-run.js +14 -16
  245. package/dist/daemon/human/finish.d.ts +12 -7
  246. package/dist/daemon/human/finish.js +101 -38
  247. package/dist/daemon/human/sweep.js +5 -1
  248. package/dist/daemon/profile-delete.d.ts +7 -0
  249. package/dist/daemon/profile-delete.js +306 -0
  250. package/dist/daemon/reconcilers/broker-supervision.js +3 -1
  251. package/dist/daemon/review/deliver.js +3 -3
  252. package/dist/daemon/review/finish.d.ts +0 -2
  253. package/dist/daemon/review/finish.js +1 -29
  254. package/dist/pi-extensions/__tests__/canvas-bash-valve.test.js +56 -1
  255. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  256. package/dist/pi-extensions/canvas-bash-valve.js +19 -5
  257. package/dist/pi-extensions/canvas-inbox-watcher.js +1 -1
  258. package/dist/pi-extensions/canvas-review-boundary.d.ts +4 -0
  259. package/dist/pi-extensions/canvas-review-boundary.js +18 -9
  260. package/dist/pi-extensions/canvas-stophook.js +4 -4
  261. package/dist/shared/generated-context.js +2 -2
  262. package/dist/types.d.ts +44 -2
  263. package/dist/types.js +7 -2
  264. package/package.json +4 -4
  265. package/runtime.lock.json +5 -33
  266. package/dist/builtin-memory/init.md +0 -38
  267. package/dist/builtin-memory/plan.md +0 -19
  268. package/dist/builtin-memory/spec.md +0 -19
  269. package/dist/commands/human/doc.d.ts +0 -2
  270. package/dist/commands/human/doc.js +0 -91
  271. package/dist/commands/human/html.js +0 -81
  272. package/dist/commands/human/inbox.d.ts +0 -2
  273. package/dist/core/human/__tests__/html-markdown.test.js +0 -52
  274. package/dist/core/human/__tests__/page-render.test.js +0 -66
  275. package/dist/core/human/html-markdown.d.ts +0 -3
  276. package/dist/core/human/html-markdown.js +0 -288
  277. package/dist/core/human/page-render.d.ts +0 -4
  278. package/dist/core/human/page-render.js +0 -105
  279. package/dist/pages/bundle.css +0 -1
  280. package/dist/pages/bundle.js +0 -1778
  281. package/dist/pages/comments.d.ts +0 -72
  282. package/dist/pages/comments.js +0 -176
  283. package/dist/pages/controls.d.ts +0 -98
  284. package/dist/pages/controls.js +0 -261
  285. package/dist/pages/elements/cards.d.ts +0 -28
  286. package/dist/pages/elements/cards.js +0 -915
  287. package/dist/pages/elements/chart.d.ts +0 -23
  288. package/dist/pages/elements/chart.js +0 -664
  289. package/dist/pages/elements/options.d.ts +0 -30
  290. package/dist/pages/elements/options.js +0 -742
  291. package/dist/pages/elements/pages.d.ts +0 -30
  292. package/dist/pages/elements/pages.js +0 -377
  293. package/dist/pages/elements/slot.d.ts +0 -26
  294. package/dist/pages/elements/slot.js +0 -112
  295. package/dist/pages/elements/table.d.ts +0 -36
  296. package/dist/pages/elements/table.js +0 -1043
  297. package/dist/pages/elements/text.d.ts +0 -31
  298. package/dist/pages/elements/text.js +0 -1175
  299. package/dist/pages/entry.d.ts +0 -8
  300. package/dist/pages/entry.js +0 -8
  301. package/dist/pages/host.d.ts +0 -134
  302. package/dist/pages/host.js +0 -165
  303. package/dist/pages/readonly.d.ts +0 -24
  304. package/dist/pages/readonly.js +0 -31
  305. package/dist/pages/register.d.ts +0 -2
  306. package/dist/pages/register.js +0 -3
  307. package/dist/pages/responses.d.ts +0 -37
  308. package/dist/pages/responses.js +0 -56
  309. package/dist/pages/slot-config.d.ts +0 -36
  310. package/dist/pages/slot-config.js +0 -50
  311. package/dist/pages/types.d.ts +0 -98
  312. package/dist/pages/types.js +0 -23
  313. /package/dist/{core/human/__tests__/html-markdown.test.d.ts → commands/__tests__/node-message.test.d.ts} +0 -0
  314. /package/dist/core/{human/__tests__/page-render.test.d.ts → __tests__/plugin-page-components.test.d.ts} +0 -0
@@ -1,742 +0,0 @@
1
- /**
2
- * `<crtr-options>` — the agent asks a multiple-choice question.
3
- *
4
- * A port of the interaction kit's `agent-options` (Northlight
5
- * `apps/gateway/components/agent-ui/agent-options.tsx` @ `3705fe9e2`) onto a dependency-free
6
- * custom element, keeping its two load-bearing decisions:
7
- *
8
- * - **The control's silhouette tells the truth about how many answers are wanted.**
9
- * Single-select is a radio group, multi-select is checkbox rows, so the element never has
10
- * to say "pick one" in prose.
11
- * - **"This one, but…" is a first-class answer.** Every option carries the comment
12
- * affordance in place, rather than making the user squeeze a caveat into the freetext box
13
- * at the bottom. The affordance is quiet until you reach for it — it reveals on hover, on
14
- * keyboard focus, and on the `j`/`k` cursor — and stays lit once a comment exists, which
15
- * is the whole "there is a note here" signal at a glance.
16
- *
17
- * Selection is kept in the agent's authored order, never click order, so the response reads
18
- * the way the question did. `freetext` is absent rather than empty: the response says whether
19
- * the user wrote something, not whether the box existed.
20
- *
21
- * Every styling value this element paints is read through a `--crtr-options-*` component
22
- * token, so a host restyles the question without touching crtr. Paint that the bundle shares —
23
- * the option controls, the two writing boxes, the focus ring on every button — chains through
24
- * the `--crtr-control-*` family behind its element token, which is what keeps a comment box
25
- * here identical to one in `<crtr-text>` unless a host deliberately parts them. Each anatomy
26
- * part carries its own scope: the option row and the control inside it, the comment trigger
27
- * and the box it opens, and the two buttons in that box are five things a person can point at,
28
- * so they are five scopes rather than one shared name meaning two different rectangles.
29
- */
30
- import { adoptComments, canRecordComments, setComment, findComment } from '../comments.js';
31
- import { controlStyle, focusRingStyle, writingBoxStyle } from '../controls.js';
32
- import { respond, savedIds, savedResponse } from '../host.js';
33
- import { applyReadOnly, isReadOnly } from '../readonly.js';
34
- import { registerElement } from '../register.js';
35
- import { registerSlotResponse } from '../responses.js';
36
- import { readSlotConfig, readSlotId } from '../slot-config.js';
37
- const BUBBLE_PLUS = '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z"/><path d="M8 5.5v3M6.5 7h3"/></svg>';
38
- const BUBBLE_TEXT = '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z"/><path d="M5 6.25h6M5 8.25h4"/></svg>';
39
- const STYLE = `
40
- :host {
41
- display: block;
42
- }
43
-
44
- [part="container"] {
45
- display: grid;
46
- gap: var(--crtr-options-container-gap, var(--crtr-space-3));
47
- }
48
-
49
- /* The question reads as the agent's own prose above its control, matching the table's
50
- caption rather than shouting over the options it introduces. */
51
- [part="label"] {
52
- margin: var(--crtr-options-label-margin, 0);
53
- color: var(--crtr-options-label-text-color, var(--crtr-color-text-muted));
54
- }
55
-
56
- [part="list"] {
57
- display: grid;
58
- gap: var(--crtr-options-list-gap, var(--crtr-space-2));
59
- }
60
-
61
- [part="option"] {
62
- position: relative;
63
- display: flex;
64
- align-items: flex-start;
65
- gap: var(--crtr-options-option-gap, var(--crtr-space-3));
66
- padding: var(--crtr-options-option-padding, var(--crtr-space-2) var(--crtr-space-3));
67
- background: var(--crtr-options-option-fill, var(--crtr-color-surface));
68
- border: var(--crtr-options-option-border-width, 1px)
69
- var(--crtr-options-option-border-style, solid)
70
- var(--crtr-options-option-border-color, var(--crtr-color-border));
71
- border-radius: var(--crtr-options-option-radius, var(--crtr-radius-medium));
72
- transition: background-color var(--crtr-options-option-transition, 120ms ease),
73
- border-color var(--crtr-options-option-transition, 120ms ease);
74
- }
75
-
76
- [part="option"]:hover,
77
- [part="option"][data-active="true"] {
78
- background: var(--crtr-options-option-hover-fill, var(--crtr-color-surface-hover));
79
- }
80
-
81
- [part="option"][data-selected="true"] {
82
- border-color: var(
83
- --crtr-options-option-selected-border-color,
84
- color-mix(in srgb, var(--crtr-color-accent) 45%, transparent)
85
- );
86
- background: var(
87
- --crtr-options-option-selected-fill,
88
- color-mix(in srgb, var(--crtr-color-accent) 10%, transparent)
89
- );
90
- }
91
-
92
- [part="option"][data-selected="true"]:hover,
93
- [part="option"][data-selected="true"][data-active="true"] {
94
- background: var(
95
- --crtr-options-option-selected-hover-fill,
96
- color-mix(in srgb, var(--crtr-color-accent) 14%, transparent)
97
- );
98
- }
99
-
100
- /* A page that can no longer take an answer keeps its selection legible and drops every
101
- affordance that says it could still be changed. */
102
- [part="option"]:has([part="option-control"]:disabled) [part="option-label"] {
103
- cursor: default;
104
- }
105
-
106
- [part="option-label"] {
107
- display: flex;
108
- flex: 1;
109
- align-items: flex-start;
110
- gap: var(--crtr-options-option-label-gap, var(--crtr-space-3));
111
- cursor: pointer;
112
- }
113
-
114
- ${controlStyle({ scope: 'options-option-control' }, '[part="option-control"]')}
115
-
116
- /* Centred on the first line of the label rather than on its box, so a two-line option keeps
117
- the control beside the title. Half the difference between a 1.5 line box and the control. */
118
- [part="option-control"] {
119
- margin-top: var(--crtr-options-option-control-offset, 0.1875em);
120
- }
121
-
122
- [part="option-text"] {
123
- display: grid;
124
- gap: var(--crtr-options-option-text-gap, var(--crtr-space-1));
125
- }
126
-
127
- [part="option-title"] {
128
- font-weight: var(--crtr-options-option-title-font-weight, 500);
129
- }
130
-
131
- [part="option-description"] {
132
- color: var(--crtr-options-option-description-text-color, var(--crtr-color-text-muted));
133
- font-size: var(--crtr-options-option-description-font-size, 0.875em);
134
- }
135
-
136
- /* The trigger stays in the row's anatomy while quiet, then its state rule below reveals it.
137
- A host can choose how quiet and how bright those states are independently; the transition
138
- remains one knob because all three properties are one reveal. */
139
- [part="comment"] {
140
- display: inline-flex;
141
- align-items: center;
142
- justify-content: center;
143
- flex: none;
144
- width: var(--crtr-options-comment-size, 1.5em);
145
- height: var(--crtr-options-comment-size, 1.5em);
146
- padding: var(--crtr-options-comment-padding, 0);
147
- color: var(--crtr-options-comment-text-color, var(--crtr-color-text-muted));
148
- background: var(--crtr-options-comment-fill, transparent);
149
- border: var(--crtr-options-comment-border-width, 1px)
150
- var(--crtr-options-comment-border-style, solid)
151
- var(--crtr-options-comment-border-color, transparent);
152
- border-radius: var(--crtr-options-comment-radius, var(--crtr-radius-small));
153
- opacity: var(--crtr-options-comment-opacity, 0);
154
- cursor: pointer;
155
- transition: opacity var(--crtr-options-comment-transition, var(--crtr-control-transition, 120ms ease)),
156
- color var(--crtr-options-comment-transition, var(--crtr-control-transition, 120ms ease)),
157
- background-color var(--crtr-options-comment-transition, var(--crtr-control-transition, 120ms ease));
158
- }
159
-
160
- [part="option"]:hover [part="comment"],
161
- [part="option"][data-active="true"] [part="comment"],
162
- [part="option"]:focus-within [part="comment"],
163
- [part="comment"][data-state="commented"],
164
- [part="comment"][aria-expanded="true"] {
165
- opacity: var(--crtr-options-comment-visible-opacity, 1);
166
- }
167
-
168
- [part="comment"]:hover {
169
- background: var(--crtr-options-comment-hover-fill, var(--crtr-color-surface-hover));
170
- border-color: var(--crtr-options-comment-hover-border-color, var(--crtr-color-border));
171
- }
172
-
173
- ${focusRingStyle({ scope: 'options-comment' }, '[part="comment"]')}
174
- [part="comment"][data-state="commented"] {
175
- color: var(--crtr-options-comment-commented-text-color, var(--crtr-color-accent));
176
- }
177
-
178
- [part="comment"] svg {
179
- width: var(--crtr-options-comment-icon-size, 0.875em);
180
- height: var(--crtr-options-comment-icon-size, 0.875em);
181
- fill: var(--crtr-options-comment-icon-fill, none);
182
- stroke: var(--crtr-options-comment-icon-stroke-color, currentColor);
183
- stroke-width: var(--crtr-options-comment-icon-stroke-width, 1.25);
184
- stroke-linecap: var(--crtr-options-comment-icon-stroke-cap, round);
185
- stroke-linejoin: var(--crtr-options-comment-icon-stroke-join, round);
186
- }
187
-
188
- [part="comment-popover"] {
189
- position: absolute;
190
- top: calc(100% + var(--crtr-options-comment-popover-offset, var(--crtr-space-1)));
191
- right: 0;
192
- z-index: 2;
193
- display: grid;
194
- gap: var(--crtr-options-comment-popover-gap, var(--crtr-space-2));
195
- width: var(--crtr-options-comment-popover-width, min(20em, 100%));
196
- padding: var(--crtr-options-comment-popover-padding, var(--crtr-space-3));
197
- background: var(--crtr-options-comment-popover-fill, var(--crtr-color-background));
198
- border: var(--crtr-options-comment-popover-border-width, 1px)
199
- var(--crtr-options-comment-popover-border-style, solid)
200
- var(--crtr-options-comment-popover-border-color, var(--crtr-color-border));
201
- border-radius: var(--crtr-options-comment-popover-radius, var(--crtr-radius-medium));
202
- box-shadow: var(--crtr-options-comment-popover-shadow, var(--crtr-shadow));
203
- }
204
-
205
- [part="comment-popover"][hidden] {
206
- display: none;
207
- }
208
-
209
- /* The popover's own prose, in the page's voice — not a caps-mono field label shouting an
210
- option id at someone who just clicked that option. */
211
- [part="comment-subject"] {
212
- margin: var(--crtr-options-comment-subject-margin, 0);
213
- color: var(--crtr-options-comment-subject-text-color, var(--crtr-color-text-muted));
214
- font-size: var(--crtr-options-comment-subject-font-size, 0.875em);
215
- }
216
-
217
- /* Two calls rather than one selector list: a comment box and the freetext box are two things
218
- a person can point at, so each takes its own scope. They still share the family layer
219
- underneath, so they cannot drift apart unless a host parts them on purpose. */
220
- ${writingBoxStyle({ scope: 'options-comment-input' }, '[part="comment-input"]')}
221
- ${writingBoxStyle({ scope: 'options-freetext-input' }, '[part="freetext-input"]')}
222
- [part="comment-input"] {
223
- min-height: var(--crtr-options-comment-input-min-height, 4.5em);
224
- resize: vertical;
225
- }
226
-
227
- [part="freetext-input"] {
228
- min-height: var(--crtr-options-freetext-input-min-height, 4em);
229
- resize: vertical;
230
- }
231
-
232
- [part="comment-actions"] {
233
- display: flex;
234
- align-items: center;
235
- justify-content: space-between;
236
- gap: var(--crtr-options-comment-actions-gap, var(--crtr-space-2));
237
- }
238
-
239
- [part="comment-hint"] {
240
- color: var(--crtr-options-comment-hint-text-color, var(--crtr-color-text-muted));
241
- font-size: var(--crtr-options-comment-hint-font-size, 0.875em);
242
- }
243
-
244
- /* The two buttons are one shape drawn twice rather than one rule serving two parts, because
245
- a scope that named both would make "the cancel button's fill" unsayable. */
246
- [part="comment-cancel"] {
247
- padding: var(--crtr-options-comment-cancel-padding, var(--crtr-space-1) var(--crtr-space-2));
248
- font: var(--crtr-options-comment-cancel-font, inherit);
249
- font-size: var(--crtr-options-comment-cancel-font-size, 0.875em);
250
- color: var(--crtr-options-comment-cancel-text-color, var(--crtr-color-text-muted));
251
- background: var(--crtr-options-comment-cancel-fill, transparent);
252
- border: var(--crtr-options-comment-cancel-border-width, 1px)
253
- var(--crtr-options-comment-cancel-border-style, solid)
254
- var(--crtr-options-comment-cancel-border-color, transparent);
255
- border-radius: var(--crtr-options-comment-cancel-radius, var(--crtr-radius-small));
256
- cursor: pointer;
257
- transition: color var(--crtr-options-comment-cancel-transition, var(--crtr-control-transition, 120ms ease)),
258
- background-color var(--crtr-options-comment-cancel-transition, var(--crtr-control-transition, 120ms ease)),
259
- border-color var(--crtr-options-comment-cancel-transition, var(--crtr-control-transition, 120ms ease));
260
- }
261
-
262
- [part="comment-cancel"]:hover {
263
- color: var(--crtr-options-comment-cancel-hover-text-color, var(--crtr-color-text));
264
- background: var(--crtr-options-comment-cancel-hover-fill, var(--crtr-color-surface-hover));
265
- }
266
-
267
- [part="comment-save"] {
268
- padding: var(--crtr-options-comment-save-padding, var(--crtr-space-1) var(--crtr-space-2));
269
- font: var(--crtr-options-comment-save-font, inherit);
270
- font-size: var(--crtr-options-comment-save-font-size, 0.875em);
271
- color: var(--crtr-options-comment-save-text-color, var(--crtr-color-accent-contrast));
272
- background: var(--crtr-options-comment-save-fill, var(--crtr-color-accent));
273
- border: var(--crtr-options-comment-save-border-width, 1px)
274
- var(--crtr-options-comment-save-border-style, solid)
275
- var(--crtr-options-comment-save-border-color, var(--crtr-color-accent));
276
- border-radius: var(--crtr-options-comment-save-radius, var(--crtr-radius-small));
277
- cursor: pointer;
278
- transition: color var(--crtr-options-comment-save-transition, var(--crtr-control-transition, 120ms ease)),
279
- background-color var(--crtr-options-comment-save-transition, var(--crtr-control-transition, 120ms ease)),
280
- border-color var(--crtr-options-comment-save-transition, var(--crtr-control-transition, 120ms ease));
281
- }
282
-
283
- /* Mixing the accent toward the text colour darkens on a light token set and lightens on a
284
- dark one — a brightness filter moves the wrong way on one of them. */
285
- [part="comment-save"]:hover {
286
- background: var(
287
- --crtr-options-comment-save-hover-fill,
288
- color-mix(in srgb, var(--crtr-color-accent) 88%, var(--crtr-color-text))
289
- );
290
- border-color: var(
291
- --crtr-options-comment-save-hover-border-color,
292
- color-mix(in srgb, var(--crtr-color-accent) 88%, var(--crtr-color-text))
293
- );
294
- }
295
-
296
- ${focusRingStyle({ scope: 'options-comment-cancel' }, '[part="comment-cancel"]')}
297
- ${focusRingStyle({ scope: 'options-comment-save' }, '[part="comment-save"]')}
298
- [part="freetext"] {
299
- display: grid;
300
- gap: var(--crtr-options-freetext-gap, var(--crtr-space-1));
301
- }
302
-
303
- [part="freetext-label"] {
304
- color: var(--crtr-options-freetext-label-text-color, var(--crtr-color-text-muted));
305
- font-size: var(--crtr-options-freetext-label-font-size, 0.875em);
306
- }
307
-
308
- [part="unavailable"] {
309
- padding: var(--crtr-options-unavailable-padding, var(--crtr-space-3));
310
- color: var(--crtr-options-unavailable-text-color, var(--crtr-color-text-muted));
311
- background: var(--crtr-options-unavailable-fill, var(--crtr-color-surface));
312
- border: var(--crtr-options-unavailable-border-width, 1px)
313
- var(--crtr-options-unavailable-border-style, solid)
314
- var(--crtr-options-unavailable-border-color, var(--crtr-color-border));
315
- border-radius: var(--crtr-options-unavailable-radius, var(--crtr-radius-medium));
316
- }
317
-
318
- [part="reason"] {
319
- color: var(--crtr-options-reason-text-color, var(--crtr-color-text));
320
- font-family: var(--crtr-options-reason-font-family, var(--crtr-font-mono));
321
- }
322
- `;
323
- /** Ids are scoped to this element's shadow root, so one constant is unique everywhere. */
324
- const LABEL_ID = 'prompt-label';
325
- let groupCounter = 0;
326
- /**
327
- * Whether a key belongs to something being written in rather than to the list grammar.
328
- * The option controls are real radios and checkboxes, so this deliberately admits them —
329
- * only prose entry (the comment box, the freetext box) swallows `j`/`k`/`x`/`c`.
330
- */
331
- function isTextEntry(target) {
332
- if (target instanceof HTMLTextAreaElement)
333
- return true;
334
- if (target instanceof HTMLElement && target.isContentEditable)
335
- return true;
336
- if (!(target instanceof HTMLInputElement))
337
- return false;
338
- return target.type !== 'checkbox' && target.type !== 'radio';
339
- }
340
- class CrtrOptionsElement extends HTMLElement {
341
- /** The host marks a resolved or canceled page `inert`; that is this element's read-only signal. */
342
- static observedAttributes = ['inert'];
343
- #shadow;
344
- #config;
345
- #slotId;
346
- #rows = new Map();
347
- #order = [];
348
- #selected = [];
349
- #comments = [];
350
- #freetext;
351
- #activeId;
352
- #commentingId;
353
- #built = false;
354
- constructor() {
355
- super();
356
- this.#shadow = this.attachShadow({ mode: 'open' });
357
- }
358
- connectedCallback() {
359
- if (this.#built)
360
- return;
361
- this.#built = true;
362
- this.#slotId = readSlotId(this);
363
- const read = readSlotConfig(this);
364
- if (read.status !== 'ok') {
365
- this.#renderUnavailable(read.reason);
366
- return;
367
- }
368
- const config = read.config;
369
- if (!Array.isArray(config.options) || config.options.length === 0) {
370
- this.#renderUnavailable('options config lists no options');
371
- return;
372
- }
373
- this.#config = config;
374
- this.#order = config.options.map((option) => option.id);
375
- this.#hydrate(config);
376
- // Registered whether or not the person ever selects anything: the pager reports every
377
- // response-bearing slot at submit, and an empty selection is an answer the host validates
378
- // and names, where an absent slot is only a map the host cannot read.
379
- if (this.#slotId !== undefined)
380
- registerSlotResponse(this, this.#slotId, () => this.#response());
381
- this.#render(config);
382
- this.#sync();
383
- }
384
- disconnectedCallback() {
385
- document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
386
- }
387
- attributeChangedCallback() {
388
- if (this.isConnected)
389
- this.#syncReadOnly();
390
- }
391
- /** Read-only is repainted after every build, because a rebuild replaces the controls. */
392
- #syncReadOnly() {
393
- const readOnly = isReadOnly(this);
394
- applyReadOnly(this.#shadow, readOnly);
395
- // A page can go read-only while a box is open — the host sets `inert` on a ticket that
396
- // resolves under it. A box that can no longer save is closed rather than left there
397
- // taking words nothing will keep.
398
- if (readOnly && this.#commentingId !== undefined)
399
- this.#closeComment(false);
400
- }
401
- /**
402
- * Seed local state from the answer the host is already holding, so a reopened page shows
403
- * what it would submit. Ids that no longer name an option are dropped, and single-select
404
- * keeps only the first, so a stale saved answer cannot put the control in a state the user
405
- * could not have clicked into.
406
- */
407
- #hydrate(config) {
408
- if (this.#slotId === undefined)
409
- return;
410
- const saved = savedResponse(this.#slotId);
411
- if (saved === undefined)
412
- return;
413
- const selected = savedIds(saved.selectedOptionIds);
414
- this.#selected = this.#order.filter((id) => selected.includes(id));
415
- if (config.mode === 'single')
416
- this.#selected = this.#selected.slice(0, 1);
417
- this.#comments = adoptComments(saved.comments).filter((comment) => comment.anchor.kind !== 'option' || this.#order.includes(comment.anchor.optionId));
418
- if (config.allowFreetext === true && typeof saved.freetext === 'string' && saved.freetext !== '') {
419
- this.#freetext = saved.freetext;
420
- }
421
- }
422
- /** The named state for a config this element cannot render — never a thrown error. */
423
- #renderUnavailable(reason) {
424
- const style = document.createElement('style');
425
- style.textContent = STYLE;
426
- const container = document.createElement('div');
427
- container.part.add('unavailable');
428
- container.setAttribute('role', 'status');
429
- const detail = document.createElement('span');
430
- detail.part.add('reason');
431
- detail.textContent = reason;
432
- container.append('Options unavailable: ', detail);
433
- this.#shadow.replaceChildren(style, container);
434
- }
435
- #render(config) {
436
- const style = document.createElement('style');
437
- style.textContent = STYLE;
438
- const container = document.createElement('div');
439
- container.part.add('container');
440
- const labelled = config.label !== undefined && config.label !== '';
441
- if (labelled) {
442
- const label = document.createElement('p');
443
- label.part.add('label');
444
- label.id = LABEL_ID;
445
- label.textContent = config.label ?? '';
446
- container.append(label);
447
- }
448
- const list = document.createElement('div');
449
- list.part.add('list');
450
- // A labelled multi-select is a named group of checkboxes; single-select is already one
451
- // control, so the radiogroup role it always carries takes the name instead.
452
- if (config.mode === 'single')
453
- list.setAttribute('role', 'radiogroup');
454
- else if (labelled)
455
- list.setAttribute('role', 'group');
456
- if (labelled)
457
- list.setAttribute('aria-labelledby', LABEL_ID);
458
- groupCounter += 1;
459
- const group = `crtr-options-${groupCounter}`;
460
- for (const option of config.options)
461
- list.append(this.#renderOption(config, option, group));
462
- container.append(list);
463
- if (config.allowFreetext === true)
464
- container.append(this.#renderFreetext(config));
465
- // The grammar lives on the container, so it reaches every row and never escapes the
466
- // shadow root: `j`/`k` move the cursor, `x` toggles it, `c` comments on it.
467
- container.addEventListener('keydown', (event) => this.#onKeydown(event));
468
- this.#shadow.replaceChildren(style, container);
469
- }
470
- #renderOption(config, option, group) {
471
- const root = document.createElement('div');
472
- root.part.add('option');
473
- root.dataset.optionId = option.id;
474
- const label = document.createElement('label');
475
- label.part.add('option-label');
476
- const control = document.createElement('input');
477
- control.part.add('option-control');
478
- control.type = config.mode === 'single' ? 'radio' : 'checkbox';
479
- control.name = group;
480
- control.value = option.id;
481
- control.addEventListener('change', () => this.#toggle(option.id, control.checked));
482
- control.addEventListener('focus', () => this.#setActive(option.id));
483
- const text = document.createElement('span');
484
- text.part.add('option-text');
485
- const title = document.createElement('span');
486
- title.part.add('option-title');
487
- title.textContent = option.label;
488
- text.append(title);
489
- if (option.description !== undefined && option.description !== '') {
490
- const description = document.createElement('span');
491
- description.part.add('option-description');
492
- description.textContent = option.description;
493
- text.append(description);
494
- }
495
- label.append(control, text);
496
- // No comment affordance where a comment has nowhere to go — see `canRecordComments`.
497
- const parts = canRecordComments(this.#slotId) ? this.#buildComment(option) : undefined;
498
- root.append(label);
499
- if (parts !== undefined)
500
- root.append(parts.trigger, parts.popover);
501
- root.addEventListener('mouseenter', () => this.#setActive(option.id));
502
- root.addEventListener('mouseleave', () => this.#clearActive(option.id));
503
- this.#rows.set(option.id, {
504
- id: option.id,
505
- label: option.label,
506
- root,
507
- control,
508
- ...(parts === undefined ? {} : { comment: parts }),
509
- });
510
- return root;
511
- }
512
- /** The trigger and its box, built together because they are only ever used together. */
513
- #buildComment(option) {
514
- const comment = document.createElement('button');
515
- comment.part.add('comment');
516
- comment.type = 'button';
517
- comment.setAttribute('aria-haspopup', 'dialog');
518
- comment.addEventListener('click', () => {
519
- if (this.#commentingId === option.id)
520
- this.#closeComment();
521
- else
522
- this.#openComment(option.id);
523
- });
524
- const popover = document.createElement('div');
525
- popover.part.add('comment-popover');
526
- popover.setAttribute('role', 'dialog');
527
- popover.hidden = true;
528
- const subject = document.createElement('p');
529
- subject.part.add('comment-subject');
530
- subject.textContent = `Comment on ${option.label}`;
531
- popover.setAttribute('aria-label', subject.textContent);
532
- const input = document.createElement('textarea');
533
- input.part.add('comment-input');
534
- input.placeholder = 'What should the agent know about this?';
535
- input.addEventListener('keydown', (event) => {
536
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
537
- event.preventDefault();
538
- this.#commitComment(option.id);
539
- return;
540
- }
541
- if (event.key === 'Escape') {
542
- event.preventDefault();
543
- this.#closeComment();
544
- }
545
- });
546
- const actions = document.createElement('div');
547
- actions.part.add('comment-actions');
548
- const hint = document.createElement('span');
549
- hint.part.add('comment-hint');
550
- const buttons = document.createElement('span');
551
- const cancel = document.createElement('button');
552
- cancel.part.add('comment-cancel');
553
- cancel.type = 'button';
554
- cancel.textContent = 'Cancel';
555
- cancel.addEventListener('click', () => this.#closeComment());
556
- const save = document.createElement('button');
557
- save.part.add('comment-save');
558
- save.type = 'button';
559
- save.textContent = 'Save';
560
- save.addEventListener('click', () => this.#commitComment(option.id));
561
- buttons.append(cancel, save);
562
- actions.append(hint, buttons);
563
- popover.append(subject, input, actions);
564
- return { trigger: comment, popover, input, hint };
565
- }
566
- #renderFreetext(config) {
567
- const wrapper = document.createElement('label');
568
- wrapper.part.add('freetext');
569
- const label = document.createElement('span');
570
- label.part.add('freetext-label');
571
- label.textContent = config.freetextLabel ?? 'Something else';
572
- const input = document.createElement('textarea');
573
- input.part.add('freetext-input');
574
- input.placeholder = config.freetextPlaceholder ?? 'Answer in your own words…';
575
- // Hydration runs before the render, so the saved words are already in local state.
576
- if (this.#freetext !== undefined)
577
- input.value = this.#freetext;
578
- input.addEventListener('input', () => {
579
- // Absent rather than empty — see the note on `freetext` in the response type.
580
- this.#freetext = input.value === '' ? undefined : input.value;
581
- this.#emit();
582
- });
583
- wrapper.append(label, input);
584
- return wrapper;
585
- }
586
- #onKeydown(event) {
587
- if (event.metaKey || event.ctrlKey || event.altKey)
588
- return;
589
- if (isTextEntry(event.target))
590
- return;
591
- const key = event.key.toLowerCase();
592
- const current = this.#activeId ?? this.#selected.at(-1) ?? this.#order[0];
593
- if (current === undefined)
594
- return;
595
- if (key === 'j' || key === 'k') {
596
- event.preventDefault();
597
- const index = Math.max(0, this.#order.indexOf(current));
598
- const step = key === 'j' ? 1 : -1;
599
- const next = this.#order[(index + step + this.#order.length) % this.#order.length];
600
- this.#setActive(next);
601
- this.#rows.get(next)?.control.focus();
602
- return;
603
- }
604
- if (key === 'x') {
605
- event.preventDefault();
606
- const selected = this.#selected.includes(current);
607
- this.#toggle(current, this.#config?.mode === 'single' ? true : !selected);
608
- return;
609
- }
610
- if (key === 'c') {
611
- event.preventDefault();
612
- this.#openComment(current);
613
- }
614
- }
615
- #setActive(optionId) {
616
- if (this.#activeId === optionId)
617
- return;
618
- this.#activeId = optionId;
619
- this.#sync();
620
- }
621
- #clearActive(optionId) {
622
- if (this.#activeId !== optionId)
623
- return;
624
- this.#activeId = undefined;
625
- this.#sync();
626
- }
627
- #toggle(optionId, selected) {
628
- if (this.#config === undefined || isReadOnly(this))
629
- return;
630
- if (this.#config.mode === 'single') {
631
- this.#selected = [optionId];
632
- }
633
- else {
634
- const next = selected
635
- ? [...this.#selected, optionId]
636
- : this.#selected.filter((id) => id !== optionId);
637
- // Kept in the agent's authored order, so the response reads the way the question did
638
- // rather than in click order.
639
- this.#selected = this.#order.filter((id) => next.includes(id));
640
- }
641
- this.#setActive(optionId);
642
- this.#sync();
643
- this.#emit();
644
- }
645
- /**
646
- * A press anywhere but the open box and its trigger closes the box without committing,
647
- * matching the kit's popover. It listens on the document rather than on this element,
648
- * because "anywhere else" includes the rest of the page: `pointerdown` is composed, so the
649
- * path still names the shadow nodes an inside press went through.
650
- */
651
- #onDocumentPointerDown = (event) => {
652
- if (this.#commentingId === undefined)
653
- return;
654
- const parts = this.#rows.get(this.#commentingId)?.comment;
655
- if (parts === undefined)
656
- return;
657
- const path = event.composedPath();
658
- if (path.includes(parts.popover) || path.includes(parts.trigger))
659
- return;
660
- // No focus restore: the press is on its way somewhere else, and pulling focus back to
661
- // the trigger would fight it.
662
- this.#closeComment(false);
663
- };
664
- #openComment(optionId) {
665
- const parts = this.#rows.get(optionId)?.comment;
666
- // A box opens only where its comment can land: never on a read-only page, whose textareas
667
- // are already `readOnly`, so it would take focus and refuse every keystroke.
668
- if (parts === undefined || isReadOnly(this))
669
- return;
670
- if (this.#commentingId === undefined) {
671
- document.addEventListener('pointerdown', this.#onDocumentPointerDown, true);
672
- }
673
- this.#commentingId = optionId;
674
- this.#activeId = optionId;
675
- // Seeded from the saved comment every time it opens.
676
- parts.input.value = findComment(this.#comments, anchorFor(optionId))?.text ?? '';
677
- this.#sync();
678
- parts.input.focus();
679
- }
680
- #closeComment(restoreFocus = true) {
681
- if (this.#commentingId === undefined)
682
- return;
683
- const parts = this.#rows.get(this.#commentingId)?.comment;
684
- this.#commentingId = undefined;
685
- document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
686
- this.#sync();
687
- if (restoreFocus)
688
- parts?.trigger.focus();
689
- }
690
- #commitComment(optionId) {
691
- const parts = this.#rows.get(optionId)?.comment;
692
- if (parts === undefined || isReadOnly(this))
693
- return;
694
- // Clearing the box deletes the comment — `setComment` treats empty text as removal, so
695
- // there is no separate destructive control.
696
- this.#comments = setComment(this.#comments, anchorFor(optionId), parts.input.value);
697
- this.#closeComment();
698
- this.#emit();
699
- }
700
- /** Paint every row from local state; local state is authoritative for rendering. */
701
- #sync() {
702
- for (const row of this.#rows.values()) {
703
- const selected = this.#selected.includes(row.id);
704
- row.control.checked = selected;
705
- row.root.dataset.selected = String(selected);
706
- row.root.dataset.active = String(this.#activeId === row.id);
707
- const parts = row.comment;
708
- if (parts === undefined)
709
- continue;
710
- const comment = findComment(this.#comments, anchorFor(row.id));
711
- parts.trigger.dataset.state = comment === undefined ? 'empty' : 'commented';
712
- parts.trigger.innerHTML = comment === undefined ? BUBBLE_PLUS : BUBBLE_TEXT;
713
- parts.trigger.setAttribute('aria-label', comment === undefined ? `Comment on ${row.label}` : `Edit comment on ${row.label}`);
714
- const open = this.#commentingId === row.id;
715
- parts.trigger.setAttribute('aria-expanded', String(open));
716
- parts.popover.hidden = !open;
717
- parts.hint.textContent = comment === undefined ? '\u2318\u21b5 to save' : 'empty to delete';
718
- }
719
- this.#syncReadOnly();
720
- }
721
- /** The complete current response — what every change hands `respond()`. */
722
- #response() {
723
- const response = {
724
- selectedOptionIds: [...this.#selected],
725
- comments: this.#comments.map((comment) => ({ ...comment })),
726
- };
727
- if (this.#config?.allowFreetext === true && this.#freetext !== undefined) {
728
- response.freetext = this.#freetext;
729
- }
730
- return response;
731
- }
732
- /** The complete current response, on every user change — never a delta. */
733
- #emit() {
734
- if (this.#slotId === undefined)
735
- return;
736
- respond(this.#slotId, this.#response());
737
- }
738
- }
739
- function anchorFor(optionId) {
740
- return { kind: 'option', optionId };
741
- }
742
- registerElement('crtr-options', CrtrOptionsElement);