@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
@@ -15,51 +15,106 @@
15
15
  * Ported from the agent interaction kit's `agent-table.tsx` (northlight `3705fe9e2`), with the
16
16
  * kit's `classic-list-keys` grammar — j/k move, x selects, c comments — reimplemented here
17
17
  * over the shadow root.
18
+ *
19
+ * ## Styling
20
+ *
21
+ * Every styling value below is read through a `--crtr-table-*` component token, so a host
22
+ * restyles this element without touching crtr — custom properties inherit through the shadow
23
+ * boundary, which is the whole mechanism. The parts a person can name each own a scope:
24
+ * `container`, `caption`, `header`, `column-toggle`, `row`, `cell`, `selection` (the row
25
+ * control and the header's select-all, deliberately one thing), `comment-trigger`,
26
+ * `comment-editor`, `comment-input`, `comment-button` and its `comment-cancel` /
27
+ * `comment-save` paint, `notice`, `reason`, `empty`. Where a shared recipe draws the value the
28
+ * chain is `--crtr-table-<part>-<knob>` over the family's `--crtr-control-*` and then the kit
29
+ * default; every default stays inline at the rule that spends it, and no token is declared
30
+ * anywhere, which is what keeps the kit's own look when a host sets nothing.
31
+ *
32
+ * Layout and behaviour stay literal: `display`, positioning, the shrink-to-content column
33
+ * widths, text alignment, and the trigger's reveal-on-hover `opacity` are anatomy rather than
34
+ * paint, so they are not knobs.
18
35
  */
36
+ import { controlStyle, focusRingStyle, writingBoxStyle } from '../controls.js';
19
37
  import { registerElement } from '../register.js';
20
38
  import { readSlotConfig, readSlotId } from '../slot-config.js';
21
- import { artifactData, respond, savedIds, savedResponse } from '../host.js';
22
- import { adoptComments, anchorLabel, findComment, setComment } from '../comments.js';
39
+ import { respond, savedIds, savedResponse } from '../host.js';
40
+ import { adoptComments, anchorLabel, canRecordComments, findComment, setComment } from '../comments.js';
41
+ import { applyReadOnly, isReadOnly } from '../readonly.js';
42
+ import { registerSlotResponse } from '../responses.js';
43
+ /** The gap between a comment trigger and the editor that opens off it. */
44
+ const EDITOR_GAP = 4;
45
+ /**
46
+ * The motion the table's control-shaped things wear. A column toggle, a comment trigger and
47
+ * the editor's two buttons are all controls, so their timing chains through the family's
48
+ * `--crtr-control-transition` before reaching the kit default; a row is not a control, so its
49
+ * tint animation carries an element token alone.
50
+ */
51
+ const TOGGLE_MOTION = 'var(--crtr-table-column-toggle-transition, var(--crtr-control-transition, 120ms ease))';
52
+ const ROW_MOTION = 'var(--crtr-table-row-transition, 120ms ease)';
53
+ const TRIGGER_MOTION = 'var(--crtr-table-comment-trigger-transition, var(--crtr-control-transition, 120ms ease))';
54
+ const COMMENT_BUTTON_MOTION = 'var(--crtr-table-comment-button-transition, var(--crtr-control-transition, 120ms ease))';
23
55
  const COMMENT_BUBBLE = 'M3.25 3.5h9.5A1.75 1.75 0 0 1 14.5 5.25v5A1.75 1.75 0 0 1 12.75 12H7.4l-3.15 2.36A.5.5 0 0 1 3.5 14v-2H3.25A1.75 1.75 0 0 1 1.5 10.25v-5A1.75 1.75 0 0 1 3.25 3.5Z';
24
56
  const STYLE = `
25
57
  :host {
26
58
  display: block;
27
59
  }
28
60
 
29
- [part="container"] {
61
+ /* The positioning context for the comment editor, and the ONLY one. The editor cannot live
62
+ inside the container: that box clips to its own rounded corners, so a popover anchored to
63
+ a row was cut off at the table's edge instead of floating over it. The frame has no paint
64
+ and no clipping, so the editor overlays whatever the table sits on. */
65
+ [part="frame"] {
30
66
  position: relative;
31
- background: var(--crtr-color-background);
32
- border: 1px solid var(--crtr-color-border);
33
- border-radius: var(--crtr-radius-medium);
34
- box-shadow: var(--crtr-shadow);
67
+ }
68
+
69
+ /* Flat: the table sits in the flow of the page. A shadow in this bundle means floating —
70
+ the comment editor, and nothing else here. */
71
+ [part="container"] {
72
+ background: var(--crtr-table-container-fill, var(--crtr-color-background));
73
+ border: var(--crtr-table-container-border-width, 1px)
74
+ var(--crtr-table-container-border-style, solid)
75
+ var(--crtr-table-container-border-color, var(--crtr-color-border));
76
+ border-radius: var(--crtr-table-container-radius, var(--crtr-radius-medium));
35
77
  overflow: hidden;
36
78
  }
37
79
 
80
+ /* Inside the border, not outside it: the container is full-bleed, so an outset ring is
81
+ clipped at the edges of the frame the host gives the page and draws only three sides.
82
+ That is why this is NOT the shared control focus family — it is a focusable region rather
83
+ than a control, and it says so with its own inset outline. */
38
84
  [part="container"]:focus-visible {
39
- outline: 2px solid var(--crtr-color-focus);
40
- outline-offset: 2px;
85
+ outline: var(--crtr-table-container-focus-outline, 2px solid var(--crtr-color-focus));
86
+ outline-offset: var(--crtr-table-container-focus-outline-offset, -2px);
41
87
  }
42
88
 
89
+ /* Typography here is the element's host typography, not a second set of knobs: font stays
90
+ literal inherit so --crtr-table-font-family, --crtr-table-font-size and
91
+ --crtr-table-line-height reach the table, and the colour reads the host's own
92
+ --crtr-table-color, whose fallback is the colour this rule used to hard-code. */
43
93
  [part="table"] {
44
94
  width: 100%;
45
95
  border-collapse: collapse;
46
96
  font: inherit;
47
- color: var(--crtr-color-text);
97
+ color: var(--crtr-table-color, var(--crtr-color-text));
48
98
  }
49
99
 
50
100
  [part="caption"] {
51
101
  caption-side: top;
52
- padding: var(--crtr-space-3) var(--crtr-space-3) var(--crtr-space-2);
102
+ padding: var(--crtr-table-caption-padding, var(--crtr-space-3) var(--crtr-space-3) var(--crtr-space-2));
53
103
  text-align: left;
54
- color: var(--crtr-color-text-muted);
104
+ color: var(--crtr-table-caption-text-color, var(--crtr-color-text-muted));
55
105
  }
56
106
 
57
107
  th {
58
- padding: var(--crtr-space-2) var(--crtr-space-3);
108
+ padding: var(--crtr-table-header-padding, var(--crtr-space-2) var(--crtr-space-3));
59
109
  text-align: left;
60
- font-weight: 600;
61
- color: var(--crtr-color-text-muted);
62
- border-bottom: 1px solid var(--crtr-color-border);
110
+ font-weight: var(--crtr-table-header-font-weight, 600);
111
+ color: var(--crtr-table-header-text-color, var(--crtr-color-text-muted));
112
+ /* A tinted header reads as the table's own chrome; the column-selected tint still wins,
113
+ on specificity (0-1-0 against this 0-0-1). */
114
+ background: var(--crtr-table-header-fill, var(--crtr-color-surface));
115
+ border-bottom: var(--crtr-table-header-border-width, 1px)
116
+ var(--crtr-table-header-border-style, solid)
117
+ var(--crtr-table-header-border-color, var(--crtr-color-border));
63
118
  white-space: nowrap;
64
119
  }
65
120
 
@@ -70,7 +125,7 @@ const STYLE = `
70
125
  [part="header-cell-inner"] {
71
126
  display: inline-flex;
72
127
  align-items: center;
73
- gap: var(--crtr-space-1);
128
+ gap: var(--crtr-table-header-gap, var(--crtr-space-1));
74
129
  }
75
130
 
76
131
  th[data-align="right"] [part="header-cell-inner"] {
@@ -78,125 +133,170 @@ const STYLE = `
78
133
  }
79
134
 
80
135
  [part="column-toggle"] {
81
- font: inherit;
82
- font-weight: 600;
83
- color: inherit;
84
- background: transparent;
85
- border: 1px solid transparent;
86
- border-radius: var(--crtr-radius-small);
87
- padding: var(--crtr-space-1) var(--crtr-space-2);
88
- margin: calc(var(--crtr-space-1) * -1) calc(var(--crtr-space-2) * -1);
136
+ font: var(--crtr-table-column-toggle-font, inherit);
137
+ font-weight: var(--crtr-table-column-toggle-font-weight, 600);
138
+ color: var(--crtr-table-column-toggle-text-color, inherit);
139
+ background: var(--crtr-table-column-toggle-fill, transparent);
140
+ border: var(--crtr-table-column-toggle-border-width, 1px)
141
+ var(--crtr-table-column-toggle-border-style, solid)
142
+ var(--crtr-table-column-toggle-border-color, transparent);
143
+ border-radius: var(--crtr-table-column-toggle-radius, var(--crtr-radius-small));
144
+ padding: var(--crtr-table-column-toggle-padding, var(--crtr-space-1) var(--crtr-space-2));
145
+ /* The toggle is pulled back out of its own padding so the header label sits where an
146
+ unselectable one does; a host that repads the toggle repays this too. */
147
+ margin: var(--crtr-table-column-toggle-margin, calc(var(--crtr-space-1) * -1) calc(var(--crtr-space-2) * -1));
89
148
  cursor: pointer;
149
+ transition: color ${TOGGLE_MOTION}, background-color ${TOGGLE_MOTION}, border-color ${TOGGLE_MOTION};
90
150
  }
91
151
 
92
152
  [part="column-toggle"]:hover {
93
- background: var(--crtr-color-surface-hover);
153
+ background: var(--crtr-table-column-toggle-hover-fill, var(--crtr-color-surface-hover));
94
154
  }
95
155
 
96
156
  [part="column-toggle"][aria-pressed="true"] {
97
- color: var(--crtr-color-text);
98
- background: color-mix(in srgb, var(--crtr-color-accent) 12%, transparent);
99
- border-color: color-mix(in srgb, var(--crtr-color-accent) 40%, transparent);
157
+ color: var(--crtr-table-column-toggle-selected-text-color, var(--crtr-color-text));
158
+ background: var(--crtr-table-column-toggle-selected-fill,
159
+ color-mix(in srgb, var(--crtr-color-accent) 10%, transparent));
160
+ border-color: var(--crtr-table-column-toggle-selected-border-color,
161
+ color-mix(in srgb, var(--crtr-color-accent) 45%, transparent));
100
162
  }
101
163
 
102
- [part="column-toggle"]:focus-visible,
103
- [part="comment-trigger"]:focus-visible,
104
- [part="row-control"]:focus-visible,
105
- [part="select-all"]:focus-visible {
106
- outline: 2px solid var(--crtr-color-focus);
107
- outline-offset: 1px;
164
+ [part="column-toggle"][aria-pressed="true"]:hover {
165
+ background: var(--crtr-table-column-toggle-selected-hover-fill,
166
+ color-mix(in srgb, var(--crtr-color-accent) 14%, transparent));
108
167
  }
109
168
 
169
+ ${focusRingStyle({ scope: 'table-column-toggle' }, '[part="column-toggle"]')}
170
+ ${focusRingStyle({ scope: 'table-comment-trigger' }, '[part="comment-trigger"]')}
171
+
110
172
  [part="row"] {
111
- border-bottom: 1px solid color-mix(in srgb, var(--crtr-color-border) 60%, transparent);
173
+ border-bottom: var(--crtr-table-row-border-width, 1px)
174
+ var(--crtr-table-row-border-style, solid)
175
+ var(--crtr-table-row-border-color, color-mix(in srgb, var(--crtr-color-border) 60%, transparent));
176
+ transition: background-color ${ROW_MOTION};
112
177
  }
113
178
 
114
179
  [part="row"]:last-child {
115
- border-bottom: none;
180
+ border-bottom: var(--crtr-table-row-last-border, none);
116
181
  }
117
182
 
118
183
  [part="row"]:hover,
119
184
  [part="row"][data-active="true"] {
120
- background: var(--crtr-color-surface-hover);
185
+ background: var(--crtr-table-row-hover-fill, var(--crtr-color-surface-hover));
121
186
  }
122
187
 
123
- [part="row"][data-state="selected"],
124
- [part="row"][data-state="selected"]:hover {
125
- background: color-mix(in srgb, var(--crtr-color-accent) 8%, transparent);
188
+ [part="row"][data-state="selected"] {
189
+ background: var(--crtr-table-row-selected-fill, color-mix(in srgb, var(--crtr-color-accent) 10%, transparent));
126
190
  }
127
191
 
192
+ [part="row"][data-state="selected"]:hover,
193
+ [part="row"][data-state="selected"][data-active="true"] {
194
+ background: var(--crtr-table-row-selected-hover-fill,
195
+ color-mix(in srgb, var(--crtr-color-accent) 14%, transparent));
196
+ }
197
+
198
+ /* Inset for the same reason the container's is: a row runs the full width of the table. */
128
199
  [part="row"]:focus-visible {
129
- outline: 2px solid var(--crtr-color-focus);
130
- outline-offset: -2px;
200
+ outline: var(--crtr-table-row-focus-outline, 2px solid var(--crtr-color-focus));
201
+ outline-offset: var(--crtr-table-row-focus-outline-offset, -2px);
131
202
  }
132
203
 
133
204
  td {
134
- padding: var(--crtr-space-2) var(--crtr-space-3);
205
+ padding: var(--crtr-table-cell-padding, var(--crtr-space-2) var(--crtr-space-3));
135
206
  vertical-align: middle;
136
207
  }
137
208
 
138
209
  td[data-align="right"] {
139
210
  text-align: right;
140
- font-variant-numeric: tabular-nums;
211
+ font-variant-numeric: var(--crtr-table-cell-numeric-font-variant, tabular-nums);
141
212
  }
142
213
 
143
214
  td[data-mono="true"] {
144
- font-family: var(--crtr-font-mono);
145
- font-size: 0.8125em;
215
+ font-family: var(--crtr-table-cell-mono-font-family, var(--crtr-font-mono));
216
+ font-size: var(--crtr-table-cell-mono-font-size, 0.8125em);
146
217
  }
147
218
 
148
219
  [data-column-selected="true"] {
149
- background: color-mix(in srgb, var(--crtr-color-accent) 6%, transparent);
220
+ background: var(--crtr-table-column-selected-fill, color-mix(in srgb, var(--crtr-color-accent) 6%, transparent));
150
221
  }
151
222
 
152
223
  [part="select-cell"],
153
224
  [part="select-head"] {
154
225
  width: 1px;
155
- padding-right: 0;
226
+ padding-right: var(--crtr-table-select-cell-padding-right, 0);
156
227
  }
157
228
 
158
229
  [part="comment-cell"],
159
230
  [part="comment-head"] {
160
231
  width: 1px;
161
232
  text-align: right;
162
- padding-left: 0;
233
+ padding-left: var(--crtr-table-comment-cell-padding-left, 0);
163
234
  }
164
235
 
165
- [part="row-control"],
166
- [part="select-all"] {
167
- accent-color: var(--crtr-color-accent);
168
- margin: 0;
169
- cursor: pointer;
236
+ /* One scope over both selectors: the header's select-all IS the row control, drawn once in
237
+ the head, and they are meant to be indistinguishable. */
238
+ ${controlStyle({ scope: 'table-selection' }, '[part="row-control"]', '[part="select-all"]')}
239
+
240
+ /* A page that can no longer take an answer keeps its selection legible and drops every
241
+ affordance that says it could still be changed. */
242
+ [part="column-toggle"]:disabled {
243
+ cursor: default;
244
+ }
245
+
246
+ [part="column-toggle"]:disabled:hover {
247
+ color: var(--crtr-table-column-toggle-disabled-text-color, var(--crtr-color-text-muted));
248
+ background: var(--crtr-table-column-toggle-disabled-fill, none);
170
249
  }
171
250
 
251
+ /* One size knob for a square button, and one for the glyph in it — width and height are one
252
+ visual decision each. The opacity is the reveal-on-hover mechanic, which is behaviour. */
172
253
  [part="comment-trigger"] {
173
254
  display: inline-flex;
174
255
  align-items: center;
175
256
  justify-content: center;
176
- width: 1.5rem;
177
- height: 1.5rem;
178
- padding: 0;
179
- color: var(--crtr-color-text-muted);
180
- background: transparent;
181
- border: none;
182
- border-radius: var(--crtr-radius-small);
257
+ width: var(--crtr-table-comment-trigger-size, 1.5em);
258
+ height: var(--crtr-table-comment-trigger-size, 1.5em);
259
+ padding: var(--crtr-table-comment-trigger-padding, 0);
260
+ color: var(--crtr-table-comment-trigger-text-color, var(--crtr-color-text-muted));
261
+ background: var(--crtr-table-comment-trigger-fill, transparent);
262
+ border: var(--crtr-table-comment-trigger-border-width, 0)
263
+ var(--crtr-table-comment-trigger-border-style, none)
264
+ var(--crtr-table-comment-trigger-border-color, currentColor);
265
+ border-radius: var(--crtr-table-comment-trigger-radius, var(--crtr-radius-small));
183
266
  cursor: pointer;
184
267
  opacity: 0;
185
- transition: opacity 120ms ease;
268
+ transition: opacity ${TRIGGER_MOTION}, color ${TRIGGER_MOTION}, background-color ${TRIGGER_MOTION};
186
269
  }
187
270
 
188
271
  [part="comment-trigger"] svg {
189
- width: 0.875rem;
190
- height: 0.875rem;
272
+ width: var(--crtr-table-comment-trigger-icon-size, 0.875em);
273
+ height: var(--crtr-table-comment-trigger-icon-size, 0.875em);
274
+ }
275
+
276
+ /* The path geometry stays the icon's anatomy; its paint is component styling. Attribute
277
+ selectors preserve the filled-comment state that syncState writes while allowing both
278
+ appearances to inherit document-root overrides through this shadow root. */
279
+ [part="comment-trigger"] path {
280
+ stroke: var(--crtr-table-comment-trigger-icon-stroke, currentColor);
281
+ stroke-width: var(--crtr-table-comment-trigger-icon-stroke-width, 1.25);
282
+ stroke-linejoin: var(--crtr-table-comment-trigger-icon-stroke-linejoin, round);
283
+ }
284
+
285
+ [part="comment-trigger"] path[fill="none"] {
286
+ fill: var(--crtr-table-comment-trigger-icon-fill, none);
287
+ }
288
+
289
+ [part="comment-trigger"] path[fill="currentColor"] {
290
+ fill: var(--crtr-table-comment-trigger-commented-icon-fill, currentColor);
191
291
  }
192
292
 
193
293
  [part="comment-trigger"][data-has-comment="true"] {
194
- color: var(--crtr-color-accent);
294
+ color: var(--crtr-table-comment-trigger-commented-text-color, var(--crtr-color-accent));
195
295
  opacity: 1;
196
296
  }
197
297
 
198
298
  [part="comment-trigger"]:hover {
199
- background: var(--crtr-color-surface-hover);
299
+ background: var(--crtr-table-comment-trigger-hover-fill, var(--crtr-color-surface-hover));
200
300
  }
201
301
 
202
302
  tr:hover [part="comment-trigger"],
@@ -207,108 +307,122 @@ const STYLE = `
207
307
  opacity: 1;
208
308
  }
209
309
 
310
+ /* A floating panel over the page, not a box in the table's layout: the background token
311
+ rather than the surface tint, so it reads as lifted off whatever it covers. */
210
312
  [part="comment-editor"] {
211
313
  position: absolute;
212
- z-index: 2;
213
- width: min(20rem, calc(100% - var(--crtr-space-4)));
314
+ z-index: 5;
315
+ box-sizing: border-box;
316
+ width: var(--crtr-table-comment-editor-width, min(20em, 100%));
214
317
  display: grid;
215
- gap: var(--crtr-space-2);
216
- padding: var(--crtr-space-3);
217
- background: var(--crtr-color-surface);
218
- border: 1px solid var(--crtr-color-border);
219
- border-radius: var(--crtr-radius-medium);
220
- box-shadow: var(--crtr-shadow);
318
+ gap: var(--crtr-table-comment-editor-gap, var(--crtr-space-2));
319
+ padding: var(--crtr-table-comment-editor-padding, var(--crtr-space-3));
320
+ background: var(--crtr-table-comment-editor-fill, var(--crtr-color-background));
321
+ border: var(--crtr-table-comment-editor-border-width, 1px)
322
+ var(--crtr-table-comment-editor-border-style, solid)
323
+ var(--crtr-table-comment-editor-border-color, var(--crtr-color-border));
324
+ border-radius: var(--crtr-table-comment-editor-radius, var(--crtr-radius-medium));
325
+ box-shadow: var(--crtr-table-comment-editor-shadow, var(--crtr-shadow));
221
326
  }
222
327
 
223
328
  [part="comment-editor"][hidden] {
224
329
  display: none;
225
330
  }
226
331
 
332
+ /* The editor's own prose, in the page's voice. It was set in caps mono, which shouted a
333
+ row id at someone who had just clicked that row. */
227
334
  [part="comment-target"] {
228
- margin: 0;
229
- font-family: var(--crtr-font-mono);
230
- font-size: 0.75em;
231
- text-transform: uppercase;
232
- letter-spacing: 0.06em;
233
- color: var(--crtr-color-text-muted);
335
+ margin: var(--crtr-table-comment-target-margin, 0);
336
+ font-size: var(--crtr-table-comment-target-font-size, 0.875em);
337
+ color: var(--crtr-table-comment-target-text-color, var(--crtr-color-text-muted));
234
338
  }
235
339
 
340
+ ${writingBoxStyle({ scope: 'table-comment-input' }, '[part="comment-input"]')}
236
341
  [part="comment-input"] {
237
- font: inherit;
238
- color: var(--crtr-color-text);
239
- background: var(--crtr-color-background);
240
- border: 1px solid var(--crtr-color-border);
241
- border-radius: var(--crtr-radius-small);
242
- padding: var(--crtr-space-2);
243
- min-height: 4rem;
342
+ min-height: var(--crtr-table-comment-input-min-height, 4.5em);
244
343
  resize: vertical;
245
344
  }
246
345
 
247
- [part="comment-input"]:focus-visible {
248
- outline: 2px solid var(--crtr-color-focus);
249
- outline-offset: -1px;
250
- }
251
-
252
346
  [part="comment-actions"] {
253
347
  display: flex;
254
348
  align-items: center;
255
349
  justify-content: space-between;
256
- gap: var(--crtr-space-2);
350
+ gap: var(--crtr-table-comment-actions-gap, var(--crtr-space-2));
257
351
  }
258
352
 
259
353
  [part="comment-hint"] {
260
- font-family: var(--crtr-font-mono);
261
- font-size: 0.75em;
262
- text-transform: uppercase;
263
- letter-spacing: 0.06em;
264
- color: var(--crtr-color-text-muted);
354
+ font-size: var(--crtr-table-comment-hint-font-size, 0.875em);
355
+ color: var(--crtr-table-comment-hint-text-color, var(--crtr-color-text-muted));
265
356
  }
266
357
 
358
+ /* The chrome both editor buttons share reads one scope — a person sees one pair of buttons —
359
+ and each button's own paint carries its own. */
267
360
  [part="comment-cancel"],
268
361
  [part="comment-save"] {
269
- font: inherit;
270
- font-size: 0.875em;
271
- padding: var(--crtr-space-1) var(--crtr-space-2);
272
- border-radius: var(--crtr-radius-small);
273
- border: 1px solid var(--crtr-color-border);
362
+ font: var(--crtr-table-comment-button-font, inherit);
363
+ font-size: var(--crtr-table-comment-button-font-size, 0.875em);
364
+ padding: var(--crtr-table-comment-button-padding, var(--crtr-space-1) var(--crtr-space-2));
365
+ border-radius: var(--crtr-table-comment-button-radius, var(--crtr-radius-small));
366
+ border: var(--crtr-table-comment-button-border-width, 1px)
367
+ var(--crtr-table-comment-button-border-style, solid)
368
+ var(--crtr-table-comment-button-border-color, var(--crtr-color-border));
274
369
  cursor: pointer;
370
+ transition: color ${COMMENT_BUTTON_MOTION}, background-color ${COMMENT_BUTTON_MOTION},
371
+ border-color ${COMMENT_BUTTON_MOTION};
275
372
  }
276
373
 
277
374
  [part="comment-cancel"] {
278
- color: var(--crtr-color-text-muted);
279
- background: transparent;
375
+ color: var(--crtr-table-comment-cancel-text-color, var(--crtr-color-text-muted));
376
+ background: var(--crtr-table-comment-cancel-fill, transparent);
377
+ border-color: var(--crtr-table-comment-cancel-border-color,
378
+ var(--crtr-table-comment-button-border-color, transparent));
379
+ }
380
+
381
+ [part="comment-cancel"]:hover {
382
+ color: var(--crtr-table-comment-cancel-hover-text-color, var(--crtr-color-text));
383
+ background: var(--crtr-table-comment-cancel-hover-fill, var(--crtr-color-surface-hover));
280
384
  }
281
385
 
282
386
  [part="comment-save"] {
283
- color: var(--crtr-color-accent-contrast);
284
- background: var(--crtr-color-accent);
285
- border-color: var(--crtr-color-accent);
387
+ color: var(--crtr-table-comment-save-text-color, var(--crtr-color-accent-contrast));
388
+ background: var(--crtr-table-comment-save-fill, var(--crtr-color-accent));
389
+ border-color: var(--crtr-table-comment-save-border-color,
390
+ var(--crtr-table-comment-button-border-color, var(--crtr-color-accent)));
286
391
  }
287
392
 
288
- [part="comment-cancel"]:focus-visible,
289
- [part="comment-save"]:focus-visible {
290
- outline: 2px solid var(--crtr-color-focus);
291
- outline-offset: 1px;
393
+ /* Mixing toward the text colour darkens on a light token set and lightens on a dark one —
394
+ a brightness filter moves the wrong way on one of them. */
395
+ [part="comment-save"]:hover {
396
+ background: var(--crtr-table-comment-save-hover-fill,
397
+ color-mix(in srgb, var(--crtr-color-accent) 88%, var(--crtr-color-text)));
398
+ border-color: var(--crtr-table-comment-save-hover-border-color,
399
+ color-mix(in srgb, var(--crtr-color-accent) 88%, var(--crtr-color-text)));
292
400
  }
293
401
 
402
+ ${focusRingStyle({ scope: 'table-comment-cancel' }, '[part="comment-cancel"]')}
403
+ ${focusRingStyle({ scope: 'table-comment-save' }, '[part="comment-save"]')}
404
+
405
+ /* One scope over both: a loading line and an unavailable notice are the same box, saying
406
+ the table has nothing to show yet or ever. */
294
407
  [part="status"],
295
408
  [part="unavailable"] {
296
- padding: var(--crtr-space-3);
297
- color: var(--crtr-color-text-muted);
298
- background: var(--crtr-color-surface);
299
- border: 1px solid var(--crtr-color-border);
300
- border-radius: var(--crtr-radius-medium);
301
- box-shadow: var(--crtr-shadow);
409
+ padding: var(--crtr-table-notice-padding, var(--crtr-space-3));
410
+ color: var(--crtr-table-notice-text-color, var(--crtr-color-text-muted));
411
+ background: var(--crtr-table-notice-fill, var(--crtr-color-surface));
412
+ border: var(--crtr-table-notice-border-width, 1px)
413
+ var(--crtr-table-notice-border-style, solid)
414
+ var(--crtr-table-notice-border-color, var(--crtr-color-border));
415
+ border-radius: var(--crtr-table-notice-radius, var(--crtr-radius-medium));
302
416
  }
303
417
 
304
418
  [part="reason"] {
305
- color: var(--crtr-color-text);
306
- font-family: var(--crtr-font-mono);
419
+ color: var(--crtr-table-reason-text-color, var(--crtr-color-text));
420
+ font-family: var(--crtr-table-reason-font-family, var(--crtr-font-mono));
307
421
  }
308
422
 
309
423
  [part="empty"] {
310
- padding: var(--crtr-space-3);
311
- color: var(--crtr-color-text-muted);
424
+ padding: var(--crtr-table-empty-padding, var(--crtr-space-3));
425
+ color: var(--crtr-table-empty-text-color, var(--crtr-color-text-muted));
312
426
  text-align: center;
313
427
  }
314
428
  `;
@@ -321,34 +435,6 @@ function cellText(value) {
321
435
  function isPlainObject(value) {
322
436
  return typeof value === 'object' && value !== null && !Array.isArray(value);
323
437
  }
324
- /**
325
- * The rows inside whatever the host handed back for a `source`. Accepted shapes are the same
326
- * `{ id, cells }` rows an inline config carries, either as a bare array or under a `rows` key;
327
- * anything else is a named unavailable state rather than a guess at the author's intent.
328
- */
329
- function readSourceRows(data) {
330
- const raw = Array.isArray(data) ? data : isPlainObject(data) && Array.isArray(data.rows) ? data.rows : undefined;
331
- if (raw === undefined)
332
- return { status: 'invalid', reason: 'source data is not a list of rows' };
333
- const rows = [];
334
- for (let index = 0; index < raw.length; index += 1) {
335
- const row = raw[index];
336
- if (!isPlainObject(row) || typeof row.id !== 'string' || row.id === '' || !isPlainObject(row.cells)) {
337
- return { status: 'invalid', reason: `source row #${index} is not { id, cells }` };
338
- }
339
- const cells = {};
340
- for (const [key, value] of Object.entries(row.cells)) {
341
- if (value === undefined)
342
- continue;
343
- if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
344
- return { status: 'invalid', reason: `source row "${row.id}" cell "${key}" is not a string, number, boolean or null` };
345
- }
346
- cells[key] = value;
347
- }
348
- rows.push({ id: row.id, cells });
349
- }
350
- return { status: 'ok', rows };
351
- }
352
438
  function commentIcon() {
353
439
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
354
440
  svg.setAttribute('viewBox', '0 0 16 16');
@@ -369,6 +455,8 @@ function isTextEntry(target) {
369
455
  return target.closest('input[type="text"], textarea, [contenteditable="true"]') !== null;
370
456
  }
371
457
  class CrtrTableElement extends HTMLElement {
458
+ /** The host marks a resolved or canceled page `inert`; that is this element's read-only signal. */
459
+ static observedAttributes = ['inert'];
372
460
  #root;
373
461
  #slotId;
374
462
  #config;
@@ -379,8 +467,9 @@ class CrtrTableElement extends HTMLElement {
379
467
  #activeRowId = null;
380
468
  #editorAnchor = null;
381
469
  #editorTrigger;
382
- #connected = false;
383
470
  #container;
471
+ /** The editor's positioning context — the unclipped wrapper around the container. */
472
+ #frame;
384
473
  #editor;
385
474
  #editorTarget;
386
475
  #editorInput;
@@ -394,7 +483,6 @@ class CrtrTableElement extends HTMLElement {
394
483
  this.#root = this.attachShadow({ mode: 'open' });
395
484
  }
396
485
  connectedCallback() {
397
- this.#connected = true;
398
486
  const read = readSlotConfig(this);
399
487
  if (read.status !== 'ok') {
400
488
  this.#renderUnavailable(read.reason);
@@ -405,49 +493,43 @@ class CrtrTableElement extends HTMLElement {
405
493
  this.#renderUnavailable('table config has no columns');
406
494
  return;
407
495
  }
408
- if (config.rows !== undefined && config.source !== undefined) {
409
- this.#renderUnavailable('table rows and source are mutually exclusive');
410
- return;
411
- }
412
496
  this.#config = config;
413
497
  this.#slotId = readSlotId(this);
414
- if (config.source !== undefined) {
415
- this.#renderStatus('Loading table data…');
416
- void this.#loadSource(config.source);
417
- return;
498
+ // Only a selectable table bears a response — the host rejects a map naming a display-only
499
+ // slot — and the modes are known here before rendering.
500
+ if (this.#slotId !== undefined && (this.#rowSelect !== 'none' || this.#columnSelect !== 'none')) {
501
+ registerSlotResponse(this, this.#slotId, () => this.#response());
418
502
  }
419
- if (!Array.isArray(config.rows)) {
420
- this.#renderUnavailable('table config has neither rows nor source');
421
- return;
422
- }
423
- this.#rows = config.rows;
503
+ this.#rows = config.rows ?? [];
424
504
  this.#hydrate();
425
505
  this.#renderTable();
426
506
  }
427
- disconnectedCallback() {
428
- this.#connected = false;
507
+ attributeChangedCallback() {
508
+ if (this.isConnected)
509
+ this.#syncReadOnly();
429
510
  }
430
- async #loadSource(source) {
431
- const result = await artifactData(source);
432
- if (!this.#connected)
433
- return;
434
- if (result.status !== 'ok') {
435
- this.#renderUnavailable(`table source "${source}" is ${result.status}: ${result.reason}`);
436
- return;
437
- }
438
- const rows = readSourceRows(result.data);
439
- if (rows.status !== 'ok') {
440
- this.#renderUnavailable(`table source "${source}": ${rows.reason}`);
441
- return;
511
+ /**
512
+ * Repainted after every state sync, because a rebuild replaces the controls. Column
513
+ * toggles are buttons rather than inputs, so they are named here; the mutation paths
514
+ * refuse anyway, and this is what makes the refusal visible.
515
+ */
516
+ #syncReadOnly() {
517
+ const readOnly = isReadOnly(this);
518
+ applyReadOnly(this.#root, readOnly);
519
+ for (const parts of this.#columnParts.values()) {
520
+ if (parts.toggle !== undefined)
521
+ parts.toggle.disabled = readOnly;
442
522
  }
443
- this.#rows = rows.rows;
444
- this.#hydrate();
445
- this.#renderTable();
523
+ // A page can go read-only while the composer is open — the host sets `inert` on a ticket
524
+ // that resolves under it. A box that can no longer save is closed rather than left there
525
+ // taking words nothing will keep.
526
+ if (readOnly && this.#editorAnchor !== null)
527
+ this.#closeEditor();
446
528
  }
447
529
  /**
448
530
  * Seed local state from the answer the host is already holding, so a reopened page shows
449
- * the rows and columns that were picked. It runs once the rows are known — after a `source`
450
- * load, not before — because a saved id is only honoured while it still names something on
531
+ * the rows and columns that were picked. It runs once rows are known because a saved id is
532
+ * only honoured while it still names something on
451
533
  * screen. The select modes are re-imposed for the same reason.
452
534
  */
453
535
  #hydrate() {
@@ -500,7 +582,7 @@ class CrtrTableElement extends HTMLElement {
500
582
  }
501
583
  #setRowSelected(rowId, selected) {
502
584
  const mode = this.#rowSelect;
503
- if (mode === 'none')
585
+ if (mode === 'none' || isReadOnly(this))
504
586
  return;
505
587
  if (mode === 'single') {
506
588
  this.#selectedRowIds = selected ? [rowId] : [];
@@ -519,7 +601,7 @@ class CrtrTableElement extends HTMLElement {
519
601
  }
520
602
  #setColumnSelected(columnId, selected) {
521
603
  const mode = this.#columnSelect;
522
- if (mode === 'none')
604
+ if (mode === 'none' || isReadOnly(this))
523
605
  return;
524
606
  if (mode === 'single') {
525
607
  this.#selectedColumnIds = selected ? [columnId] : [];
@@ -533,7 +615,7 @@ class CrtrTableElement extends HTMLElement {
533
615
  this.#setColumnSelected(columnId, !this.#selectedColumnIds.includes(columnId));
534
616
  }
535
617
  #setAllRows(selected) {
536
- if (this.#rowSelect !== 'multi')
618
+ if (this.#rowSelect !== 'multi' || isReadOnly(this))
537
619
  return;
538
620
  this.#selectedRowIds = selected ? this.#rows.map((row) => row.id) : [];
539
621
  this.#commit();
@@ -544,6 +626,7 @@ class CrtrTableElement extends HTMLElement {
544
626
  this.#rowParts.clear();
545
627
  this.#columnParts.clear();
546
628
  this.#container = undefined;
629
+ this.#frame = undefined;
547
630
  this.#editor = undefined;
548
631
  this.#editorTrigger = undefined;
549
632
  this.#editorAnchor = null;
@@ -564,14 +647,6 @@ class CrtrTableElement extends HTMLElement {
564
647
  box.append('Table unavailable: ', detail);
565
648
  this.#root.append(box);
566
649
  }
567
- #renderStatus(text) {
568
- this.#reset();
569
- const box = document.createElement('div');
570
- box.part.add('status');
571
- box.setAttribute('role', 'status');
572
- box.textContent = text;
573
- this.#root.append(box);
574
- }
575
650
  #renderTable() {
576
651
  const config = this.#config;
577
652
  if (config === undefined)
@@ -590,10 +665,10 @@ class CrtrTableElement extends HTMLElement {
590
665
  this.#container = container;
591
666
  const table = document.createElement('table');
592
667
  table.part.add('table');
593
- if (config.caption !== undefined && config.caption !== '') {
668
+ if (config.label !== undefined && config.label !== '') {
594
669
  const caption = document.createElement('caption');
595
670
  caption.part.add('caption');
596
- caption.textContent = config.caption;
671
+ caption.textContent = config.label;
597
672
  table.append(caption);
598
673
  }
599
674
  table.append(this.#buildHead(config), this.#buildBody(config));
@@ -604,8 +679,13 @@ class CrtrTableElement extends HTMLElement {
604
679
  empty.textContent = 'No rows.';
605
680
  container.append(empty);
606
681
  }
607
- container.append(this.#buildEditor());
608
- this.#root.append(container);
682
+ const frame = document.createElement('div');
683
+ frame.part.add('frame');
684
+ frame.append(container);
685
+ if (this.#commentable)
686
+ frame.append(this.#buildEditor());
687
+ this.#frame = frame;
688
+ this.#root.append(frame);
609
689
  this.#syncState();
610
690
  }
611
691
  #buildHead(config) {
@@ -653,18 +733,25 @@ class CrtrTableElement extends HTMLElement {
653
733
  toggle.addEventListener('click', () => this.#toggleColumn(column.id));
654
734
  inner.append(toggle);
655
735
  }
656
- const comment = this.#buildCommentTrigger({ kind: 'column', columnId: column.id }, `the ${column.label} column`);
657
- inner.append(comment);
736
+ const comment = this.#commentable
737
+ ? this.#buildCommentTrigger({ kind: 'column', columnId: column.id }, `the ${column.label} column`)
738
+ : undefined;
739
+ if (comment !== undefined)
740
+ inner.append(comment);
658
741
  th.append(inner);
659
742
  tr.append(th);
660
- this.#columnParts.set(column.id, { toggle, comment, cells: [], head: th });
743
+ this.#columnParts.set(column.id, { toggle, ...(comment === undefined ? {} : { comment }), cells: [], head: th });
744
+ }
745
+ // The trailing column exists to hold the whole-table trigger; without comments there is
746
+ // nothing to put in it, and an empty column would just be a stripe of dead width.
747
+ if (this.#commentable) {
748
+ const trailing = document.createElement('th');
749
+ trailing.part.add('comment-head');
750
+ trailing.setAttribute('scope', 'col');
751
+ this.#wholeComment = this.#buildCommentTrigger({ kind: 'whole' }, 'this table');
752
+ trailing.append(this.#wholeComment);
753
+ tr.append(trailing);
661
754
  }
662
- const trailing = document.createElement('th');
663
- trailing.part.add('comment-head');
664
- trailing.setAttribute('scope', 'col');
665
- this.#wholeComment = this.#buildCommentTrigger({ kind: 'whole' }, 'this table');
666
- trailing.append(this.#wholeComment);
667
- tr.append(trailing);
668
755
  head.append(tr);
669
756
  return head;
670
757
  }
@@ -705,16 +792,23 @@ class CrtrTableElement extends HTMLElement {
705
792
  tr.append(td);
706
793
  this.#columnParts.get(column.id)?.cells.push(td);
707
794
  }
708
- const commentCell = document.createElement('td');
709
- commentCell.part.add('comment-cell');
710
- const comment = this.#buildCommentTrigger({ kind: 'row', rowId: row.id }, `row ${row.id}`);
711
- commentCell.append(comment);
712
- tr.append(commentCell);
795
+ let comment;
796
+ if (this.#commentable) {
797
+ const commentCell = document.createElement('td');
798
+ commentCell.part.add('comment-cell');
799
+ comment = this.#buildCommentTrigger({ kind: 'row', rowId: row.id }, `row ${row.id}`);
800
+ commentCell.append(comment);
801
+ tr.append(commentCell);
802
+ }
713
803
  body.append(tr);
714
- this.#rowParts.set(row.id, { tr, ...(control === undefined ? {} : { control }), comment });
804
+ this.#rowParts.set(row.id, { tr, ...(control === undefined ? {} : { control }), ...(comment === undefined ? {} : { comment }) });
715
805
  }
716
806
  return body;
717
807
  }
808
+ /** Whether this table can keep a comment — see `canRecordComments`. */
809
+ get #commentable() {
810
+ return canRecordComments(this.#slotId);
811
+ }
718
812
  #buildCommentTrigger(anchor, target) {
719
813
  const button = document.createElement('button');
720
814
  button.type = 'button';
@@ -776,8 +870,10 @@ class CrtrTableElement extends HTMLElement {
776
870
  // ── comments ───────────────────────────────────────────────────────────────────────────
777
871
  #openEditor(anchor, trigger) {
778
872
  const editor = this.#editor;
779
- const container = this.#container;
780
- if (editor === undefined || container === undefined)
873
+ const frame = this.#frame;
874
+ // A composer opens only where its comment can land: never on a read-only page, whose
875
+ // textareas are already `readOnly`, so the box would take focus and refuse every keystroke.
876
+ if (editor === undefined || frame === undefined || !this.#commentable || isReadOnly(this))
781
877
  return;
782
878
  this.#editorAnchor = anchor;
783
879
  this.#editorTrigger = trigger;
@@ -791,11 +887,20 @@ class CrtrTableElement extends HTMLElement {
791
887
  }
792
888
  if (this.#editorHint !== undefined)
793
889
  this.#editorHint.textContent = existing === undefined ? '⌘↵ to save' : 'empty to delete';
794
- const bounds = container.getBoundingClientRect();
890
+ const bounds = frame.getBoundingClientRect();
795
891
  const rect = trigger.getBoundingClientRect();
796
892
  editor.hidden = false;
797
- editor.style.top = `${Math.max(0, rect.bottom - bounds.top + 4)}px`;
798
893
  editor.style.right = `${Math.max(0, bounds.right - rect.right)}px`;
894
+ // Below the trigger by default; above it when the viewport has no room below and does
895
+ // have room above. Measured after unhiding, because a hidden panel has no height.
896
+ editor.style.top = '0px';
897
+ const height = editor.getBoundingClientRect().height;
898
+ const below = rect.bottom + EDITOR_GAP + height <= window.innerHeight;
899
+ const above = rect.top - EDITOR_GAP - height >= 0;
900
+ editor.style.top =
901
+ !below && above
902
+ ? `${rect.top - EDITOR_GAP - height - bounds.top}px`
903
+ : `${rect.bottom + EDITOR_GAP - bounds.top}px`;
799
904
  this.#syncState();
800
905
  this.#editorInput?.focus();
801
906
  }
@@ -815,7 +920,7 @@ class CrtrTableElement extends HTMLElement {
815
920
  }
816
921
  #saveComment() {
817
922
  const anchor = this.#editorAnchor;
818
- if (anchor === null)
923
+ if (anchor === null || isReadOnly(this))
819
924
  return;
820
925
  this.#comments = setComment(this.#comments, anchor, this.#editorInput?.value ?? '');
821
926
  this.#closeEditor();
@@ -854,9 +959,9 @@ class CrtrTableElement extends HTMLElement {
854
959
  if (key === 'x')
855
960
  this.#toggleColumn(columnId);
856
961
  else {
857
- const parts = this.#columnParts.get(columnId);
858
- if (parts !== undefined)
859
- this.#openEditor({ kind: 'column', columnId }, parts.comment);
962
+ const comment = this.#columnParts.get(columnId)?.comment;
963
+ if (comment !== undefined)
964
+ this.#openEditor({ kind: 'column', columnId }, comment);
860
965
  }
861
966
  return;
862
967
  }
@@ -877,9 +982,9 @@ class CrtrTableElement extends HTMLElement {
877
982
  this.#toggleRow(currentId);
878
983
  return;
879
984
  }
880
- const parts = this.#rowParts.get(currentId);
881
- if (parts !== undefined)
882
- this.#openEditor({ kind: 'row', rowId: currentId }, parts.comment);
985
+ const comment = this.#rowParts.get(currentId)?.comment;
986
+ if (comment !== undefined)
987
+ this.#openEditor({ kind: 'row', rowId: currentId }, comment);
883
988
  }
884
989
  // ── state → DOM ────────────────────────────────────────────────────────────────────────
885
990
  #syncCommentTrigger(button, anchor) {
@@ -913,7 +1018,8 @@ class CrtrTableElement extends HTMLElement {
913
1018
  parts.tr.setAttribute('aria-selected', selected ? 'true' : 'false');
914
1019
  if (parts.control !== undefined)
915
1020
  parts.control.checked = selected;
916
- this.#syncCommentTrigger(parts.comment, { kind: 'row', rowId });
1021
+ if (parts.comment !== undefined)
1022
+ this.#syncCommentTrigger(parts.comment, { kind: 'row', rowId });
917
1023
  }
918
1024
  for (const [columnId, parts] of this.#columnParts) {
919
1025
  const selected = this.#selectedColumnIds.includes(columnId);
@@ -921,7 +1027,8 @@ class CrtrTableElement extends HTMLElement {
921
1027
  parts.head.dataset.columnSelected = selected ? 'true' : 'false';
922
1028
  for (const cell of parts.cells)
923
1029
  cell.dataset.columnSelected = selected ? 'true' : 'false';
924
- this.#syncCommentTrigger(parts.comment, { kind: 'column', columnId });
1030
+ if (parts.comment !== undefined)
1031
+ this.#syncCommentTrigger(parts.comment, { kind: 'column', columnId });
925
1032
  }
926
1033
  if (this.#wholeComment !== undefined)
927
1034
  this.#syncCommentTrigger(this.#wholeComment, { kind: 'whole' });
@@ -930,6 +1037,7 @@ class CrtrTableElement extends HTMLElement {
930
1037
  this.#selectAll.checked = all;
931
1038
  this.#selectAll.indeterminate = this.#selectedRowIds.length > 0 && !all;
932
1039
  }
1040
+ this.#syncReadOnly();
933
1041
  }
934
1042
  }
935
1043
  registerElement('crtr-table', CrtrTableElement);