@north-light/crouter 0.3.196 → 0.3.198

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 (148) hide show
  1. package/dist/api/client.d.ts +2 -2
  2. package/dist/api/dto/inbox.d.ts +46 -0
  3. package/dist/builtin-memory/00-runtime-base.md +3 -3
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  5. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
  7. package/dist/clients/attach/render/chat-view.d.ts +3 -8
  8. package/dist/clients/attach/render/chat-view.js +3 -24
  9. package/dist/clients/attach/render/markdown-source.js +0 -6
  10. package/dist/clients/attach/render/tool-calls.js +16 -2
  11. package/dist/clients/attach/viewer.js +613 -602
  12. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +2 -2
  13. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +11 -4
  14. package/dist/clients/inbox/controller.js +4 -4
  15. package/dist/clients/inbox/page-adapter.d.ts +2 -1
  16. package/dist/clients/inbox/tui/render.js +46 -25
  17. package/dist/clients/inbox/tui/slots.d.ts +1 -3
  18. package/dist/clients/inbox/tui/slots.js +8 -37
  19. package/dist/clients/inbox/tui/types.d.ts +4 -3
  20. package/dist/commands/__tests__/human.test.js +160 -23
  21. package/dist/commands/canvas-browse.js +1 -1
  22. package/dist/commands/human/doc.js +21 -74
  23. package/dist/commands/human/html.d.ts +2 -0
  24. package/dist/commands/human/html.js +81 -0
  25. package/dist/commands/human/prompts.d.ts +5 -4
  26. package/dist/commands/human/prompts.js +90 -168
  27. package/dist/commands/human/shared.d.ts +5 -6
  28. package/dist/commands/human/shared.js +13 -61
  29. package/dist/commands/human.js +7 -2
  30. package/dist/core/__tests__/daemon-boot.test.js +7 -4
  31. package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
  32. package/dist/core/__tests__/human-deliver.test.js +28 -37
  33. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
  34. package/dist/core/__tests__/seam/dormancy-release.test.js +63 -4
  35. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +3 -13
  36. package/dist/core/canvas/__tests__/attention.test.js +2 -2
  37. package/dist/core/canvas/__tests__/render-remote.test.js +2 -2
  38. package/dist/core/canvas/browse/app.d.ts +1 -1
  39. package/dist/core/canvas/browse/app.js +21 -3
  40. package/dist/core/canvas/browse/render.d.ts +4 -0
  41. package/dist/core/canvas/browse/render.js +5 -1
  42. package/dist/core/canvas/canvas.d.ts +26 -0
  43. package/dist/core/canvas/canvas.js +55 -0
  44. package/dist/core/command.js +6 -11
  45. package/dist/core/config.js +3 -2
  46. package/dist/core/help.d.ts +3 -0
  47. package/dist/core/human/__tests__/html-markdown.test.d.ts +1 -0
  48. package/dist/core/human/__tests__/html-markdown.test.js +52 -0
  49. package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
  50. package/dist/core/human/__tests__/page-catalog.test.js +16 -0
  51. package/dist/core/human/__tests__/page-render.test.js +40 -23
  52. package/dist/core/human/__tests__/page-tickets.test.js +48 -27
  53. package/dist/core/human/__tests__/page.test.js +48 -65
  54. package/dist/core/human/__tests__/serial/inbox-core.test.js +5 -15
  55. package/dist/core/human/answer-text.d.ts +3 -0
  56. package/dist/core/human/answer-text.js +98 -0
  57. package/dist/core/human/answer.d.ts +51 -0
  58. package/dist/core/human/answer.js +125 -0
  59. package/dist/core/human/component-docs.d.ts +14 -0
  60. package/dist/core/human/component-docs.js +161 -0
  61. package/dist/core/human/html-markdown.d.ts +3 -0
  62. package/dist/core/human/html-markdown.js +288 -0
  63. package/dist/core/human/page-catalog.d.ts +19 -3
  64. package/dist/core/human/page-catalog.js +73 -16
  65. package/dist/core/human/page-render.d.ts +3 -2
  66. package/dist/core/human/page-render.js +19 -45
  67. package/dist/core/human/page-schema.d.ts +108 -8
  68. package/dist/core/human/page-schema.js +88 -31
  69. package/dist/core/human/page-synth.d.ts +2 -4
  70. package/dist/core/human/page-synth.js +23 -7
  71. package/dist/core/human/page.d.ts +14 -7
  72. package/dist/core/human/page.js +178 -90
  73. package/dist/core/human/review-schema.d.ts +1 -0
  74. package/dist/core/human/review-schema.js +1 -1
  75. package/dist/core/human/scan.d.ts +3 -2
  76. package/dist/core/human/scan.js +7 -8
  77. package/dist/core/human/summary.d.ts +4 -7
  78. package/dist/core/human/summary.js +4 -60
  79. package/dist/core/human/tickets.d.ts +10 -9
  80. package/dist/core/human/tickets.js +5 -3
  81. package/dist/core/human/types.d.ts +4 -0
  82. package/dist/core/keybindings/catalog.d.ts +2 -2
  83. package/dist/core/keybindings/catalog.js +1 -0
  84. package/dist/core/review/realize.js +9 -1
  85. package/dist/core/runtime/boot-root.js +6 -1
  86. package/dist/core/runtime/broker.js +8 -6
  87. package/dist/core/runtime/busy.d.ts +4 -3
  88. package/dist/core/runtime/busy.js +4 -3
  89. package/dist/core/runtime/spawn.js +4 -4
  90. package/dist/core/termrender/display.d.ts +7 -3
  91. package/dist/core/termrender/display.js +45 -9
  92. package/dist/core/termrender/termrender.d.ts +3 -12
  93. package/dist/core/termrender/termrender.js +6 -37
  94. package/dist/core/user-settings.d.ts +17 -2
  95. package/dist/core/user-settings.js +32 -11
  96. package/dist/daemon/api/handlers/human.js +1 -1
  97. package/dist/daemon/api/handlers/inbox.js +22 -7
  98. package/dist/daemon/cron-run.js +16 -2
  99. package/dist/daemon/crtrd.js +1 -1
  100. package/dist/daemon/fleet.d.ts +22 -10
  101. package/dist/daemon/fleet.js +43 -24
  102. package/dist/daemon/human/finish.d.ts +2 -1
  103. package/dist/daemon/human/finish.js +45 -28
  104. package/dist/daemon/human/sweep.js +4 -2
  105. package/dist/daemon/reconcilers/broker-supervision.js +11 -8
  106. package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
  107. package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
  108. package/dist/daemon/reconcilers/live-obligation.js +30 -2
  109. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  110. package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
  111. package/dist/daemon/review/sweep.js +12 -1
  112. package/dist/pages/bundle.css +1 -1
  113. package/dist/pages/bundle.js +1008 -577
  114. package/dist/pages/comments.d.ts +29 -0
  115. package/dist/pages/comments.js +51 -0
  116. package/dist/pages/controls.d.ts +98 -0
  117. package/dist/pages/controls.js +261 -0
  118. package/dist/pages/elements/cards.d.ts +15 -4
  119. package/dist/pages/elements/cards.js +299 -186
  120. package/dist/pages/elements/chart.d.ts +22 -0
  121. package/dist/pages/elements/chart.js +107 -91
  122. package/dist/pages/elements/options.d.ts +9 -0
  123. package/dist/pages/elements/options.js +268 -155
  124. package/dist/pages/elements/pages.d.ts +6 -0
  125. package/dist/pages/elements/pages.js +60 -38
  126. package/dist/pages/elements/slot.d.ts +5 -0
  127. package/dist/pages/elements/slot.js +17 -10
  128. package/dist/pages/elements/table.d.ts +17 -0
  129. package/dist/pages/elements/table.js +336 -228
  130. package/dist/pages/elements/text.d.ts +9 -3
  131. package/dist/pages/elements/text.js +350 -143
  132. package/dist/pages/host.d.ts +0 -24
  133. package/dist/pages/host.js +0 -17
  134. package/dist/pages/readonly.d.ts +24 -0
  135. package/dist/pages/readonly.js +31 -0
  136. package/dist/pages/responses.d.ts +37 -0
  137. package/dist/pages/responses.js +56 -0
  138. package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
  139. package/dist/pi-extensions/canvas-bash-valve.js +12 -8
  140. package/dist/types.d.ts +19 -5
  141. package/dist/types.js +2 -1
  142. package/package.json +1 -1
  143. package/runtime.lock.json +2 -2
  144. package/scripts/install-runtime.mjs +19 -24
  145. package/dist/clients/attach/render/html-markdown.d.ts +0 -13
  146. package/dist/clients/attach/render/html-markdown.js +0 -206
  147. package/dist/core/human/markdown-html.d.ts +0 -8
  148. package/dist/core/human/markdown-html.js +0 -581
@@ -17,10 +17,22 @@
17
17
  * Selection is kept in the agent's authored order, never click order, so the response reads
18
18
  * the way the question did. `freetext` is absent rather than empty: the response says whether
19
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.
20
29
  */
21
- import { adoptComments, setComment, findComment } from '../comments.js';
30
+ import { adoptComments, canRecordComments, setComment, findComment } from '../comments.js';
31
+ import { controlStyle, focusRingStyle, writingBoxStyle } from '../controls.js';
22
32
  import { respond, savedIds, savedResponse } from '../host.js';
33
+ import { applyReadOnly, isReadOnly } from '../readonly.js';
23
34
  import { registerElement } from '../register.js';
35
+ import { registerSlotResponse } from '../responses.js';
24
36
  import { readSlotConfig, readSlotId } from '../slot-config.js';
25
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>';
26
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>';
@@ -31,84 +43,118 @@ const STYLE = `
31
43
 
32
44
  [part="container"] {
33
45
  display: grid;
34
- gap: var(--crtr-space-3);
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));
35
54
  }
36
55
 
37
56
  [part="list"] {
38
57
  display: grid;
39
- gap: var(--crtr-space-2);
58
+ gap: var(--crtr-options-list-gap, var(--crtr-space-2));
40
59
  }
41
60
 
42
61
  [part="option"] {
43
62
  position: relative;
44
63
  display: flex;
45
64
  align-items: flex-start;
46
- gap: var(--crtr-space-3);
47
- padding: var(--crtr-space-2) var(--crtr-space-3);
48
- background: var(--crtr-color-surface);
49
- border: 1px solid var(--crtr-color-border);
50
- border-radius: var(--crtr-radius-medium);
51
- transition: background-color 120ms ease, border-color 120ms ease;
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);
52
74
  }
53
75
 
54
76
  [part="option"]:hover,
55
77
  [part="option"][data-active="true"] {
56
- background: var(--crtr-color-surface-hover);
78
+ background: var(--crtr-options-option-hover-fill, var(--crtr-color-surface-hover));
57
79
  }
58
80
 
59
81
  [part="option"][data-selected="true"] {
60
- border-color: var(--crtr-color-accent);
61
- background: color-mix(in srgb, var(--crtr-color-accent) 10%, var(--crtr-color-surface));
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;
62
104
  }
63
105
 
64
106
  [part="option-label"] {
65
107
  display: flex;
66
108
  flex: 1;
67
109
  align-items: flex-start;
68
- gap: var(--crtr-space-3);
110
+ gap: var(--crtr-options-option-label-gap, var(--crtr-space-3));
69
111
  cursor: pointer;
70
112
  }
71
113
 
72
- [part="option-control"] {
73
- margin: 0;
74
- margin-top: 0.15em;
75
- accent-color: var(--crtr-color-accent);
76
- }
114
+ ${controlStyle({ scope: 'options-option-control' }, '[part="option-control"]')}
77
115
 
78
- [part="option-control"]:focus-visible {
79
- outline: 2px solid var(--crtr-color-focus);
80
- outline-offset: 2px;
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);
81
120
  }
82
121
 
83
122
  [part="option-text"] {
84
123
  display: grid;
85
- gap: var(--crtr-space-1);
124
+ gap: var(--crtr-options-option-text-gap, var(--crtr-space-1));
86
125
  }
87
126
 
88
127
  [part="option-title"] {
89
- font-weight: 500;
128
+ font-weight: var(--crtr-options-option-title-font-weight, 500);
90
129
  }
91
130
 
92
131
  [part="option-description"] {
93
- color: var(--crtr-color-text-muted);
94
- font-size: 0.9em;
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);
95
134
  }
96
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. */
97
139
  [part="comment"] {
98
140
  display: inline-flex;
99
141
  align-items: center;
100
142
  justify-content: center;
101
143
  flex: none;
102
- width: 1.5rem;
103
- height: 1.5rem;
104
- padding: 0;
105
- color: var(--crtr-color-text-muted);
106
- background: transparent;
107
- border: 1px solid transparent;
108
- border-radius: var(--crtr-radius-small);
109
- opacity: 0;
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);
110
154
  cursor: pointer;
111
- transition: opacity 120ms ease, color 120ms ease, background-color 120ms ease;
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));
112
158
  }
113
159
 
114
160
  [part="option"]:hover [part="comment"],
@@ -116,158 +162,166 @@ const STYLE = `
116
162
  [part="option"]:focus-within [part="comment"],
117
163
  [part="comment"][data-state="commented"],
118
164
  [part="comment"][aria-expanded="true"] {
119
- opacity: 1;
165
+ opacity: var(--crtr-options-comment-visible-opacity, 1);
120
166
  }
121
167
 
122
168
  [part="comment"]:hover {
123
- background: var(--crtr-color-surface-hover);
124
- border-color: var(--crtr-color-border);
125
- }
126
-
127
- [part="comment"]:focus-visible {
128
- outline: 2px solid var(--crtr-color-focus);
129
- outline-offset: 1px;
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));
130
171
  }
131
172
 
173
+ ${focusRingStyle({ scope: 'options-comment' }, '[part="comment"]')}
132
174
  [part="comment"][data-state="commented"] {
133
- color: var(--crtr-color-accent);
175
+ color: var(--crtr-options-comment-commented-text-color, var(--crtr-color-accent));
134
176
  }
135
177
 
136
178
  [part="comment"] svg {
137
- width: 0.9rem;
138
- height: 0.9rem;
139
- fill: none;
140
- stroke: currentColor;
141
- stroke-width: 1.25;
142
- stroke-linecap: round;
143
- stroke-linejoin: round;
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);
144
186
  }
145
187
 
146
188
  [part="comment-popover"] {
147
189
  position: absolute;
148
- top: calc(100% + var(--crtr-space-1));
190
+ top: calc(100% + var(--crtr-options-comment-popover-offset, var(--crtr-space-1)));
149
191
  right: 0;
150
192
  z-index: 2;
151
193
  display: grid;
152
- gap: var(--crtr-space-2);
153
- width: min(20rem, 100%);
154
- padding: var(--crtr-space-3);
155
- background: var(--crtr-color-background);
156
- border: 1px solid var(--crtr-color-border);
157
- border-radius: var(--crtr-radius-medium);
158
- box-shadow: var(--crtr-shadow);
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));
159
203
  }
160
204
 
161
205
  [part="comment-popover"][hidden] {
162
206
  display: none;
163
207
  }
164
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. */
165
211
  [part="comment-subject"] {
166
- margin: 0;
167
- color: var(--crtr-color-text-muted);
168
- font-family: var(--crtr-font-mono);
169
- font-size: 0.75em;
170
- letter-spacing: 0.06em;
171
- text-transform: uppercase;
172
- }
173
-
174
- [part="comment-input"],
175
- [part="freetext-input"] {
176
- width: 100%;
177
- box-sizing: border-box;
178
- padding: var(--crtr-space-2);
179
- color: var(--crtr-color-text);
180
- font: inherit;
181
- background: var(--crtr-color-surface);
182
- border: 1px solid var(--crtr-color-border);
183
- border-radius: var(--crtr-radius-small);
184
- resize: vertical;
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);
185
215
  }
186
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"]')}
187
222
  [part="comment-input"] {
188
- min-height: 4.5rem;
223
+ min-height: var(--crtr-options-comment-input-min-height, 4.5em);
224
+ resize: vertical;
189
225
  }
190
226
 
191
227
  [part="freetext-input"] {
192
- min-height: 4rem;
193
- }
194
-
195
- [part="comment-input"]:focus-visible,
196
- [part="freetext-input"]:focus-visible {
197
- outline: 2px solid var(--crtr-color-focus);
198
- outline-offset: -1px;
228
+ min-height: var(--crtr-options-freetext-input-min-height, 4em);
229
+ resize: vertical;
199
230
  }
200
231
 
201
232
  [part="comment-actions"] {
202
233
  display: flex;
203
234
  align-items: center;
204
235
  justify-content: space-between;
205
- gap: var(--crtr-space-2);
236
+ gap: var(--crtr-options-comment-actions-gap, var(--crtr-space-2));
206
237
  }
207
238
 
208
239
  [part="comment-hint"] {
209
- color: var(--crtr-color-text-muted);
210
- font-family: var(--crtr-font-mono);
211
- font-size: 0.75em;
212
- letter-spacing: 0.06em;
213
- text-transform: uppercase;
214
- }
215
-
216
- [part="comment-cancel"],
217
- [part="comment-save"] {
218
- padding: var(--crtr-space-1) var(--crtr-space-2);
219
- font: inherit;
220
- font-size: 0.9em;
221
- border-radius: var(--crtr-radius-small);
222
- cursor: pointer;
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);
223
242
  }
224
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. */
225
246
  [part="comment-cancel"] {
226
- color: var(--crtr-color-text-muted);
227
- background: transparent;
228
- border: 1px solid transparent;
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));
229
260
  }
230
261
 
231
262
  [part="comment-cancel"]:hover {
232
- color: var(--crtr-color-text);
233
- background: var(--crtr-color-surface-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));
234
265
  }
235
266
 
236
267
  [part="comment-save"] {
237
- color: var(--crtr-color-accent-contrast);
238
- background: var(--crtr-color-accent);
239
- border: 1px solid var(--crtr-color-accent);
240
- }
241
-
242
- [part="comment-cancel"]:focus-visible,
243
- [part="comment-save"]:focus-visible {
244
- outline: 2px solid var(--crtr-color-focus);
245
- outline-offset: 1px;
246
- }
247
-
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"]')}
248
298
  [part="freetext"] {
249
299
  display: grid;
250
- gap: var(--crtr-space-1);
300
+ gap: var(--crtr-options-freetext-gap, var(--crtr-space-1));
251
301
  }
252
302
 
253
303
  [part="freetext-label"] {
254
- color: var(--crtr-color-text-muted);
255
- font-size: 0.85em;
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);
256
306
  }
257
307
 
258
308
  [part="unavailable"] {
259
- padding: var(--crtr-space-3);
260
- color: var(--crtr-color-text-muted);
261
- background: var(--crtr-color-surface);
262
- border: 1px solid var(--crtr-color-border);
263
- border-radius: var(--crtr-radius-medium);
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));
264
316
  }
265
317
 
266
318
  [part="reason"] {
267
- color: var(--crtr-color-text);
268
- font-family: var(--crtr-font-mono);
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));
269
321
  }
270
322
  `;
323
+ /** Ids are scoped to this element's shadow root, so one constant is unique everywhere. */
324
+ const LABEL_ID = 'prompt-label';
271
325
  let groupCounter = 0;
272
326
  /**
273
327
  * Whether a key belongs to something being written in rather than to the list grammar.
@@ -284,6 +338,8 @@ function isTextEntry(target) {
284
338
  return target.type !== 'checkbox' && target.type !== 'radio';
285
339
  }
286
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'];
287
343
  #shadow;
288
344
  #config;
289
345
  #slotId;
@@ -317,12 +373,31 @@ class CrtrOptionsElement extends HTMLElement {
317
373
  this.#config = config;
318
374
  this.#order = config.options.map((option) => option.id);
319
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());
320
381
  this.#render(config);
321
382
  this.#sync();
322
383
  }
323
384
  disconnectedCallback() {
324
385
  document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
325
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
+ }
326
401
  /**
327
402
  * Seed local state from the answer the host is already holding, so a reopened page shows
328
403
  * what it would submit. Ids that no longer name an option are dropped, and single-select
@@ -362,10 +437,24 @@ class CrtrOptionsElement extends HTMLElement {
362
437
  style.textContent = STYLE;
363
438
  const container = document.createElement('div');
364
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
+ }
365
448
  const list = document.createElement('div');
366
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.
367
452
  if (config.mode === 'single')
368
453
  list.setAttribute('role', 'radiogroup');
454
+ else if (labelled)
455
+ list.setAttribute('role', 'group');
456
+ if (labelled)
457
+ list.setAttribute('aria-labelledby', LABEL_ID);
369
458
  groupCounter += 1;
370
459
  const group = `crtr-options-${groupCounter}`;
371
460
  for (const option of config.options)
@@ -404,6 +493,24 @@ class CrtrOptionsElement extends HTMLElement {
404
493
  text.append(description);
405
494
  }
406
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) {
407
514
  const comment = document.createElement('button');
408
515
  comment.part.add('comment');
409
516
  comment.type = 'button';
@@ -454,11 +561,7 @@ class CrtrOptionsElement extends HTMLElement {
454
561
  buttons.append(cancel, save);
455
562
  actions.append(hint, buttons);
456
563
  popover.append(subject, input, actions);
457
- root.append(label, comment, popover);
458
- root.addEventListener('mouseenter', () => this.#setActive(option.id));
459
- root.addEventListener('mouseleave', () => this.#clearActive(option.id));
460
- this.#rows.set(option.id, { id: option.id, label: option.label, root, control, comment, popover, input, hint });
461
- return root;
564
+ return { trigger: comment, popover, input, hint };
462
565
  }
463
566
  #renderFreetext(config) {
464
567
  const wrapper = document.createElement('label');
@@ -522,7 +625,7 @@ class CrtrOptionsElement extends HTMLElement {
522
625
  this.#sync();
523
626
  }
524
627
  #toggle(optionId, selected) {
525
- if (this.#config === undefined)
628
+ if (this.#config === undefined || isReadOnly(this))
526
629
  return;
527
630
  if (this.#config.mode === 'single') {
528
631
  this.#selected = [optionId];
@@ -548,19 +651,21 @@ class CrtrOptionsElement extends HTMLElement {
548
651
  #onDocumentPointerDown = (event) => {
549
652
  if (this.#commentingId === undefined)
550
653
  return;
551
- const row = this.#rows.get(this.#commentingId);
552
- if (row === undefined)
654
+ const parts = this.#rows.get(this.#commentingId)?.comment;
655
+ if (parts === undefined)
553
656
  return;
554
657
  const path = event.composedPath();
555
- if (path.includes(row.popover) || path.includes(row.comment))
658
+ if (path.includes(parts.popover) || path.includes(parts.trigger))
556
659
  return;
557
660
  // No focus restore: the press is on its way somewhere else, and pulling focus back to
558
661
  // the trigger would fight it.
559
662
  this.#closeComment(false);
560
663
  };
561
664
  #openComment(optionId) {
562
- const row = this.#rows.get(optionId);
563
- if (row === undefined)
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))
564
669
  return;
565
670
  if (this.#commentingId === undefined) {
566
671
  document.addEventListener('pointerdown', this.#onDocumentPointerDown, true);
@@ -568,27 +673,27 @@ class CrtrOptionsElement extends HTMLElement {
568
673
  this.#commentingId = optionId;
569
674
  this.#activeId = optionId;
570
675
  // Seeded from the saved comment every time it opens.
571
- row.input.value = findComment(this.#comments, anchorFor(optionId))?.text ?? '';
676
+ parts.input.value = findComment(this.#comments, anchorFor(optionId))?.text ?? '';
572
677
  this.#sync();
573
- row.input.focus();
678
+ parts.input.focus();
574
679
  }
575
680
  #closeComment(restoreFocus = true) {
576
681
  if (this.#commentingId === undefined)
577
682
  return;
578
- const row = this.#rows.get(this.#commentingId);
683
+ const parts = this.#rows.get(this.#commentingId)?.comment;
579
684
  this.#commentingId = undefined;
580
685
  document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
581
686
  this.#sync();
582
687
  if (restoreFocus)
583
- row?.comment.focus();
688
+ parts?.trigger.focus();
584
689
  }
585
690
  #commitComment(optionId) {
586
- const row = this.#rows.get(optionId);
587
- if (row === undefined)
691
+ const parts = this.#rows.get(optionId)?.comment;
692
+ if (parts === undefined || isReadOnly(this))
588
693
  return;
589
694
  // Clearing the box deletes the comment — `setComment` treats empty text as removal, so
590
695
  // there is no separate destructive control.
591
- this.#comments = setComment(this.#comments, anchorFor(optionId), row.input.value);
696
+ this.#comments = setComment(this.#comments, anchorFor(optionId), parts.input.value);
592
697
  this.#closeComment();
593
698
  this.#emit();
594
699
  }
@@ -599,20 +704,22 @@ class CrtrOptionsElement extends HTMLElement {
599
704
  row.control.checked = selected;
600
705
  row.root.dataset.selected = String(selected);
601
706
  row.root.dataset.active = String(this.#activeId === row.id);
707
+ const parts = row.comment;
708
+ if (parts === undefined)
709
+ continue;
602
710
  const comment = findComment(this.#comments, anchorFor(row.id));
603
- row.comment.dataset.state = comment === undefined ? 'empty' : 'commented';
604
- row.comment.innerHTML = comment === undefined ? BUBBLE_PLUS : BUBBLE_TEXT;
605
- row.comment.setAttribute('aria-label', comment === undefined ? `Comment on ${row.label}` : `Edit comment on ${row.label}`);
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}`);
606
714
  const open = this.#commentingId === row.id;
607
- row.comment.setAttribute('aria-expanded', String(open));
608
- row.popover.hidden = !open;
609
- row.hint.textContent = comment === undefined ? '\u2318\u21b5 to save' : 'empty to delete';
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';
610
718
  }
719
+ this.#syncReadOnly();
611
720
  }
612
- /** The complete current response, on every user change — never a delta. */
613
- #emit() {
614
- if (this.#slotId === undefined)
615
- return;
721
+ /** The complete current response — what every change hands `respond()`. */
722
+ #response() {
616
723
  const response = {
617
724
  selectedOptionIds: [...this.#selected],
618
725
  comments: this.#comments.map((comment) => ({ ...comment })),
@@ -620,7 +727,13 @@ class CrtrOptionsElement extends HTMLElement {
620
727
  if (this.#config?.allowFreetext === true && this.#freetext !== undefined) {
621
728
  response.freetext = this.#freetext;
622
729
  }
623
- respond(this.#slotId, response);
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());
624
737
  }
625
738
  }
626
739
  function anchorFor(optionId) {
@@ -20,5 +20,11 @@
20
20
  *
21
21
  * A single-step page renders no pager chrome at all — no progress, no forward/back — just
22
22
  * the submit affordance, because there is nothing to flip.
23
+ *
24
+ * Both elements paint entirely through `--crtr-page-*` component tokens — the same element
25
+ * scope `bundle.css` gives `crtr-pages` and `crtr-page`, because a person looking at the page
26
+ * sees one thing. The pager's buttons are the bundle's ordinary buttons, so their focus ring
27
+ * and their motion chain through the shared `--crtr-control-*` family before falling back to
28
+ * the kit default.
23
29
  */
24
30
  export {};