@north-light/crouter 0.3.191 → 0.3.193
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.
- package/dist/api/client.d.ts +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +22 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +15 -28
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +32 -0
- package/dist/core/bash-jobs.js +72 -1
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +89 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- package/dist/pages/bundle.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +13 -5
- package/dist/pi-extensions/canvas-structured-output.js +7 -3
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- package/dist/core/human/deck-schema.js +0 -92
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<crtr-cards>` — the choice `options` makes, when each candidate has enough body to
|
|
3
|
+
* deserve its own surface: a meeting slot, a vendor, a search result.
|
|
4
|
+
*
|
|
5
|
+
* This is a port of the agent interaction kit's `cards` component, not a new design. The
|
|
6
|
+
* selection treatment is the kit's selectable-surface recipe — a hairline surface that
|
|
7
|
+
* answers the pointer and goes accent-toned once chosen — carrying the kit's selection
|
|
8
|
+
* control and the kit's comment affordance. Navigation is the kit's classic list grammar
|
|
9
|
+
* (`j`/`k` move, `x` selects, `c` comments), so a card grid, an option list and a table
|
|
10
|
+
* are all driven the same way.
|
|
11
|
+
*
|
|
12
|
+
* Data arrives one of two ways: inline `cards`, or a `source` the host resolves through
|
|
13
|
+
* `artifactData()`. The element never touches the network, and anything it cannot render —
|
|
14
|
+
* a malformed config, an unreachable source, source data that is not a card list — becomes
|
|
15
|
+
* a named unavailable state rather than an exception.
|
|
16
|
+
*/
|
|
17
|
+
import { findComment, setComment } from '../comments.js';
|
|
18
|
+
import { artifactData, respond } from '../host.js';
|
|
19
|
+
import { registerElement } from '../register.js';
|
|
20
|
+
import { readSlotConfig, readSlotId } from '../slot-config.js';
|
|
21
|
+
const STYLE = `
|
|
22
|
+
:host {
|
|
23
|
+
display: block;
|
|
24
|
+
/* Column count answers the element's own width, not the viewport: a slot can sit in
|
|
25
|
+
a narrow rail on a wide screen. */
|
|
26
|
+
container-type: inline-size;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
[part~="container"] {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
gap: var(--crtr-space-1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[part~="grid"] {
|
|
36
|
+
display: grid;
|
|
37
|
+
grid-template-columns: minmax(0, 1fr);
|
|
38
|
+
gap: var(--crtr-space-3);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@container (min-width: 30rem) {
|
|
42
|
+
[part~="grid"] {
|
|
43
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
[part~="grid"][data-columns="3"] {
|
|
47
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
[part~="card"] {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
gap: var(--crtr-space-2);
|
|
55
|
+
padding: var(--crtr-space-3);
|
|
56
|
+
color: var(--crtr-color-text);
|
|
57
|
+
background: var(--crtr-color-surface);
|
|
58
|
+
border: 1px solid color-mix(in srgb, var(--crtr-color-border) 60%, transparent);
|
|
59
|
+
border-radius: var(--crtr-radius-medium);
|
|
60
|
+
transition: background-color 120ms ease, border-color 120ms ease;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
[part~="card"]:hover,
|
|
64
|
+
[part~="card"][part~="active"] {
|
|
65
|
+
background: var(--crtr-color-surface-hover);
|
|
66
|
+
border-color: var(--crtr-color-border);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
[part~="card"][part~="selected"] {
|
|
70
|
+
background: color-mix(in srgb, var(--crtr-color-accent) 7%, transparent);
|
|
71
|
+
border-color: color-mix(in srgb, var(--crtr-color-accent) 40%, transparent);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
[part~="card"][part~="selected"]:hover,
|
|
75
|
+
[part~="card"][part~="selected"][part~="active"] {
|
|
76
|
+
background: color-mix(in srgb, var(--crtr-color-accent) 12%, transparent);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
[part~="card-header"] {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: flex-start;
|
|
82
|
+
gap: var(--crtr-space-2);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* The label carries the click target, so the whole heading toggles the native control
|
|
86
|
+
the way the kit's Label does — and the comment button stays its own control. */
|
|
87
|
+
[part~="card-label"] {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex: 1;
|
|
90
|
+
align-items: flex-start;
|
|
91
|
+
gap: var(--crtr-space-2);
|
|
92
|
+
min-width: 0;
|
|
93
|
+
font: inherit;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
[part~="control"] {
|
|
98
|
+
flex: none;
|
|
99
|
+
margin: 0;
|
|
100
|
+
margin-top: 0.2em;
|
|
101
|
+
accent-color: var(--crtr-color-accent);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
[part~="control"]:focus-visible {
|
|
105
|
+
outline: 2px solid var(--crtr-color-focus);
|
|
106
|
+
outline-offset: 2px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
[part~="card-heading"] {
|
|
110
|
+
display: grid;
|
|
111
|
+
flex: 1;
|
|
112
|
+
gap: calc(var(--crtr-space-1) / 2);
|
|
113
|
+
min-width: 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
[part~="card-title"] {
|
|
117
|
+
font-weight: 500;
|
|
118
|
+
line-height: 1.35;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
[part~="card-subtitle"] {
|
|
122
|
+
color: var(--crtr-color-text-muted);
|
|
123
|
+
font-size: calc(var(--crtr-font-size) * 0.85);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
[part~="card-body"] {
|
|
127
|
+
margin: 0;
|
|
128
|
+
color: var(--crtr-color-text-muted);
|
|
129
|
+
font-size: calc(var(--crtr-font-size) * 0.85);
|
|
130
|
+
line-height: 1.6;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
[part~="tag"] {
|
|
134
|
+
align-self: flex-start;
|
|
135
|
+
padding: 0 var(--crtr-space-1);
|
|
136
|
+
color: var(--crtr-color-text-muted);
|
|
137
|
+
font-size: calc(var(--crtr-font-size) * 0.78);
|
|
138
|
+
line-height: 1.7;
|
|
139
|
+
border: 1px solid var(--crtr-color-border);
|
|
140
|
+
border-radius: var(--crtr-radius-small);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
[part~="card"][part~="selected"] [part~="tag"] {
|
|
144
|
+
color: var(--crtr-color-accent);
|
|
145
|
+
background: color-mix(in srgb, var(--crtr-color-accent) 10%, transparent);
|
|
146
|
+
border-color: color-mix(in srgb, var(--crtr-color-accent) 40%, transparent);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* The affordance is quiet until you reach for it, and stays visible once a note lives
|
|
150
|
+
there — that presence is the whole "there is a comment here" signal. */
|
|
151
|
+
[part~="comment-button"] {
|
|
152
|
+
flex: none;
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
justify-content: center;
|
|
156
|
+
width: calc(var(--crtr-font-size) * 1.6);
|
|
157
|
+
height: calc(var(--crtr-font-size) * 1.6);
|
|
158
|
+
padding: 0;
|
|
159
|
+
color: var(--crtr-color-text-muted);
|
|
160
|
+
background: none;
|
|
161
|
+
border: none;
|
|
162
|
+
border-radius: var(--crtr-radius-small);
|
|
163
|
+
cursor: pointer;
|
|
164
|
+
opacity: 0;
|
|
165
|
+
transition: opacity 120ms ease, color 120ms ease, background-color 120ms ease;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
[part~="comment-button"] svg {
|
|
169
|
+
width: calc(var(--crtr-font-size) * 0.95);
|
|
170
|
+
height: calc(var(--crtr-font-size) * 0.95);
|
|
171
|
+
/* The glyph is never the event target: a repaint that swaps it mid-press would
|
|
172
|
+
otherwise leave mousedown and mouseup on different nodes and eat the click. */
|
|
173
|
+
pointer-events: none;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
[part~="comment-button"]:hover {
|
|
177
|
+
color: var(--crtr-color-text);
|
|
178
|
+
background: var(--crtr-color-surface-hover);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
[part~="comment-button"][part~="has-comment"] {
|
|
182
|
+
color: var(--crtr-color-accent);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
[part~="card"]:hover [part~="comment-button"],
|
|
186
|
+
[part~="card"]:focus-within [part~="comment-button"],
|
|
187
|
+
[part~="comment-button"]:focus-visible,
|
|
188
|
+
[part~="comment-button"][part~="has-comment"] {
|
|
189
|
+
opacity: 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
[part~="comment-editor"] {
|
|
193
|
+
display: grid;
|
|
194
|
+
gap: var(--crtr-space-2);
|
|
195
|
+
width: 100%;
|
|
196
|
+
padding: var(--crtr-space-2);
|
|
197
|
+
text-align: left;
|
|
198
|
+
background: var(--crtr-color-background);
|
|
199
|
+
border: 1px solid var(--crtr-color-border);
|
|
200
|
+
border-radius: var(--crtr-radius-small);
|
|
201
|
+
cursor: auto;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
[part~="comment-subject"],
|
|
205
|
+
[part~="comment-hint"] {
|
|
206
|
+
color: var(--crtr-color-text-muted);
|
|
207
|
+
font-family: var(--crtr-font-mono);
|
|
208
|
+
font-size: calc(var(--crtr-font-size) * 0.72);
|
|
209
|
+
letter-spacing: 0.06em;
|
|
210
|
+
text-transform: uppercase;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
[part~="comment-input"] {
|
|
214
|
+
box-sizing: border-box;
|
|
215
|
+
width: 100%;
|
|
216
|
+
min-height: 4.5em;
|
|
217
|
+
padding: var(--crtr-space-1) var(--crtr-space-2);
|
|
218
|
+
color: var(--crtr-color-text);
|
|
219
|
+
font: inherit;
|
|
220
|
+
background: var(--crtr-color-background);
|
|
221
|
+
border: 1px solid var(--crtr-color-border);
|
|
222
|
+
border-radius: var(--crtr-radius-small);
|
|
223
|
+
resize: vertical;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
[part~="comment-input"]:focus-visible {
|
|
227
|
+
outline: 2px solid var(--crtr-color-focus);
|
|
228
|
+
outline-offset: 1px;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
[part~="comment-actions"] {
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
justify-content: space-between;
|
|
235
|
+
gap: var(--crtr-space-2);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
[part~="comment-buttons"] {
|
|
239
|
+
display: flex;
|
|
240
|
+
gap: var(--crtr-space-1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
[part~="comment-cancel"],
|
|
244
|
+
[part~="comment-save"] {
|
|
245
|
+
padding: var(--crtr-space-1) var(--crtr-space-2);
|
|
246
|
+
font: inherit;
|
|
247
|
+
font-size: calc(var(--crtr-font-size) * 0.9);
|
|
248
|
+
border-radius: var(--crtr-radius-small);
|
|
249
|
+
cursor: pointer;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
[part~="comment-cancel"] {
|
|
253
|
+
color: var(--crtr-color-text-muted);
|
|
254
|
+
background: none;
|
|
255
|
+
border: 1px solid transparent;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
[part~="comment-cancel"]:hover {
|
|
259
|
+
color: var(--crtr-color-text);
|
|
260
|
+
background: var(--crtr-color-surface-hover);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
[part~="comment-save"] {
|
|
264
|
+
color: var(--crtr-color-accent-contrast);
|
|
265
|
+
background: var(--crtr-color-accent);
|
|
266
|
+
border: 1px solid var(--crtr-color-accent);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
[part~="unavailable"],
|
|
270
|
+
[part~="empty"],
|
|
271
|
+
[part~="loading"] {
|
|
272
|
+
padding: var(--crtr-space-3);
|
|
273
|
+
color: var(--crtr-color-text-muted);
|
|
274
|
+
background: var(--crtr-color-surface);
|
|
275
|
+
border: 1px solid var(--crtr-color-border);
|
|
276
|
+
border-radius: var(--crtr-radius-medium);
|
|
277
|
+
box-shadow: var(--crtr-shadow);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
[part~="kind"] {
|
|
281
|
+
color: var(--crtr-color-text);
|
|
282
|
+
font-family: var(--crtr-font-mono);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
[part~="reason"] {
|
|
286
|
+
display: block;
|
|
287
|
+
margin-top: var(--crtr-space-1);
|
|
288
|
+
font-size: calc(var(--crtr-font-size) * 0.85);
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
/**
|
|
292
|
+
* A card is a plain `{ id, title }` record; everything else is optional prose. This mirrors
|
|
293
|
+
* `cardSchema`, which is strict — an unknown key means the data is not a card, so a typo
|
|
294
|
+
* surfaces as an unavailable state rather than a silently dropped field.
|
|
295
|
+
*/
|
|
296
|
+
const CARD_KEYS = new Set(['id', 'title', 'subtitle', 'body', 'tag']);
|
|
297
|
+
function normalizeCard(raw) {
|
|
298
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
299
|
+
return null;
|
|
300
|
+
const value = raw;
|
|
301
|
+
if (typeof value.id !== 'string' || value.id === '')
|
|
302
|
+
return null;
|
|
303
|
+
if (typeof value.title !== 'string' || value.title === '')
|
|
304
|
+
return null;
|
|
305
|
+
for (const key of ['subtitle', 'body', 'tag']) {
|
|
306
|
+
if (value[key] !== undefined && typeof value[key] !== 'string')
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
for (const key of Object.keys(value)) {
|
|
310
|
+
if (!CARD_KEYS.has(key))
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
id: value.id,
|
|
315
|
+
title: value.title,
|
|
316
|
+
subtitle: value.subtitle,
|
|
317
|
+
body: value.body,
|
|
318
|
+
tag: value.tag,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/** A card list, or `null` when the data is not one. Ids must be unique — selection keys off them. */
|
|
322
|
+
function normalizeCards(raw) {
|
|
323
|
+
if (!Array.isArray(raw))
|
|
324
|
+
return null;
|
|
325
|
+
const cards = [];
|
|
326
|
+
const seen = new Set();
|
|
327
|
+
for (const entry of raw) {
|
|
328
|
+
const card = normalizeCard(entry);
|
|
329
|
+
if (card === null || seen.has(card.id))
|
|
330
|
+
return null;
|
|
331
|
+
seen.add(card.id);
|
|
332
|
+
cards.push(card);
|
|
333
|
+
}
|
|
334
|
+
return cards;
|
|
335
|
+
}
|
|
336
|
+
const CONFIG_KEYS = new Set(['cards', 'source', 'mode', 'columns']);
|
|
337
|
+
/** Mirrors strict `cardsConfigSchema`, so this element accepts exactly what the agent may send. */
|
|
338
|
+
function normalizeConfig(raw) {
|
|
339
|
+
for (const key of Object.keys(raw)) {
|
|
340
|
+
if (!CONFIG_KEYS.has(key))
|
|
341
|
+
return { status: 'invalid', reason: `unknown config property "${key}"` };
|
|
342
|
+
}
|
|
343
|
+
const mode = raw.mode;
|
|
344
|
+
if (mode !== 'single' && mode !== 'multi')
|
|
345
|
+
return { status: 'invalid', reason: 'mode must be "single" or "multi"' };
|
|
346
|
+
// The source string is opaque to this element — the host is the only thing that reads it.
|
|
347
|
+
const source = raw.source;
|
|
348
|
+
if (source !== undefined && typeof source !== 'string') {
|
|
349
|
+
return { status: 'invalid', reason: 'source must be a string' };
|
|
350
|
+
}
|
|
351
|
+
const columns = raw.columns;
|
|
352
|
+
if (columns !== undefined && columns !== 2 && columns !== 3) {
|
|
353
|
+
return { status: 'invalid', reason: 'columns must be 2 or 3' };
|
|
354
|
+
}
|
|
355
|
+
let cards;
|
|
356
|
+
if (raw.cards !== undefined) {
|
|
357
|
+
if (source !== undefined)
|
|
358
|
+
return { status: 'invalid', reason: 'cards and source are mutually exclusive' };
|
|
359
|
+
const parsed = normalizeCards(raw.cards);
|
|
360
|
+
if (parsed === null)
|
|
361
|
+
return { status: 'invalid', reason: 'cards must be a list of uniquely identified cards' };
|
|
362
|
+
cards = parsed;
|
|
363
|
+
}
|
|
364
|
+
return { status: 'ok', config: { cards, source: source, mode, columns } };
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Typing in the comment box is ordinary writing — the list grammar keeps out of it. A card's
|
|
368
|
+
* own radio or checkbox is not prose, so `j`/`k`/`x`/`c` still reach the grid from there.
|
|
369
|
+
*/
|
|
370
|
+
function isTextEntry(target) {
|
|
371
|
+
if (target instanceof HTMLTextAreaElement)
|
|
372
|
+
return true;
|
|
373
|
+
if (target instanceof HTMLElement && target.isContentEditable)
|
|
374
|
+
return true;
|
|
375
|
+
if (!(target instanceof HTMLInputElement))
|
|
376
|
+
return false;
|
|
377
|
+
return target.type !== 'checkbox' && target.type !== 'radio';
|
|
378
|
+
}
|
|
379
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
380
|
+
/** The comment glyph: a speech square, gaining a line of text once a note lives there. */
|
|
381
|
+
function commentIcon(hasComment) {
|
|
382
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
383
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
384
|
+
svg.setAttribute('fill', 'none');
|
|
385
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
386
|
+
svg.setAttribute('stroke-width', '2');
|
|
387
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
388
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
389
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
390
|
+
const bubble = document.createElementNS(SVG_NS, 'path');
|
|
391
|
+
bubble.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
|
|
392
|
+
svg.append(bubble);
|
|
393
|
+
const marks = hasComment
|
|
394
|
+
? [
|
|
395
|
+
['7', '9', '17', '9'],
|
|
396
|
+
['7', '13', '13', '13'],
|
|
397
|
+
]
|
|
398
|
+
: [
|
|
399
|
+
['12', '7', '12', '13'],
|
|
400
|
+
['9', '10', '15', '10'],
|
|
401
|
+
];
|
|
402
|
+
for (const [x1, y1, x2, y2] of marks) {
|
|
403
|
+
const line = document.createElementNS(SVG_NS, 'line');
|
|
404
|
+
line.setAttribute('x1', x1 ?? '0');
|
|
405
|
+
line.setAttribute('y1', y1 ?? '0');
|
|
406
|
+
line.setAttribute('x2', x2 ?? '0');
|
|
407
|
+
line.setAttribute('y2', y2 ?? '0');
|
|
408
|
+
svg.append(line);
|
|
409
|
+
}
|
|
410
|
+
return svg;
|
|
411
|
+
}
|
|
412
|
+
/** Radio groups are keyed by name, so every element on a page needs its own. */
|
|
413
|
+
let groupCounter = 0;
|
|
414
|
+
class CrtrCardsElement extends HTMLElement {
|
|
415
|
+
#container = document.createElement('div');
|
|
416
|
+
#group;
|
|
417
|
+
#config = null;
|
|
418
|
+
#cards = [];
|
|
419
|
+
#slotId;
|
|
420
|
+
// Response state. Authoritative for rendering; `respond()` only asks the host to save it.
|
|
421
|
+
#selected = [];
|
|
422
|
+
#comments = [];
|
|
423
|
+
#activeId = null;
|
|
424
|
+
#nodes = new Map();
|
|
425
|
+
#open = null;
|
|
426
|
+
#editor = null;
|
|
427
|
+
/** Bumped on every (dis)connect so a late source read cannot paint a stale grid. */
|
|
428
|
+
#generation = 0;
|
|
429
|
+
constructor() {
|
|
430
|
+
super();
|
|
431
|
+
groupCounter += 1;
|
|
432
|
+
this.#group = `crtr-cards-${groupCounter}`;
|
|
433
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
434
|
+
const style = document.createElement('style');
|
|
435
|
+
style.textContent = STYLE;
|
|
436
|
+
this.#container.part.add('container');
|
|
437
|
+
this.#container.addEventListener('keydown', (event) => this.#onKeyDown(event));
|
|
438
|
+
root.append(style, this.#container);
|
|
439
|
+
}
|
|
440
|
+
connectedCallback() {
|
|
441
|
+
this.#generation += 1;
|
|
442
|
+
const generation = this.#generation;
|
|
443
|
+
const read = readSlotConfig(this);
|
|
444
|
+
if (read.status !== 'ok') {
|
|
445
|
+
this.#renderUnavailable(read.reason);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const normalized = normalizeConfig(read.config);
|
|
449
|
+
if (normalized.status !== 'ok') {
|
|
450
|
+
this.#renderUnavailable(normalized.reason);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
this.#config = normalized.config;
|
|
454
|
+
this.#slotId = readSlotId(this);
|
|
455
|
+
const source = normalized.config.source;
|
|
456
|
+
if (source === undefined) {
|
|
457
|
+
this.#cards = normalized.config.cards ?? [];
|
|
458
|
+
this.#build();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
this.#renderStatus('loading', 'Loading cards…');
|
|
462
|
+
void this.#resolveSource(source, generation);
|
|
463
|
+
}
|
|
464
|
+
disconnectedCallback() {
|
|
465
|
+
this.#generation += 1;
|
|
466
|
+
}
|
|
467
|
+
/** The host resolves `source`; this element never touches the network. */
|
|
468
|
+
async #resolveSource(source, generation) {
|
|
469
|
+
const read = await artifactData(source);
|
|
470
|
+
if (generation !== this.#generation)
|
|
471
|
+
return;
|
|
472
|
+
if (read.status !== 'ok') {
|
|
473
|
+
this.#renderUnavailable(read.reason);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const cards = normalizeCards(read.data);
|
|
477
|
+
if (cards === null) {
|
|
478
|
+
this.#renderUnavailable(`source "${source}" is not a list of cards`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
this.#cards = cards;
|
|
482
|
+
this.#build();
|
|
483
|
+
}
|
|
484
|
+
// ---------------------------------------------------------------- rendering
|
|
485
|
+
#renderUnavailable(reason) {
|
|
486
|
+
this.#nodes.clear();
|
|
487
|
+
this.#open = null;
|
|
488
|
+
this.#editor = null;
|
|
489
|
+
const box = document.createElement('div');
|
|
490
|
+
box.part.add('unavailable');
|
|
491
|
+
box.setAttribute('role', 'status');
|
|
492
|
+
const kind = document.createElement('span');
|
|
493
|
+
kind.part.add('kind');
|
|
494
|
+
kind.textContent = 'cards';
|
|
495
|
+
const detail = document.createElement('span');
|
|
496
|
+
detail.part.add('reason');
|
|
497
|
+
detail.textContent = reason;
|
|
498
|
+
box.append('Component unavailable: ', kind, detail);
|
|
499
|
+
this.#container.replaceChildren(box);
|
|
500
|
+
}
|
|
501
|
+
#renderStatus(part, text) {
|
|
502
|
+
const box = document.createElement('div');
|
|
503
|
+
box.part.add(part);
|
|
504
|
+
box.setAttribute('role', 'status');
|
|
505
|
+
box.textContent = text;
|
|
506
|
+
this.#container.replaceChildren(box);
|
|
507
|
+
}
|
|
508
|
+
/** Builds the grid once per data load; every later change repaints in place. */
|
|
509
|
+
#build() {
|
|
510
|
+
const config = this.#config;
|
|
511
|
+
if (config === null)
|
|
512
|
+
return;
|
|
513
|
+
this.#nodes.clear();
|
|
514
|
+
this.#open = null;
|
|
515
|
+
this.#editor = null;
|
|
516
|
+
// A selection or comment carried over from a previous data load can only name a card
|
|
517
|
+
// that is still here — true even when the new load has no cards at all.
|
|
518
|
+
const ids = new Set(this.#cards.map((card) => card.id));
|
|
519
|
+
this.#selected = this.#selected.filter((id) => ids.has(id));
|
|
520
|
+
this.#comments = this.#comments.filter((comment) => comment.anchor.kind !== 'card' || ids.has(comment.anchor.cardId));
|
|
521
|
+
if (this.#cards.length === 0) {
|
|
522
|
+
this.#renderStatus('empty', 'No cards to choose from.');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const grid = document.createElement('div');
|
|
526
|
+
grid.part.add('grid');
|
|
527
|
+
if (config.columns === 3)
|
|
528
|
+
grid.dataset.columns = '3';
|
|
529
|
+
grid.append(...this.#cards.map((card) => this.#buildCard(card, config.mode, this.#group)));
|
|
530
|
+
this.#container.replaceChildren(grid);
|
|
531
|
+
this.#paint();
|
|
532
|
+
}
|
|
533
|
+
#buildCard(card, mode, group) {
|
|
534
|
+
const root = document.createElement('div');
|
|
535
|
+
root.part.add('card');
|
|
536
|
+
root.dataset.cardId = card.id;
|
|
537
|
+
const header = document.createElement('div');
|
|
538
|
+
header.part.add('card-header');
|
|
539
|
+
// The surface is presentational; the real radio / checkbox is the control, so a
|
|
540
|
+
// native radio group's arrow keys, Enter and Space all behave as they always do.
|
|
541
|
+
const label = document.createElement('label');
|
|
542
|
+
label.part.add('card-label');
|
|
543
|
+
const control = document.createElement('input');
|
|
544
|
+
control.part.add('control');
|
|
545
|
+
control.type = mode === 'single' ? 'radio' : 'checkbox';
|
|
546
|
+
control.name = group;
|
|
547
|
+
control.value = card.id;
|
|
548
|
+
control.addEventListener('change', () => this.#toggle(card.id, control.checked));
|
|
549
|
+
control.addEventListener('focus', () => this.#setActive(card.id, false));
|
|
550
|
+
const heading = document.createElement('span');
|
|
551
|
+
heading.part.add('card-heading');
|
|
552
|
+
const title = document.createElement('span');
|
|
553
|
+
title.part.add('card-title');
|
|
554
|
+
title.textContent = card.title;
|
|
555
|
+
heading.append(title);
|
|
556
|
+
if (card.subtitle !== undefined && card.subtitle !== '') {
|
|
557
|
+
const subtitle = document.createElement('span');
|
|
558
|
+
subtitle.part.add('card-subtitle');
|
|
559
|
+
subtitle.textContent = card.subtitle;
|
|
560
|
+
heading.append(subtitle);
|
|
561
|
+
}
|
|
562
|
+
const commentButton = document.createElement('button');
|
|
563
|
+
commentButton.type = 'button';
|
|
564
|
+
commentButton.part.add('comment-button');
|
|
565
|
+
commentButton.addEventListener('click', (event) => {
|
|
566
|
+
event.stopPropagation();
|
|
567
|
+
this.#setActive(card.id, false);
|
|
568
|
+
this.#toggleEditor({ kind: 'card', cardId: card.id }, card.title, root, null);
|
|
569
|
+
});
|
|
570
|
+
label.append(control, heading);
|
|
571
|
+
header.append(label, commentButton);
|
|
572
|
+
root.append(header);
|
|
573
|
+
if (card.body !== undefined && card.body !== '') {
|
|
574
|
+
const body = document.createElement('p');
|
|
575
|
+
body.part.add('card-body');
|
|
576
|
+
body.textContent = card.body;
|
|
577
|
+
root.append(body);
|
|
578
|
+
}
|
|
579
|
+
let tag = null;
|
|
580
|
+
if (card.tag !== undefined && card.tag !== '') {
|
|
581
|
+
tag = document.createElement('span');
|
|
582
|
+
tag.part.add('tag');
|
|
583
|
+
tag.textContent = card.tag;
|
|
584
|
+
root.append(tag);
|
|
585
|
+
}
|
|
586
|
+
root.addEventListener('mouseenter', () => this.#setActive(card.id, false));
|
|
587
|
+
root.addEventListener('mouseleave', () => {
|
|
588
|
+
if (this.#activeId === card.id)
|
|
589
|
+
this.#setActive(null, false);
|
|
590
|
+
});
|
|
591
|
+
root.addEventListener('focusin', () => this.#setActive(card.id, false));
|
|
592
|
+
this.#nodes.set(card.id, { root, control, commentButton, tag });
|
|
593
|
+
return root;
|
|
594
|
+
}
|
|
595
|
+
/** Repaints selection, activity and comment presence without rebuilding the grid. */
|
|
596
|
+
#paint() {
|
|
597
|
+
const mode = this.#config?.mode ?? 'multi';
|
|
598
|
+
const tabbableId = mode === 'single' ? (this.#selected[0] ?? this.#cards[0]?.id) : undefined;
|
|
599
|
+
for (const card of this.#cards) {
|
|
600
|
+
const node = this.#nodes.get(card.id);
|
|
601
|
+
if (node === undefined)
|
|
602
|
+
continue;
|
|
603
|
+
const selected = this.#selected.includes(card.id);
|
|
604
|
+
node.root.part.toggle('selected', selected);
|
|
605
|
+
node.root.part.toggle('active', this.#activeId === card.id);
|
|
606
|
+
node.control.checked = selected;
|
|
607
|
+
// A radio group holds one tab stop; an unselected group still needs an entry point.
|
|
608
|
+
node.control.tabIndex = mode === 'single' && card.id !== tabbableId ? -1 : 0;
|
|
609
|
+
const comment = findComment(this.#comments, { kind: 'card', cardId: card.id });
|
|
610
|
+
this.#paintCommentButton(node.commentButton, comment, card.title);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
#paintCommentButton(button, comment, target) {
|
|
614
|
+
const has = comment !== undefined;
|
|
615
|
+
const glyphChanged = button.part.contains('has-comment') !== has;
|
|
616
|
+
button.part.toggle('has-comment', has);
|
|
617
|
+
button.setAttribute('aria-label', has ? `Edit comment on ${target}` : `Comment on ${target}`);
|
|
618
|
+
// The saved note reads on hover, so a comment is legible without opening its editor.
|
|
619
|
+
if (has)
|
|
620
|
+
button.title = comment.text;
|
|
621
|
+
else
|
|
622
|
+
button.removeAttribute('title');
|
|
623
|
+
// Paint runs on every hover and focus change; only a real change touches the DOM.
|
|
624
|
+
if (glyphChanged || button.childElementCount === 0)
|
|
625
|
+
button.replaceChildren(commentIcon(has));
|
|
626
|
+
}
|
|
627
|
+
// ------------------------------------------------------------- interaction
|
|
628
|
+
#setActive(id, focus) {
|
|
629
|
+
if (this.#activeId === id && !focus)
|
|
630
|
+
return;
|
|
631
|
+
this.#activeId = id;
|
|
632
|
+
this.#paint();
|
|
633
|
+
if (focus && id !== null)
|
|
634
|
+
this.#nodes.get(id)?.control.focus();
|
|
635
|
+
}
|
|
636
|
+
#toggle(cardId, selected) {
|
|
637
|
+
if (this.#config === null)
|
|
638
|
+
return;
|
|
639
|
+
if (this.#config.mode === 'single') {
|
|
640
|
+
// Radio semantics: choosing a card replaces the answer, and re-choosing keeps it.
|
|
641
|
+
this.#selected = [cardId];
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
const next = selected
|
|
645
|
+
? [...this.#selected, cardId]
|
|
646
|
+
: this.#selected.filter((id) => id !== cardId);
|
|
647
|
+
// Selection reads back in the order the agent authored the cards, not click order.
|
|
648
|
+
this.#selected = this.#cards.map((card) => card.id).filter((id) => next.includes(id));
|
|
649
|
+
}
|
|
650
|
+
this.#paint();
|
|
651
|
+
this.#emit();
|
|
652
|
+
}
|
|
653
|
+
#onKeyDown(event) {
|
|
654
|
+
if (event.metaKey || event.ctrlKey || event.altKey)
|
|
655
|
+
return;
|
|
656
|
+
if (isTextEntry(event.target))
|
|
657
|
+
return;
|
|
658
|
+
const ids = this.#cards.map((card) => card.id);
|
|
659
|
+
if (ids.length === 0)
|
|
660
|
+
return;
|
|
661
|
+
const key = event.key.toLowerCase();
|
|
662
|
+
const currentId = this.#activeId ?? this.#selected[this.#selected.length - 1] ?? ids[0] ?? '';
|
|
663
|
+
if (key === 'j' || key === 'k') {
|
|
664
|
+
event.preventDefault();
|
|
665
|
+
const index = Math.max(0, ids.indexOf(currentId));
|
|
666
|
+
const step = key === 'j' ? 1 : -1;
|
|
667
|
+
const nextId = ids[(index + step + ids.length) % ids.length];
|
|
668
|
+
if (nextId !== undefined)
|
|
669
|
+
this.#setActive(nextId, true);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (key === 'x') {
|
|
673
|
+
event.preventDefault();
|
|
674
|
+
this.#toggle(currentId, this.#config?.mode === 'single' ? true : !this.#selected.includes(currentId));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (key === 'c') {
|
|
678
|
+
event.preventDefault();
|
|
679
|
+
const card = this.#cards.find((entry) => entry.id === currentId);
|
|
680
|
+
const node = this.#nodes.get(currentId);
|
|
681
|
+
if (card === undefined || node === undefined)
|
|
682
|
+
return;
|
|
683
|
+
this.#setActive(currentId, false);
|
|
684
|
+
this.#toggleEditor({ kind: 'card', cardId: card.id }, card.title, node.root, null);
|
|
685
|
+
}
|
|
686
|
+
// Enter, Space and the arrow keys are left to the native radio / checkbox, exactly as
|
|
687
|
+
// the kit leaves them — this handler only adds the list grammar on top.
|
|
688
|
+
}
|
|
689
|
+
// ----------------------------------------------------------------- comments
|
|
690
|
+
/** Opens the editor for `anchor`, or closes it when it is already the open one. */
|
|
691
|
+
#toggleEditor(anchor, target, mount, after) {
|
|
692
|
+
if (mount === null)
|
|
693
|
+
return;
|
|
694
|
+
const openAnchor = this.#open?.anchor;
|
|
695
|
+
const sameAnchor = openAnchor !== undefined &&
|
|
696
|
+
openAnchor.kind === anchor.kind &&
|
|
697
|
+
(anchor.kind !== 'card' || (openAnchor.kind === 'card' && openAnchor.cardId === anchor.cardId));
|
|
698
|
+
this.#closeEditor(false);
|
|
699
|
+
if (sameAnchor)
|
|
700
|
+
return;
|
|
701
|
+
this.#openEditor({ anchor, target, mount, after });
|
|
702
|
+
}
|
|
703
|
+
#openEditor(open) {
|
|
704
|
+
const editor = document.createElement('div');
|
|
705
|
+
editor.part.add('comment-editor');
|
|
706
|
+
// The editor lives inside the card it comments on; a click in it is not a card click.
|
|
707
|
+
editor.addEventListener('click', (event) => event.stopPropagation());
|
|
708
|
+
const subject = document.createElement('p');
|
|
709
|
+
subject.part.add('comment-subject');
|
|
710
|
+
subject.textContent = `Comment on ${open.target}`;
|
|
711
|
+
const input = document.createElement('textarea');
|
|
712
|
+
input.part.add('comment-input');
|
|
713
|
+
input.placeholder = 'What should the agent know about this?';
|
|
714
|
+
// Seeded from the saved comment every time it opens, so an abandoned edit never leaks.
|
|
715
|
+
input.value = findComment(this.#comments, open.anchor)?.text ?? '';
|
|
716
|
+
const actions = document.createElement('div');
|
|
717
|
+
actions.part.add('comment-actions');
|
|
718
|
+
const hint = document.createElement('span');
|
|
719
|
+
hint.part.add('comment-hint');
|
|
720
|
+
hint.textContent = findComment(this.#comments, open.anchor) !== undefined ? 'empty to delete' : '⌘↵ to save';
|
|
721
|
+
const buttons = document.createElement('div');
|
|
722
|
+
buttons.part.add('comment-buttons');
|
|
723
|
+
const cancel = document.createElement('button');
|
|
724
|
+
cancel.type = 'button';
|
|
725
|
+
cancel.part.add('comment-cancel');
|
|
726
|
+
cancel.textContent = 'Cancel';
|
|
727
|
+
cancel.addEventListener('click', () => this.#closeEditor(true));
|
|
728
|
+
const save = document.createElement('button');
|
|
729
|
+
save.type = 'button';
|
|
730
|
+
save.part.add('comment-save');
|
|
731
|
+
save.textContent = 'Save';
|
|
732
|
+
save.addEventListener('click', () => this.#commitComment(input.value));
|
|
733
|
+
buttons.append(cancel, save);
|
|
734
|
+
actions.append(hint, buttons);
|
|
735
|
+
input.addEventListener('keydown', (event) => {
|
|
736
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
737
|
+
event.preventDefault();
|
|
738
|
+
this.#commitComment(input.value);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (event.key === 'Escape') {
|
|
742
|
+
event.preventDefault();
|
|
743
|
+
this.#closeEditor(true);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
editor.append(subject, input, actions);
|
|
747
|
+
if (open.after !== null)
|
|
748
|
+
open.after.after(editor);
|
|
749
|
+
else
|
|
750
|
+
open.mount.append(editor);
|
|
751
|
+
this.#open = open;
|
|
752
|
+
this.#editor = editor;
|
|
753
|
+
input.focus();
|
|
754
|
+
}
|
|
755
|
+
#closeEditor(restoreFocus) {
|
|
756
|
+
const open = this.#open;
|
|
757
|
+
this.#editor?.remove();
|
|
758
|
+
this.#editor = null;
|
|
759
|
+
this.#open = null;
|
|
760
|
+
if (!restoreFocus || open === null)
|
|
761
|
+
return;
|
|
762
|
+
if (open.anchor.kind === 'card')
|
|
763
|
+
this.#nodes.get(open.anchor.cardId)?.control.focus();
|
|
764
|
+
}
|
|
765
|
+
#commitComment(text) {
|
|
766
|
+
const open = this.#open;
|
|
767
|
+
if (open === null)
|
|
768
|
+
return;
|
|
769
|
+
this.#comments = setComment(this.#comments, open.anchor, text);
|
|
770
|
+
this.#closeEditor(true);
|
|
771
|
+
this.#paint();
|
|
772
|
+
this.#emit();
|
|
773
|
+
}
|
|
774
|
+
// ----------------------------------------------------------------- response
|
|
775
|
+
#response() {
|
|
776
|
+
return { selectedCardIds: [...this.#selected], comments: this.#comments.map((comment) => ({ ...comment })) };
|
|
777
|
+
}
|
|
778
|
+
/** Every user change hands the host this slot's complete current response, never a delta. */
|
|
779
|
+
#emit() {
|
|
780
|
+
if (this.#slotId === undefined)
|
|
781
|
+
return;
|
|
782
|
+
respond(this.#slotId, this.#response());
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
registerElement('crtr-cards', CrtrCardsElement);
|