@north-light/crouter 0.3.197 → 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 (144) 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 +81 -153
  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/user-settings.d.ts +17 -2
  91. package/dist/core/user-settings.js +32 -11
  92. package/dist/daemon/api/handlers/human.js +1 -1
  93. package/dist/daemon/api/handlers/inbox.js +22 -7
  94. package/dist/daemon/cron-run.js +16 -2
  95. package/dist/daemon/crtrd.js +1 -1
  96. package/dist/daemon/fleet.d.ts +22 -10
  97. package/dist/daemon/fleet.js +43 -24
  98. package/dist/daemon/human/finish.d.ts +2 -1
  99. package/dist/daemon/human/finish.js +45 -28
  100. package/dist/daemon/human/sweep.js +4 -2
  101. package/dist/daemon/reconcilers/broker-supervision.js +11 -8
  102. package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
  103. package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
  104. package/dist/daemon/reconcilers/live-obligation.js +30 -2
  105. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  106. package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
  107. package/dist/daemon/review/sweep.js +12 -1
  108. package/dist/pages/bundle.css +1 -1
  109. package/dist/pages/bundle.js +1008 -577
  110. package/dist/pages/comments.d.ts +29 -0
  111. package/dist/pages/comments.js +51 -0
  112. package/dist/pages/controls.d.ts +98 -0
  113. package/dist/pages/controls.js +261 -0
  114. package/dist/pages/elements/cards.d.ts +15 -4
  115. package/dist/pages/elements/cards.js +299 -186
  116. package/dist/pages/elements/chart.d.ts +22 -0
  117. package/dist/pages/elements/chart.js +107 -91
  118. package/dist/pages/elements/options.d.ts +9 -0
  119. package/dist/pages/elements/options.js +268 -155
  120. package/dist/pages/elements/pages.d.ts +6 -0
  121. package/dist/pages/elements/pages.js +60 -38
  122. package/dist/pages/elements/slot.d.ts +5 -0
  123. package/dist/pages/elements/slot.js +17 -10
  124. package/dist/pages/elements/table.d.ts +17 -0
  125. package/dist/pages/elements/table.js +336 -228
  126. package/dist/pages/elements/text.d.ts +9 -3
  127. package/dist/pages/elements/text.js +350 -143
  128. package/dist/pages/host.d.ts +0 -24
  129. package/dist/pages/host.js +0 -17
  130. package/dist/pages/readonly.d.ts +24 -0
  131. package/dist/pages/readonly.js +31 -0
  132. package/dist/pages/responses.d.ts +37 -0
  133. package/dist/pages/responses.js +56 -0
  134. package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
  135. package/dist/pi-extensions/canvas-bash-valve.js +12 -8
  136. package/dist/types.d.ts +19 -5
  137. package/dist/types.js +2 -1
  138. package/package.json +1 -1
  139. package/runtime.lock.json +2 -2
  140. package/scripts/install-runtime.mjs +19 -24
  141. package/dist/clients/attach/render/html-markdown.d.ts +0 -13
  142. package/dist/clients/attach/render/html-markdown.js +0 -206
  143. package/dist/core/human/markdown-html.d.ts +0 -8
  144. package/dist/core/human/markdown-html.js +0 -581
@@ -4,11 +4,17 @@
4
4
  *
5
5
  * The three shapes in the catalog are all this one element:
6
6
  *
7
- * - a **draft** the user edits in place — `editable`, pre-filled;
8
- * - **read** text the user leaves anchored comments on, review-style — select a range and
9
- * the comment affordance appears at the selection;
7
+ * - a **draft** the user edits in place — pre-filled and editable by default;
8
+ * - **read** text the user leaves anchored comments on, review-style — set `editable: false`,
9
+ * then select a range and the comment affordance appears at the selection;
10
10
  * - an **empty input** — `editable` with no starting text, which opens already editing.
11
11
  *
12
+ * `singleLine` is the fourth shape and the one that is not prose: one compact form field for a
13
+ * subject line, a name, a URL. It is always editing, so it drops the edit toggle; it has no
14
+ * paragraphs to anchor a range against, so it drops the comment affordance and shows only the
15
+ * comments a saved answer already carried. Enter is a form field's Enter — it types nothing,
16
+ * and the pager's `⌘`/`Ctrl` + `Enter` still submits.
17
+ *
12
18
  * Text is always commentable; that is not a capability switch, and edit and comment coexist.
13
19
  * Reading is the resting state, because that is where anchored commenting lives; the edit
14
20
  * toggle swaps the block for a plain textarea. Comments survive the switch, but a range
@@ -22,15 +28,20 @@
22
28
  * and `comment-affordance.tsx` @ `3705fe9e2`), framework-free and styled only through the
23
29
  * bundle's `--crtr-*` tokens.
24
30
  */
31
+ import { applyReadOnly, isReadOnly } from '../readonly.js';
25
32
  import { registerElement } from '../register.js';
26
33
  import { readSlotConfig, readSlotId } from '../slot-config.js';
27
34
  import { respond, savedResponse } from '../host.js';
28
- import { adoptComments, findComment, setComment } from '../comments.js';
35
+ import { registerSlotResponse } from '../responses.js';
36
+ import { adoptComments, canRecordComments, findComment, quotePreview, setComment } from '../comments.js';
37
+ import { focusRingStyle, writingBoxStyle } from '../controls.js';
29
38
  const WHOLE = { kind: 'whole' };
30
39
  const WHOLE_TARGET = 'the whole passage';
31
40
  const RANGE_TARGET = 'the selected text';
32
41
  const POPOVER_GAP = 6;
33
42
  const POPOVER_ID = 'comment-popover';
43
+ /** Ids are scoped to this element's shadow root, so one constant is unique everywhere. */
44
+ const LABEL_ID = 'prompt-label';
34
45
  const STYLE = `
35
46
  :host {
36
47
  display: block;
@@ -39,7 +50,14 @@ const STYLE = `
39
50
  [part="container"] {
40
51
  position: relative;
41
52
  display: grid;
42
- gap: var(--crtr-space-2);
53
+ gap: var(--crtr-text-container-gap, var(--crtr-space-2));
54
+ }
55
+
56
+ /* The prompt reads as the agent's own prose above the passage, matching the table's
57
+ caption rather than shouting over the text it introduces. */
58
+ [part="label"] {
59
+ margin: var(--crtr-text-label-margin, 0);
60
+ color: var(--crtr-text-label-text-color, var(--crtr-color-text-muted));
43
61
  }
44
62
 
45
63
  [part="toolbar"] {
@@ -47,134 +65,178 @@ const STYLE = `
47
65
  flex-wrap: wrap;
48
66
  align-items: center;
49
67
  justify-content: flex-end;
50
- gap: var(--crtr-space-2);
68
+ gap: var(--crtr-text-toolbar-gap, var(--crtr-space-2));
51
69
  }
52
70
 
53
71
  [part="edited"] {
54
- padding: 0 var(--crtr-space-2);
55
- color: var(--crtr-color-accent);
56
- background: color-mix(in srgb, var(--crtr-color-accent) 14%, transparent);
57
- border-radius: var(--crtr-radius-small);
58
- font-family: var(--crtr-font-mono);
59
- font-size: 0.6875rem;
60
- letter-spacing: 0.06em;
61
- line-height: 1.7;
62
- text-transform: uppercase;
72
+ padding: var(--crtr-text-edited-padding, 0 var(--crtr-space-2));
73
+ color: var(--crtr-text-edited-text-color, var(--crtr-color-accent));
74
+ background: var(--crtr-text-edited-fill, color-mix(in srgb, var(--crtr-color-accent) 14%, transparent));
75
+ border-radius: var(--crtr-text-edited-radius, var(--crtr-radius-small));
76
+ font-family: var(--crtr-text-edited-font-family, var(--crtr-font-mono));
77
+ font-size: var(--crtr-text-edited-font-size, 0.75em);
78
+ letter-spacing: var(--crtr-text-edited-letter-spacing, 0.06em);
79
+ line-height: var(--crtr-text-edited-line-height, 1.7);
80
+ text-transform: var(--crtr-text-edited-text-transform, uppercase);
63
81
  }
64
82
 
65
83
  button {
66
84
  display: inline-flex;
67
85
  align-items: center;
68
- gap: var(--crtr-space-1);
69
- max-width: 100%;
70
- padding: var(--crtr-space-1) var(--crtr-space-2);
71
- color: var(--crtr-color-text-muted);
72
- font: inherit;
73
- background: transparent;
74
- border: 1px solid transparent;
75
- border-radius: var(--crtr-radius-small);
86
+ gap: var(--crtr-text-button-gap, var(--crtr-space-1));
87
+ max-width: var(--crtr-text-button-max-width, 100%);
88
+ padding: var(--crtr-text-button-padding, var(--crtr-space-1) var(--crtr-space-2));
89
+ color: var(--crtr-text-button-text-color, var(--crtr-color-text-muted));
90
+ font: var(--crtr-text-button-font, inherit);
91
+ background: var(--crtr-text-button-fill, transparent);
92
+ border: var(--crtr-text-button-border-width, 1px)
93
+ var(--crtr-text-button-border-style, solid)
94
+ var(--crtr-text-button-border-color, transparent);
95
+ border-radius: var(--crtr-text-button-radius, var(--crtr-radius-small));
76
96
  cursor: pointer;
97
+ /* One motion knob for all three properties, over the family's, because a button that
98
+ settles at a different speed than the field beside it is the drift controls.ts exists
99
+ to remove. */
100
+ transition: color var(--crtr-text-button-transition, var(--crtr-control-transition, 120ms ease)),
101
+ background-color var(--crtr-text-button-transition, var(--crtr-control-transition, 120ms ease)),
102
+ border-color var(--crtr-text-button-transition, var(--crtr-control-transition, 120ms ease));
77
103
  }
78
104
 
79
105
  button:hover {
80
- color: var(--crtr-color-text);
81
- background: var(--crtr-color-surface-hover);
106
+ color: var(--crtr-text-button-hover-text-color, var(--crtr-color-text));
107
+ background: var(--crtr-text-button-hover-fill, var(--crtr-color-surface-hover));
82
108
  }
83
109
 
84
- button:focus-visible {
85
- outline: 2px solid var(--crtr-color-focus);
86
- outline-offset: 2px;
110
+ ${focusRingStyle({ scope: 'text-whole-comment' }, '[part~="whole-comment"]')}
111
+ ${focusRingStyle({ scope: 'text-edit-toggle' }, '[part="edit-toggle"]')}
112
+ ${focusRingStyle({ scope: 'text-selection-comment' }, '[part~="selection-comment"]')}
113
+ ${focusRingStyle({ scope: 'text-comment-chip' }, '[part~="comment-chip"]')}
114
+ ${focusRingStyle({ scope: 'text-popover-save' }, '[part="popover-save"]')}
115
+ ${focusRingStyle({ scope: 'text-popover-cancel' }, '[part="popover-cancel"]')}
116
+ button[data-state="on"] {
117
+ color: var(--crtr-text-button-on-text-color, var(--crtr-color-accent));
87
118
  }
88
119
 
89
- button[data-state="on"] {
90
- color: var(--crtr-color-accent);
120
+ /* Editing a prefilled draft is a primary path, not an icon to discover. Keep the control
121
+ beside the text but give it the bordered, labelled shape that reads as an action. */
122
+ [part="edit-toggle"] {
123
+ color: var(--crtr-text-edit-toggle-text-color, var(--crtr-color-text));
124
+ background: var(--crtr-text-edit-toggle-fill, var(--crtr-color-background));
125
+ border-color: var(--crtr-text-edit-toggle-border-color, var(--crtr-color-border));
91
126
  }
92
127
 
128
+ /* Reached for and engaged are one treatment here, so they are one knob: active. */
129
+ [part="edit-toggle"]:hover,
130
+ [part="edit-toggle"][data-state="on"] {
131
+ color: var(--crtr-text-edit-toggle-active-text-color, var(--crtr-color-accent));
132
+ border-color: var(--crtr-text-edit-toggle-active-border-color,
133
+ color-mix(in srgb, var(--crtr-color-accent) 45%, var(--crtr-color-border)));
134
+ }
135
+
136
+ /* Width and height are one visual knob — the icon is square. */
93
137
  svg {
94
- width: 0.875rem;
95
- height: 0.875rem;
138
+ width: var(--crtr-text-icon-size, 0.875em);
139
+ height: var(--crtr-text-icon-size, 0.875em);
96
140
  flex: none;
97
141
  }
98
142
 
143
+ /* Both writing boxes in one call, because in the kit they are one control at two heights:
144
+ input.tsx and textarea.tsx carry the same border, radius, padding, fill, elevation and
145
+ focus ring, and differ only in how tall they start. Writing them separately is what let
146
+ them drift into two visibly different fields — different padding, different radius,
147
+ different fill, and an inset outline on one against the kit's outset ring on the other.
148
+
149
+ Each selector still gets its own scope: the field and editor are distinct anatomy even
150
+ though this recipe keeps their default paint identical. */
151
+ ${writingBoxStyle({ scope: 'text-input' }, '[part="input"]')}
152
+ ${writingBoxStyle({ scope: 'text-editor' }, '[part="editor"]')}
153
+ /* The one thing the textarea does not share: it starts tall and may be dragged taller. */
99
154
  [part="editor"] {
100
- display: block;
101
- box-sizing: border-box;
102
- width: 100%;
103
- min-height: 10rem;
104
- padding: var(--crtr-space-3) var(--crtr-space-4);
105
- color: var(--crtr-color-text);
106
- font: inherit;
107
- line-height: var(--crtr-line-height);
108
- background: var(--crtr-color-background);
109
- border: 1px solid var(--crtr-color-border);
110
- border-radius: var(--crtr-radius-medium);
155
+ min-height: var(--crtr-text-editor-min-height, 10em);
111
156
  resize: vertical;
112
157
  }
113
158
 
114
- [part="editor"]:focus-visible,
115
- [part="popover-input"]:focus-visible {
116
- outline: 2px solid var(--crtr-color-focus);
117
- outline-offset: -1px;
159
+ /* The kit ring is outset paint, so a writing box needs a three-pixel gutter inside its grid
160
+ track. Without it the box fills the track exactly and the ring is shaved off by a tight
161
+ host frame. Prose is the other face of this surface and keeps its own inset ring, so it
162
+ stays full-bleed. */
163
+ [part="surface"]:has([part="input"]),
164
+ [part="surface"]:has([part="editor"]) {
165
+ padding: var(--crtr-text-surface-padding, 0.1875rem);
118
166
  }
119
167
 
120
168
  [part="body"] {
121
169
  position: relative;
122
- padding: var(--crtr-space-3) var(--crtr-space-4);
123
- background: var(--crtr-color-surface);
124
- border: 1px solid var(--crtr-color-border);
125
- border-radius: var(--crtr-radius-medium);
170
+ padding: var(--crtr-text-body-padding, var(--crtr-space-3) var(--crtr-space-4));
171
+ background: var(--crtr-text-body-fill, var(--crtr-color-surface));
172
+ border: var(--crtr-text-body-border-width, 1px)
173
+ var(--crtr-text-body-border-style, solid)
174
+ var(--crtr-text-body-border-color, var(--crtr-color-border));
175
+ border-radius: var(--crtr-text-body-radius, var(--crtr-radius-medium));
126
176
  }
127
177
 
178
+ /* The ring sits inside the border rather than outside it. The body is full-bleed — it runs
179
+ the whole width of the frame the host gives the page — so an outset ring has nowhere to be
180
+ drawn and gets clipped at the frame's edges, showing three sides of a rectangle. */
128
181
  [part="body"]:focus-visible {
129
- outline: 2px solid var(--crtr-color-focus);
130
- outline-offset: 2px;
182
+ outline: var(--crtr-text-body-focus-ring-width, 2px)
183
+ var(--crtr-text-body-focus-ring-style, solid)
184
+ var(--crtr-text-body-focus-ring-color, var(--crtr-color-focus));
185
+ outline-offset: var(--crtr-text-body-focus-ring-offset, -2px);
131
186
  }
132
187
 
133
188
  [part="prose"] {
134
189
  display: grid;
135
- gap: var(--crtr-space-3);
190
+ gap: var(--crtr-text-prose-gap, var(--crtr-space-3));
136
191
  }
137
192
 
138
193
  [part="prose"] p {
139
- margin: 0;
194
+ margin: var(--crtr-text-prose-paragraph-margin, 0);
195
+ /* The agent's own line breaks are the passage; preserving them is behavior. */
140
196
  white-space: pre-wrap;
141
197
  }
142
198
 
143
199
  [part="placeholder"] {
144
- color: var(--crtr-color-text-muted);
200
+ color: var(--crtr-text-placeholder-text-color, var(--crtr-color-text-muted));
145
201
  }
146
202
 
147
203
  [part~="commented"] {
148
204
  position: relative;
149
- border-radius: var(--crtr-radius-small);
150
- background: color-mix(in srgb, var(--crtr-color-accent) 18%, transparent);
151
- text-decoration: underline dotted;
152
- text-decoration-color: var(--crtr-color-accent);
153
- text-underline-offset: 4px;
205
+ border-radius: var(--crtr-text-commented-radius, var(--crtr-radius-small));
206
+ background: var(--crtr-text-commented-fill, color-mix(in srgb, var(--crtr-color-accent) 18%, transparent));
207
+ text-decoration: var(--crtr-text-commented-text-decoration, underline dotted);
208
+ text-decoration-color: var(--crtr-text-commented-text-decoration-color, var(--crtr-color-accent));
209
+ text-underline-offset: var(--crtr-text-commented-text-underline-offset, 0.25em);
154
210
  cursor: help;
155
- outline: none;
211
+ outline: var(--crtr-text-commented-outline, none);
156
212
  }
157
213
 
158
214
  [part~="commented"]:focus-visible {
159
- outline: 2px solid var(--crtr-color-focus);
160
- outline-offset: 2px;
215
+ outline: var(--crtr-text-commented-focus-ring-width, 2px)
216
+ var(--crtr-text-commented-focus-ring-style, solid)
217
+ var(--crtr-text-commented-focus-ring-color, var(--crtr-color-focus));
218
+ outline-offset: var(--crtr-text-commented-focus-ring-offset, 2px);
161
219
  }
162
220
 
221
+ /* The staged delays here and on the hover rule below are the bubble's show/hide
222
+ choreography — behavior rather than a styling value, so they stay literal. */
163
223
  [part="comment-bubble"] {
164
224
  position: absolute;
165
- bottom: calc(100% + var(--crtr-space-1));
225
+ bottom: calc(100% + var(--crtr-text-comment-bubble-offset, var(--crtr-space-1)));
166
226
  left: 0;
167
227
  z-index: 2;
168
228
  display: grid;
169
- gap: var(--crtr-space-1);
170
- width: max-content;
171
- max-width: 18rem;
172
- padding: var(--crtr-space-2) var(--crtr-space-3);
173
- color: var(--crtr-color-text);
174
- background: var(--crtr-color-background);
175
- border: 1px solid var(--crtr-color-border);
176
- border-radius: var(--crtr-radius-medium);
177
- box-shadow: var(--crtr-shadow);
229
+ gap: var(--crtr-text-comment-bubble-gap, var(--crtr-space-1));
230
+ width: var(--crtr-text-comment-bubble-width, max-content);
231
+ max-width: var(--crtr-text-comment-bubble-max-width, 18em);
232
+ padding: var(--crtr-text-comment-bubble-padding, var(--crtr-space-2) var(--crtr-space-3));
233
+ color: var(--crtr-text-comment-bubble-text-color, var(--crtr-color-text));
234
+ background: var(--crtr-text-comment-bubble-fill, var(--crtr-color-background));
235
+ border: var(--crtr-text-comment-bubble-border-width, 1px)
236
+ var(--crtr-text-comment-bubble-border-style, solid)
237
+ var(--crtr-text-comment-bubble-border-color, var(--crtr-color-border));
238
+ border-radius: var(--crtr-text-comment-bubble-radius, var(--crtr-radius-medium));
239
+ box-shadow: var(--crtr-text-comment-bubble-shadow, var(--crtr-shadow));
178
240
  text-decoration: none;
179
241
  white-space: normal;
180
242
  user-select: none;
@@ -190,45 +252,46 @@ const STYLE = `
190
252
  transition: opacity 120ms ease 220ms, visibility 0s linear 220ms;
191
253
  }
192
254
 
255
+ /* The comment layer's micro-labels are the page's own prose, one step down and muted — not
256
+ caps-mono. Mono set in capitals reads as machine output, and what these say ("Comment on
257
+ this passage", "2 comments") is the interface talking to a person. */
193
258
  [part="bubble-label"],
194
259
  [part="comment-count"],
195
260
  [part="popover-subject"],
196
261
  [part="popover-hint"] {
197
- color: var(--crtr-color-text-muted);
198
- font-family: var(--crtr-font-mono);
199
- font-size: 0.6875rem;
200
- letter-spacing: 0.06em;
201
- text-transform: uppercase;
262
+ color: var(--crtr-text-comment-label-text-color, var(--crtr-color-text-muted));
263
+ font-size: var(--crtr-text-comment-label-font-size, 0.875em);
202
264
  }
203
265
 
204
266
  [part="bubble-label"] {
205
- color: var(--crtr-color-accent);
267
+ color: var(--crtr-text-bubble-label-text-color, var(--crtr-color-accent));
206
268
  }
207
269
 
208
270
  [part~="selection-comment"] {
209
271
  position: absolute;
210
272
  z-index: 3;
211
- color: var(--crtr-color-accent);
212
- background: var(--crtr-color-background);
213
- border-color: color-mix(in srgb, var(--crtr-color-accent) 40%, transparent);
214
- box-shadow: var(--crtr-shadow);
273
+ color: var(--crtr-text-selection-comment-text-color, var(--crtr-color-accent));
274
+ background: var(--crtr-text-selection-comment-fill, var(--crtr-color-background));
275
+ border-color: var(--crtr-text-selection-comment-border-color,
276
+ color-mix(in srgb, var(--crtr-color-accent) 40%, transparent));
277
+ box-shadow: var(--crtr-text-selection-comment-shadow, var(--crtr-shadow));
215
278
  }
216
279
 
217
280
  [part="comment-rail"] {
218
281
  display: grid;
219
- gap: var(--crtr-space-1);
282
+ gap: var(--crtr-text-comment-rail-gap, var(--crtr-space-1));
220
283
  }
221
284
 
222
285
  [part="comment-chips"] {
223
286
  display: flex;
224
287
  flex-wrap: wrap;
225
- gap: var(--crtr-space-2);
288
+ gap: var(--crtr-text-comment-chips-gap, var(--crtr-space-2));
226
289
  }
227
290
 
228
291
  [part~="comment-chip"] {
229
- color: var(--crtr-color-text);
230
- border-color: var(--crtr-color-border);
231
- text-align: left;
292
+ color: var(--crtr-text-comment-chip-text-color, var(--crtr-color-text));
293
+ border-color: var(--crtr-text-comment-chip-border-color, var(--crtr-color-border));
294
+ text-align: var(--crtr-text-comment-chip-text-align, left);
232
295
  }
233
296
 
234
297
  [part~="comment-chip"] > span {
@@ -242,17 +305,21 @@ const STYLE = `
242
305
  z-index: 5;
243
306
  box-sizing: border-box;
244
307
  display: grid;
245
- gap: var(--crtr-space-2);
246
- width: 20rem;
247
- max-width: 100%;
248
- padding: var(--crtr-space-3);
249
- background: var(--crtr-color-background);
250
- border: 1px solid var(--crtr-color-border);
251
- border-radius: var(--crtr-radius-medium);
252
- box-shadow: var(--crtr-shadow);
308
+ gap: var(--crtr-text-popover-gap, var(--crtr-space-2));
309
+ width: var(--crtr-text-popover-width, 20em);
310
+ max-width: var(--crtr-text-popover-max-width, 100%);
311
+ padding: var(--crtr-text-popover-padding, var(--crtr-space-3));
312
+ background: var(--crtr-text-popover-fill, var(--crtr-color-background));
313
+ border: var(--crtr-text-popover-border-width, 1px)
314
+ var(--crtr-text-popover-border-style, solid)
315
+ var(--crtr-text-popover-border-color, var(--crtr-color-border));
316
+ border-radius: var(--crtr-text-popover-radius, var(--crtr-radius-medium));
317
+ box-shadow: var(--crtr-text-popover-shadow, var(--crtr-shadow));
253
318
  }
254
319
 
255
320
  [part="popover"][hidden],
321
+ [part="toolbar"][hidden],
322
+ [part~="whole-comment"][hidden],
256
323
  [part="edited"][hidden],
257
324
  [part="comment-rail"][hidden],
258
325
  [part~="selection-comment"][hidden],
@@ -261,29 +328,30 @@ const STYLE = `
261
328
  display: none;
262
329
  }
263
330
 
264
- [part="popover-subject"],
265
- [part="popover-quote"] {
266
- margin: 0;
331
+ [part="popover-subject"] {
332
+ margin: var(--crtr-text-popover-subject-margin, 0);
267
333
  }
268
334
 
335
+ /* Smaller than the comment being written, because it is the context and not the subject.
336
+ The text arrives already elided to about two lines by quotePreview, so it wraps normally
337
+ rather than keeping the source's line breaks. */
269
338
  [part="popover-quote"] {
270
- padding: var(--crtr-space-1) var(--crtr-space-2);
271
- color: var(--crtr-color-text-muted);
272
- background: color-mix(in srgb, var(--crtr-color-accent) 8%, transparent);
273
- border-left: 2px solid color-mix(in srgb, var(--crtr-color-accent) 40%, transparent);
274
- white-space: pre-wrap;
339
+ margin: var(--crtr-text-popover-quote-margin, 0);
340
+ padding: var(--crtr-text-popover-quote-padding, var(--crtr-space-1) var(--crtr-space-2));
341
+ color: var(--crtr-text-popover-quote-text-color, var(--crtr-color-text-muted));
342
+ font-size: var(--crtr-text-popover-quote-font-size, 0.8125em);
343
+ background: var(--crtr-text-popover-quote-fill, color-mix(in srgb, var(--crtr-color-accent) 8%, transparent));
344
+ border-left: var(--crtr-text-popover-quote-border-width, 2px)
345
+ var(--crtr-text-popover-quote-border-style, solid)
346
+ var(--crtr-text-popover-quote-border-color, color-mix(in srgb, var(--crtr-color-accent) 40%, transparent));
347
+ overflow-wrap: anywhere;
275
348
  }
276
349
 
350
+ /* The composer is its own scope: it is the one writing box a person can meet beside the
351
+ passage's own, so the two are separately restylable. */
352
+ ${writingBoxStyle({ scope: 'text-popover-input' }, '[part="popover-input"]')}
277
353
  [part="popover-input"] {
278
- box-sizing: border-box;
279
- width: 100%;
280
- min-height: 5rem;
281
- padding: var(--crtr-space-2);
282
- color: var(--crtr-color-text);
283
- font: inherit;
284
- background: var(--crtr-color-surface);
285
- border: 1px solid var(--crtr-color-border);
286
- border-radius: var(--crtr-radius-small);
354
+ min-height: var(--crtr-text-popover-input-min-height, 5em);
287
355
  resize: vertical;
288
356
  }
289
357
 
@@ -291,41 +359,47 @@ const STYLE = `
291
359
  display: flex;
292
360
  align-items: center;
293
361
  justify-content: space-between;
294
- gap: var(--crtr-space-2);
362
+ gap: var(--crtr-text-popover-actions-gap, var(--crtr-space-2));
295
363
  }
296
364
 
297
365
  [part="popover-buttons"] {
298
366
  display: flex;
299
- gap: var(--crtr-space-2);
367
+ gap: var(--crtr-text-popover-buttons-gap, var(--crtr-space-2));
300
368
  }
301
369
 
302
370
  [part="popover-save"] {
303
- color: var(--crtr-color-accent-contrast);
304
- background: var(--crtr-color-accent);
371
+ color: var(--crtr-text-popover-save-text-color, var(--crtr-color-accent-contrast));
372
+ background: var(--crtr-text-popover-save-fill, var(--crtr-color-accent));
305
373
  }
306
374
 
375
+ /* Mixing the accent toward the text colour darkens on a light token set and lightens on a
376
+ dark one — an opacity or brightness step moves the wrong way on one of them. */
307
377
  [part="popover-save"]:hover {
308
- color: var(--crtr-color-accent-contrast);
309
- background: var(--crtr-color-accent);
310
- opacity: 0.9;
378
+ color: var(--crtr-text-popover-save-hover-text-color, var(--crtr-color-accent-contrast));
379
+ background: var(--crtr-text-popover-save-hover-fill,
380
+ color-mix(in srgb, var(--crtr-color-accent) 88%, var(--crtr-color-text)));
311
381
  }
312
382
 
383
+ /* Cancel is the quiet half of every editor in this bundle: no border until you reach for it. */
313
384
  [part="popover-cancel"] {
314
- border-color: var(--crtr-color-border);
385
+ border-color: var(--crtr-text-popover-cancel-border-color, transparent);
315
386
  }
316
387
 
388
+ /* Flat: this sits in the flow of the page. A shadow in this bundle means floating — the
389
+ comment bubble, the selection button, the popover, and nothing else. */
317
390
  [part="unavailable"] {
318
- padding: var(--crtr-space-3);
319
- color: var(--crtr-color-text-muted);
320
- background: var(--crtr-color-surface);
321
- border: 1px solid var(--crtr-color-border);
322
- border-radius: var(--crtr-radius-medium);
323
- box-shadow: var(--crtr-shadow);
391
+ padding: var(--crtr-text-unavailable-padding, var(--crtr-space-3));
392
+ color: var(--crtr-text-unavailable-text-color, var(--crtr-color-text-muted));
393
+ background: var(--crtr-text-unavailable-fill, var(--crtr-color-surface));
394
+ border: var(--crtr-text-unavailable-border-width, 1px)
395
+ var(--crtr-text-unavailable-border-style, solid)
396
+ var(--crtr-text-unavailable-border-color, var(--crtr-color-border));
397
+ border-radius: var(--crtr-text-unavailable-radius, var(--crtr-radius-medium));
324
398
  }
325
399
 
326
400
  [part="reason"] {
327
- color: var(--crtr-color-text);
328
- font-family: var(--crtr-font-mono);
401
+ color: var(--crtr-text-reason-text-color, var(--crtr-color-text));
402
+ font-family: var(--crtr-text-reason-font-family, var(--crtr-font-mono));
329
403
  }
330
404
  `;
331
405
  const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -479,8 +553,11 @@ function markTrigger(element) {
479
553
  element.setAttribute('aria-expanded', 'false');
480
554
  }
481
555
  class CrtrTextElement extends HTMLElement {
556
+ /** The host marks a resolved or canceled page `inert`; that is this element's read-only signal. */
557
+ static observedAttributes = ['inert'];
482
558
  #root;
483
559
  #container = document.createElement('div');
560
+ #label = document.createElement('p');
484
561
  #toolbar = document.createElement('div');
485
562
  #editedBadge = document.createElement('span');
486
563
  #wholeComment = document.createElement('button');
@@ -488,6 +565,7 @@ class CrtrTextElement extends HTMLElement {
488
565
  #editLabel = document.createElement('span');
489
566
  #surface = document.createElement('div');
490
567
  #editor = document.createElement('textarea');
568
+ #input = document.createElement('input');
491
569
  #body = document.createElement('div');
492
570
  #prose = document.createElement('div');
493
571
  #selectionComment = document.createElement('button');
@@ -503,18 +581,28 @@ class CrtrTextElement extends HTMLElement {
503
581
  #popoverCancel = document.createElement('button');
504
582
  #config = { initialText: '' };
505
583
  #slotId;
584
+ #singleLine = false;
506
585
  #text = '';
507
586
  #comments = [];
508
587
  #editing = false;
509
588
  #live = false;
510
589
  #draft = null;
511
590
  #open = null;
591
+ /** Live only while the popover is open: its composer resizes, and the room reserved for it must follow. */
592
+ #popoverResize = new ResizeObserver(() => {
593
+ const open = this.#open;
594
+ if (open !== null)
595
+ this.#positionPopover(open.trigger, open.align);
596
+ });
512
597
  constructor() {
513
598
  super();
514
599
  this.#root = this.attachShadow({ mode: 'open' });
515
600
  const style = document.createElement('style');
516
601
  style.textContent = STYLE;
517
602
  this.#container.part.add('container');
603
+ this.#label.part.add('label');
604
+ this.#label.id = LABEL_ID;
605
+ this.#label.hidden = true;
518
606
  this.#toolbar.part.add('toolbar');
519
607
  this.#editedBadge.part.add('edited');
520
608
  this.#editedBadge.textContent = 'edited';
@@ -548,6 +636,19 @@ class CrtrTextElement extends HTMLElement {
548
636
  this.#renderToolbar();
549
637
  this.#emit();
550
638
  });
639
+ this.#input.type = 'text';
640
+ this.#input.part.add('input');
641
+ this.#input.addEventListener('input', () => {
642
+ this.#text = this.#input.value;
643
+ this.#renderToolbar();
644
+ this.#emit();
645
+ });
646
+ // A form field's Enter types nothing and submits nothing here: the page's own submit is the
647
+ // pager's modified chord, which this leaves alone.
648
+ this.#input.addEventListener('keydown', (event) => {
649
+ if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey)
650
+ event.preventDefault();
651
+ });
551
652
  this.#body.part.add('body');
552
653
  this.#body.tabIndex = 0;
553
654
  this.#prose.part.add('prose');
@@ -602,7 +703,7 @@ class CrtrTextElement extends HTMLElement {
602
703
  buttons.append(this.#popoverCancel, this.#popoverSave);
603
704
  actions.append(this.#popoverHint, buttons);
604
705
  this.#popover.append(this.#popoverSubject, this.#popoverQuote, this.#popoverInput, actions);
605
- this.#container.append(this.#toolbar, this.#surface, this.#rail, this.#popover);
706
+ this.#container.append(this.#label, this.#toolbar, this.#surface, this.#rail, this.#popover);
606
707
  this.#root.append(style, this.#container);
607
708
  this.addEventListener('keydown', this.#onKeyDown);
608
709
  }
@@ -620,16 +721,28 @@ class CrtrTextElement extends HTMLElement {
620
721
  }
621
722
  this.#live = true;
622
723
  this.#config = read.config;
724
+ this.#singleLine = read.config.singleLine === true;
725
+ this.#surface.toggleAttribute('data-single-line', this.#singleLine);
623
726
  this.#slotId = readSlotId(this);
624
727
  this.#text = read.config.initialText;
728
+ this.#renderLabel();
625
729
  this.#hydrate();
626
- this.#editing = read.config.editable === true && this.#text.length === 0;
730
+ // A text slot the person never touches still has an answer — the words on screen — so the
731
+ // pager can report it at submit even though no change ever called `respond()`.
732
+ if (this.#slotId !== undefined)
733
+ registerSlotResponse(this, this.#slotId, () => this.#response());
734
+ this.#editing = this.#editable && this.#text.length === 0;
627
735
  this.#renderToolbar();
628
736
  this.#renderSurface();
629
737
  this.#renderRail();
630
738
  }
739
+ attributeChangedCallback() {
740
+ if (this.isConnected)
741
+ this.#syncReadOnly();
742
+ }
631
743
  disconnectedCallback() {
632
744
  document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
745
+ this.#popoverResize.disconnect();
633
746
  }
634
747
  /**
635
748
  * Seed local state from the answer the host is already holding, so a reopened page shows
@@ -643,7 +756,7 @@ class CrtrTextElement extends HTMLElement {
643
756
  const saved = savedResponse(this.#slotId);
644
757
  if (saved === undefined)
645
758
  return;
646
- if (this.#config.editable === true && typeof saved.text === 'string')
759
+ if ((this.#editable || this.#singleLine) && typeof saved.text === 'string')
647
760
  this.#text = saved.text;
648
761
  this.#comments = adoptComments(saved.comments);
649
762
  }
@@ -667,18 +780,80 @@ class CrtrTextElement extends HTMLElement {
667
780
  box.append('Text unavailable: ', detail);
668
781
  this.#container.replaceChildren(box);
669
782
  }
783
+ /**
784
+ * The prompt names both faces of the surface, because the writing box and the prose body
785
+ * swap places under it and either one can be what the user is on.
786
+ */
787
+ #renderLabel() {
788
+ const label = this.#config.label;
789
+ const shown = label !== undefined && label !== '';
790
+ this.#label.hidden = !shown;
791
+ this.#label.textContent = shown ? label : '';
792
+ for (const surface of [this.#editor, this.#input, this.#body]) {
793
+ if (shown)
794
+ surface.setAttribute('aria-labelledby', LABEL_ID);
795
+ else
796
+ surface.removeAttribute('aria-labelledby');
797
+ }
798
+ }
670
799
  #renderToolbar() {
671
- this.#editedBadge.hidden = this.#text === this.#config.initialText;
800
+ // A field the agent left empty has nothing to differ FROM, so the badge would fire on the
801
+ // first keystroke and say nothing; it earns its place only over a prefill.
802
+ this.#editedBadge.hidden = this.#text === this.#config.initialText || (this.#singleLine && this.#config.initialText === '');
672
803
  const whole = findComment(this.#comments, WHOLE);
673
804
  this.#wholeComment.replaceChildren(icon(whole ? ICON_COMMENT : ICON_COMMENT_ADD));
674
805
  this.#wholeComment.dataset.state = whole ? 'on' : 'off';
675
806
  this.#wholeComment.setAttribute('aria-label', `${whole ? 'Edit comment on' : 'Comment on'} ${WHOLE_TARGET}`);
676
- this.#editToggle.hidden = this.#config.editable !== true;
807
+ // No comment affordance where a comment has nowhere to go — see `canRecordComments`.
808
+ this.#wholeComment.hidden = this.#singleLine || !this.#commentable;
809
+ this.#editToggle.hidden = !this.#editable;
810
+ // In single-line mode the toolbar carries the badge alone, so it goes with it.
811
+ this.#toolbar.hidden = this.#singleLine && this.#editedBadge.hidden;
677
812
  this.#editToggle.dataset.state = this.#editing ? 'on' : 'off';
678
- this.#editLabel.textContent = this.#editing ? 'Done' : 'Edit';
813
+ this.#editLabel.textContent = this.#editing ? 'Done editing' : 'Edit text';
679
814
  this.#editToggle.replaceChildren(icon(this.#editing ? ICON_DONE : ICON_EDIT), this.#editLabel);
815
+ this.#syncReadOnly();
816
+ }
817
+ /**
818
+ * Repainted with the toolbar, which is repainted on every state change. The writing box
819
+ * goes read-only rather than disabled, so a saved answer stays selectable and copyable;
820
+ * the edit toggle goes disabled, because a page that can no longer take an answer has
821
+ * nothing to toggle into.
822
+ */
823
+ #syncReadOnly() {
824
+ const readOnly = isReadOnly(this);
825
+ applyReadOnly(this.#root, readOnly);
826
+ // The editor lives outside the tree whenever the surface is showing prose, so the
827
+ // sweep over the shadow root never reaches it. The compact field is a writing box too:
828
+ // the sweep disables every input it finds, and this puts it back as read-only, which
829
+ // keeps a saved one-line answer selectable and copyable.
830
+ this.#editor.readOnly = readOnly;
831
+ this.#input.disabled = false;
832
+ this.#input.readOnly = readOnly;
833
+ this.#editToggle.disabled = readOnly;
834
+ // A page can go read-only while the popover is open — the host sets `inert` on a ticket
835
+ // that resolves under it. A box that can no longer save is closed rather than left there
836
+ // taking words nothing will keep.
837
+ if (readOnly && this.#open !== null)
838
+ this.#closeComment(false);
839
+ }
840
+ /** Multi-line text is a writing surface unless the author explicitly makes it review-only. */
841
+ get #editable() {
842
+ return !this.#singleLine && this.#config.editable !== false;
843
+ }
844
+ /** Whether this slot can keep a comment — see `canRecordComments`. */
845
+ get #commentable() {
846
+ return canRecordComments(this.#slotId);
680
847
  }
681
848
  #renderSurface() {
849
+ if (this.#singleLine) {
850
+ if (this.#input.value !== this.#text)
851
+ this.#input.value = this.#text;
852
+ this.#input.placeholder = this.#config.placeholder ?? '';
853
+ if (this.#surface.firstChild !== this.#input)
854
+ this.#surface.replaceChildren(this.#input);
855
+ return;
856
+ }
682
857
  if (this.#editing) {
683
858
  if (this.#editor.value !== this.#text)
684
859
  this.#editor.value = this.#text;
@@ -795,7 +970,7 @@ class CrtrTextElement extends HTMLElement {
795
970
  * starts where the user is looking.
796
971
  */
797
972
  #captureSelection() {
798
- if (this.#editing || this.#open !== null)
973
+ if (this.#editing || this.#open !== null || !this.#commentable)
799
974
  return;
800
975
  const draft = this.#readSelection();
801
976
  if (draft === null) {
@@ -813,6 +988,11 @@ class CrtrTextElement extends HTMLElement {
813
988
  }
814
989
  // — the comment popover ————————————————————————————————————————————————————
815
990
  #openComment(anchor, target, trigger, align, quote) {
991
+ // The popover opens only where its comment can land: never on a slot that cannot record
992
+ // one, and never on a read-only page, whose textareas are already `readOnly`, so it would
993
+ // take focus and refuse every keystroke.
994
+ if (!this.#commentable || isReadOnly(this))
995
+ return;
816
996
  // Invoking the trigger of the open popover closes it, and moving to another trigger
817
997
  // hands the open state over — the popover is one dialog, and exactly one control at a
818
998
  // time reports itself as expanded.
@@ -825,34 +1005,55 @@ class CrtrTextElement extends HTMLElement {
825
1005
  previous.trigger.setAttribute('aria-expanded', 'false');
826
1006
  }
827
1007
  const existing = findComment(this.#comments, anchor);
828
- this.#open = { anchor, trigger };
1008
+ this.#open = { anchor, trigger, align };
829
1009
  trigger.setAttribute('aria-expanded', 'true');
830
1010
  this.#popoverSubject.textContent = `Comment on ${target}`;
831
1011
  this.#popover.setAttribute('aria-label', `Comment on ${target}`);
832
- this.#popoverQuote.hidden = quote === undefined || quote === '';
833
- this.#popoverQuote.textContent = quote ?? '';
1012
+ const preview = quotePreview(quote ?? '');
1013
+ this.#popoverQuote.hidden = preview === '';
1014
+ this.#popoverQuote.textContent = preview;
834
1015
  // The box is seeded from the saved comment every time it opens, so an abandoned edit
835
1016
  // never leaks into the next one.
836
1017
  this.#popoverInput.value = existing?.text ?? '';
837
1018
  this.#popoverHint.textContent = existing ? 'empty to delete' : '\u2318\u21b5 to save';
838
1019
  this.#popover.hidden = false;
839
1020
  this.#positionPopover(trigger, align);
1021
+ // The composer is draggable-tall; observing the open popover keeps the reserved room in
1022
+ // step with it, so a resized box never pushes its own actions past the frame.
1023
+ this.#popoverResize.observe(this.#popover);
840
1024
  this.#popoverInput.focus();
841
1025
  document.addEventListener('pointerdown', this.#onDocumentPointerDown, true);
842
1026
  }
843
1027
  #positionPopover(trigger, align) {
1028
+ // Start from the page's natural height. A previous open may have reserved room for a
1029
+ // lower-edge composer, and measuring against that stale room would make it accumulate.
1030
+ this.#container.style.paddingBottom = '';
844
1031
  const bounds = this.#container.getBoundingClientRect();
845
1032
  const rect = trigger.getBoundingClientRect();
846
1033
  const width = this.#popover.offsetWidth;
1034
+ const height = this.#popover.offsetHeight;
847
1035
  const raw = align === 'end' ? rect.right - bounds.left - width : rect.left - bounds.left;
848
1036
  const left = Math.min(Math.max(raw, 0), Math.max(0, bounds.width - width));
1037
+ const below = rect.bottom + POPOVER_GAP + height <= window.innerHeight;
1038
+ const above = rect.top - POPOVER_GAP - height >= 0;
1039
+ const top = !below && above
1040
+ ? rect.top - bounds.top - POPOVER_GAP - height
1041
+ : rect.bottom - bounds.top + POPOVER_GAP;
849
1042
  this.#popover.style.left = `${left}px`;
850
- this.#popover.style.top = `${rect.bottom - bounds.top + POPOVER_GAP}px`;
1043
+ this.#popover.style.top = `${top}px`;
1044
+ // An inline Gateway frame reports the body's in-flow height. Absolute popovers do not
1045
+ // contribute to that measurement, so reserve only the missing tail when neither side can
1046
+ // hold the composer. The host then grows the iframe and the entire writing box stays live.
1047
+ const overflow = this.#popover.getBoundingClientRect().bottom - document.body.getBoundingClientRect().bottom;
1048
+ if (overflow > 0)
1049
+ this.#container.style.paddingBottom = `${overflow}px`;
851
1050
  }
852
1051
  #closeComment(restoreFocus = true) {
853
1052
  const open = this.#open;
854
1053
  this.#open = null;
855
1054
  this.#popover.hidden = true;
1055
+ this.#popoverResize.disconnect();
1056
+ this.#container.style.paddingBottom = '';
856
1057
  open?.trigger.setAttribute('aria-expanded', 'false');
857
1058
  document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
858
1059
  const returnFocus = open !== null && open.trigger !== this.#selectionComment ? open.trigger : this.#body;
@@ -863,7 +1064,7 @@ class CrtrTextElement extends HTMLElement {
863
1064
  /** Clearing the box and saving deletes the comment; `setComment` is where that lives. */
864
1065
  #commitComment() {
865
1066
  const open = this.#open;
866
- if (open === null)
1067
+ if (open === null || isReadOnly(this))
867
1068
  return;
868
1069
  // Saving replaces every highlight and every chip, so a trigger living in either is
869
1070
  // about to be detached: close without restoring focus to that doomed node, then hand
@@ -872,7 +1073,7 @@ class CrtrTextElement extends HTMLElement {
872
1073
  this.#comments = setComment(this.#comments, open.anchor, this.#popoverInput.value);
873
1074
  this.#closeComment(!anchored);
874
1075
  this.#renderToolbar();
875
- if (!this.#editing)
1076
+ if (!this.#editing && !this.#singleLine)
876
1077
  this.#renderProse();
877
1078
  this.#renderRail();
878
1079
  if (anchored)
@@ -943,6 +1144,12 @@ class CrtrTextElement extends HTMLElement {
943
1144
  * the whole passage when neither says otherwise.
944
1145
  */
945
1146
  #commentFocusedAnchor() {
1147
+ // A one-line field has no passage to anchor against, and the shape offers no way to write
1148
+ // a comment; the chips a saved answer arrived with stay clickable, and that is the whole path.
1149
+ // The same key must not raise the selection button on a slot that cannot record what it
1150
+ // would take — this is the one comment path that shows an affordance before opening it.
1151
+ if (this.#singleLine || !this.#commentable)
1152
+ return;
946
1153
  const draft = this.#draft ?? this.#readSelection();
947
1154
  if (draft !== null) {
948
1155
  this.#draft = draft;