@khanglvm/relay 0.12.1 → 0.12.3
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/docs/AGENT.md +1 -1
- package/package.json +1 -1
- package/src/mcp-ui/board.js +27 -3
- package/src/mcp-ui/index.html +6 -1
- package/src/mcp.js +30 -5
- package/src/spec.js +5 -2
- package/src/ui/annotate.css +63 -8
- package/src/ui/annotate.js +62 -12
- package/src/ui/app.js +2 -2
- package/src/ui/style.css +2 -0
package/docs/AGENT.md
CHANGED
|
@@ -154,7 +154,7 @@ rly show --html-file prototype.html --title "Dashboard concept" --height 600
|
|
|
154
154
|
{ "value": "a", "label": "Approach A", "description": "fast, less flexible" },
|
|
155
155
|
"Approach B" // plain strings work too
|
|
156
156
|
],
|
|
157
|
-
"other":
|
|
157
|
+
"other": false }, // single: "Other" (free-text textarea) is ON by default — set false to remove it
|
|
158
158
|
{ "id": "scope", "type": "multi", "label": "Include which parts?", "options": ["api", "ui", "docs"],
|
|
159
159
|
"note": true }, // optional free-text under the question
|
|
160
160
|
// → returned as result.notes.scope
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanglvm/relay",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/src/mcp-ui/board.js
CHANGED
|
@@ -159,6 +159,25 @@
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// Platform awareness: reflect the host's surface so the board (and CSS) can
|
|
163
|
+
// adapt — a data-platform hint, a touch flag, and the host's safe-area insets
|
|
164
|
+
// applied as padding so content clears notches / home indicators on mobile.
|
|
165
|
+
function applyHostEnv() {
|
|
166
|
+
const root = document.documentElement;
|
|
167
|
+
if (host.platform === 'web' || host.platform === 'desktop' || host.platform === 'mobile') {
|
|
168
|
+
root.dataset.platform = host.platform;
|
|
169
|
+
}
|
|
170
|
+
root.classList.toggle('mcp-touch', Boolean(host.deviceCapabilities && host.deviceCapabilities.touch));
|
|
171
|
+
const sai = host.safeAreaInsets;
|
|
172
|
+
if (sai && typeof sai === 'object') {
|
|
173
|
+
const px = (n) => (Number.isFinite(n) ? n : 0) + 'px';
|
|
174
|
+
root.style.setProperty('--mcp-safe-top', px(sai.top));
|
|
175
|
+
root.style.setProperty('--mcp-safe-right', px(sai.right));
|
|
176
|
+
root.style.setProperty('--mcp-safe-bottom', px(sai.bottom));
|
|
177
|
+
root.style.setProperty('--mcp-safe-left', px(sai.left));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
162
181
|
let sizeTimer = null;
|
|
163
182
|
function measureHeight() {
|
|
164
183
|
return Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
|
|
@@ -230,7 +249,7 @@
|
|
|
230
249
|
}
|
|
231
250
|
return { value: String(o), label: String(o) };
|
|
232
251
|
});
|
|
233
|
-
q.other = rq.other === true;
|
|
252
|
+
q.other = type === 'single' ? rq.other !== false : rq.other === true;
|
|
234
253
|
}
|
|
235
254
|
if (type === 'scale') {
|
|
236
255
|
q.min = Number.isFinite(rq.min) ? rq.min : 1;
|
|
@@ -496,7 +515,7 @@
|
|
|
496
515
|
}
|
|
497
516
|
if (q.other) {
|
|
498
517
|
otherRadio = el('input', { type: 'radio', name: q.id });
|
|
499
|
-
const text = el('
|
|
518
|
+
const text = el('textarea', { class: 'otherinput', rows: '2', placeholder: 'your own answer…' });
|
|
500
519
|
text.value = (state.other[q.id] && state.other[q.id].text) || '';
|
|
501
520
|
otherRadio.checked = otherOn();
|
|
502
521
|
const ensureOther = () => state.other[q.id] || (state.other[q.id] = { on: false, text: text.value });
|
|
@@ -529,7 +548,7 @@
|
|
|
529
548
|
if (q.other) {
|
|
530
549
|
const oth = state.other[q.id];
|
|
531
550
|
const box = el('input', { type: 'checkbox' });
|
|
532
|
-
const text = el('
|
|
551
|
+
const text = el('textarea', { class: 'otherinput', rows: '2', placeholder: 'your own answer…' });
|
|
533
552
|
box.checked = Boolean(oth && oth.on);
|
|
534
553
|
text.value = (oth && oth.text) || '';
|
|
535
554
|
const sync = () => { state.other[q.id] = { on: box.checked, text: text.value }; syncOptSel(group); clearErr(q.id); };
|
|
@@ -841,11 +860,15 @@
|
|
|
841
860
|
if (p && typeof p === 'object') {
|
|
842
861
|
if ('theme' in p) host.theme = p.theme;
|
|
843
862
|
if ('styles' in p) host.styles = p.styles;
|
|
863
|
+
if ('platform' in p) host.platform = p.platform;
|
|
864
|
+
if ('safeAreaInsets' in p) host.safeAreaInsets = p.safeAreaInsets;
|
|
865
|
+
if ('deviceCapabilities' in p) host.deviceCapabilities = p.deviceCapabilities;
|
|
844
866
|
if (p.displayMode === 'inline' || p.displayMode === 'fullscreen' || p.displayMode === 'pip') {
|
|
845
867
|
displayMode = p.displayMode;
|
|
846
868
|
applyDisplayMode();
|
|
847
869
|
}
|
|
848
870
|
adoptHostStyles();
|
|
871
|
+
applyHostEnv();
|
|
849
872
|
applyTheme();
|
|
850
873
|
}
|
|
851
874
|
});
|
|
@@ -870,6 +893,7 @@
|
|
|
870
893
|
if (host.displayMode === 'fullscreen' || host.displayMode === 'pip') displayMode = host.displayMode;
|
|
871
894
|
notify('ui/notifications/initialized', {});
|
|
872
895
|
adoptHostStyles();
|
|
896
|
+
applyHostEnv();
|
|
873
897
|
applyTheme();
|
|
874
898
|
setStatus('Waiting for the board…');
|
|
875
899
|
reportSize();
|
package/src/mcp-ui/index.html
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
:root{ color-scheme: light dark; }
|
|
14
14
|
html,body{ background:transparent; }
|
|
15
15
|
body{ margin:0; }
|
|
16
|
-
.wrap{ max-width:none; margin:0;
|
|
16
|
+
.wrap{ max-width:none; margin:0;
|
|
17
|
+
padding:
|
|
18
|
+
calc(14px + var(--mcp-safe-top, 0px))
|
|
19
|
+
calc(16px + var(--mcp-safe-right, 0px))
|
|
20
|
+
calc(18px + var(--mcp-safe-bottom, 0px))
|
|
21
|
+
calc(16px + var(--mcp-safe-left, 0px)); }
|
|
17
22
|
.mcp-status{ font:13px/1.5 var(--sans, -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif); color:var(--muted,#8a8580); padding:18px 16px; }
|
|
18
23
|
/* post-submit: a slim one-line confirmation so the iframe collapses small */
|
|
19
24
|
.mcp-done{ display:flex; align-items:center; gap:8px; padding:11px 16px; font:14px/1.4 var(--sans, -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif); color:var(--fg,#1c1b19); }
|
package/src/mcp.js
CHANGED
|
@@ -214,17 +214,42 @@ function callTool(params) {
|
|
|
214
214
|
const msg = err instanceof CliError ? err.message : String((err && err.message) || err);
|
|
215
215
|
return { content: [{ type: 'text', text: 'relay: invalid board spec — ' + msg }], isError: true };
|
|
216
216
|
}
|
|
217
|
-
const nQ = spec.questions.length;
|
|
218
|
-
const summary = nQ
|
|
219
|
-
? `Relay board "${spec.title}" is now displayed to the user (${nQ} question${nQ === 1 ? '' : 's'}). They will fill it in and submit; their answers will be delivered back to you. Do NOT re-ask these questions in plain text — wait for the submission.`
|
|
220
|
-
: `Relay board "${spec.title}" is now displayed to the user. They can review it and acknowledge; any feedback will be delivered back to you.`;
|
|
221
217
|
return {
|
|
222
|
-
content: [{ type: 'text', text:
|
|
218
|
+
content: [{ type: 'text', text: boardText(spec) }],
|
|
223
219
|
structuredContent: { spec, mode: name === 'relay_show' ? 'show' : 'ask' },
|
|
224
220
|
_meta: { ui: { resourceUri: BOARD_URI }, [UI_EXT]: { resourceUri: BOARD_URI } },
|
|
225
221
|
};
|
|
226
222
|
}
|
|
227
223
|
|
|
224
|
+
// The tool-result text. Beyond announcing the board, it carries a readable
|
|
225
|
+
// rendering of the questions as a GRACEFUL FALLBACK: on a surface that can't show
|
|
226
|
+
// the interactive board (some mobile/remote-control views), the user can still
|
|
227
|
+
// answer in chat and the model has the questions to work from. The model should
|
|
228
|
+
// PREFER the rendered board (wait for the submission) and only fall back to chat
|
|
229
|
+
// when the board clearly didn't render for the user.
|
|
230
|
+
function boardText(spec) {
|
|
231
|
+
const nQ = spec.questions.length;
|
|
232
|
+
if (!nQ) {
|
|
233
|
+
return `Relay board "${spec.title}" is now displayed to the user. They can review it and acknowledge; any feedback will be delivered back to you.`;
|
|
234
|
+
}
|
|
235
|
+
const lines = [];
|
|
236
|
+
lines.push(`Relay board "${spec.title}" is now displayed to the user (${nQ} question${nQ === 1 ? '' : 's'}). Prefer the interactive board — wait for their submission; do NOT re-ask in plain text.`);
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push('If the board does NOT render on the user\'s surface (e.g. some mobile / remote views) they can answer in chat — the questions are:');
|
|
239
|
+
spec.questions.forEach((q, i) => {
|
|
240
|
+
let line = `${i + 1}. [${q.id}] ${q.label} (${q.type}`;
|
|
241
|
+
if (q.required || spec.allowPartial === false) line += ', required';
|
|
242
|
+
line += ')';
|
|
243
|
+
if (Array.isArray(q.options) && q.options.length) {
|
|
244
|
+
line += ' — options: ' + q.options.map((o) => o.label || o.value).join(' | ') + (q.other ? ' | Other (free text)' : '');
|
|
245
|
+
} else if (q.type === 'scale') {
|
|
246
|
+
line += ` — ${q.min}…${q.max}`;
|
|
247
|
+
}
|
|
248
|
+
lines.push(line);
|
|
249
|
+
});
|
|
250
|
+
return lines.join('\n');
|
|
251
|
+
}
|
|
252
|
+
|
|
228
253
|
// ---------- stdio loop ----------
|
|
229
254
|
export function runMcp() {
|
|
230
255
|
// The peer closing stdout (EPIPE) is the only reason to stop writing — NOT a
|
package/src/spec.js
CHANGED
|
@@ -510,7 +510,10 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
|
|
|
510
510
|
if (q.options.length < 1) {
|
|
511
511
|
throw new CliError(`${where}: type "${type}" needs at least 1 option.`);
|
|
512
512
|
}
|
|
513
|
-
|
|
513
|
+
// Radio (single) questions include an "Other" free-text option by default so
|
|
514
|
+
// the user is never boxed into the listed choices; opt out with other:false.
|
|
515
|
+
// Multi stays opt-in (checkbox lists are usually exhaustive on purpose).
|
|
516
|
+
q.other = type === 'single' ? rq.other !== false : rq.other === true;
|
|
514
517
|
}
|
|
515
518
|
|
|
516
519
|
if (type === 'scale') {
|
|
@@ -667,7 +670,7 @@ export const SPEC_SCHEMA = {
|
|
|
667
670
|
],
|
|
668
671
|
},
|
|
669
672
|
},
|
|
670
|
-
other: { type: 'boolean',
|
|
673
|
+
other: { type: 'boolean', description: 'Add a free-text "Other" option (a multi-line textarea); its text is returned verbatim as the value. Defaults ON for "single" (radio) questions so the user is never boxed in — set other:false to remove it; "multi" stays opt-in (other:true).' },
|
|
671
674
|
note: { type: 'boolean', description: 'Small optional free-text field under the question (to qualify an answer). Returned separately as result.notes[questionId]. Defaults to true for "single" (radio) questions so users can comment on their pick, false for other types; set note:false to hide it on a single question.' },
|
|
672
675
|
placeholder: { type: 'string', description: 'For text/textarea.' },
|
|
673
676
|
default: { description: 'Pre-selected value. Shape matches the answer shape for the type.' },
|
package/src/ui/annotate.css
CHANGED
|
@@ -48,12 +48,22 @@
|
|
|
48
48
|
padding: 12px 14px;
|
|
49
49
|
font: 14px/1.5 var(--sans);
|
|
50
50
|
}
|
|
51
|
+
.ann-pop-head { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; }
|
|
51
52
|
.ann-pop-label {
|
|
53
|
+
flex: 1; min-width: 0;
|
|
52
54
|
color: var(--muted);
|
|
53
55
|
font-size: 0.78rem;
|
|
54
|
-
margin-bottom: 8px;
|
|
55
56
|
overflow-wrap: anywhere;
|
|
56
57
|
}
|
|
58
|
+
.ann-pop-close {
|
|
59
|
+
flex: none; margin: -4px -4px 0 0;
|
|
60
|
+
background: transparent; color: var(--muted);
|
|
61
|
+
border: none; border-radius: 7px;
|
|
62
|
+
width: 26px; height: 26px;
|
|
63
|
+
font-size: 1.25rem; line-height: 1; cursor: pointer;
|
|
64
|
+
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
65
|
+
}
|
|
66
|
+
.ann-pop-close:hover { color: var(--fg); background: var(--bg-sunken); }
|
|
57
67
|
.ann-pop-existing { margin-bottom: 8px; max-height: min(50vh, 420px); overflow-y: auto; }
|
|
58
68
|
.ann-pop-existing:empty { display: none; margin: 0; }
|
|
59
69
|
|
|
@@ -146,16 +156,49 @@
|
|
|
146
156
|
.ann-cancel:hover { border-color: var(--accent); color: var(--accent); }
|
|
147
157
|
|
|
148
158
|
/* delete button (popover items + summary rows): muted, danger on hover */
|
|
159
|
+
/* Delete = a trash icon button, visually distinct from the close ×. */
|
|
149
160
|
.ann-del {
|
|
150
161
|
flex: none;
|
|
162
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
151
163
|
background: transparent; color: var(--muted);
|
|
152
|
-
border: none; border-radius:
|
|
153
|
-
|
|
154
|
-
font-size: 1.05rem; line-height: 1;
|
|
164
|
+
border: none; border-radius: 7px;
|
|
165
|
+
width: 26px; height: 26px;
|
|
155
166
|
cursor: pointer;
|
|
156
|
-
transition: color 150ms var(--ease);
|
|
167
|
+
transition: color 150ms var(--ease), background 150ms var(--ease);
|
|
157
168
|
}
|
|
158
|
-
.ann-del:hover { color: var(--danger); }
|
|
169
|
+
.ann-del:hover { color: var(--danger); background: var(--accent-soft); }
|
|
170
|
+
|
|
171
|
+
/* ---------- delete confirmation modal ---------- */
|
|
172
|
+
.ann-confirm-scrim {
|
|
173
|
+
position: fixed; inset: 0; z-index: 80;
|
|
174
|
+
background: rgba(28, 27, 25, 0.4);
|
|
175
|
+
display: flex; align-items: center; justify-content: center;
|
|
176
|
+
padding: 20px;
|
|
177
|
+
}
|
|
178
|
+
.ann-confirm {
|
|
179
|
+
width: min(360px, 92vw);
|
|
180
|
+
background: var(--card); color: var(--fg);
|
|
181
|
+
border: 1px solid var(--border); border-radius: 14px;
|
|
182
|
+
box-shadow: var(--shadow-lift);
|
|
183
|
+
padding: 20px;
|
|
184
|
+
font: 14px/1.5 var(--sans);
|
|
185
|
+
}
|
|
186
|
+
.ann-confirm-title { font: 600 1.05rem var(--sans); margin-bottom: 6px; }
|
|
187
|
+
.ann-confirm-msg { color: var(--fg-2); font-size: 0.9rem; }
|
|
188
|
+
.ann-confirm-skip {
|
|
189
|
+
display: flex; align-items: center; gap: 8px;
|
|
190
|
+
margin: 14px 0 4px; color: var(--fg-2); font-size: 0.85rem; cursor: pointer;
|
|
191
|
+
}
|
|
192
|
+
.ann-confirm-skip input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
|
|
193
|
+
.ann-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
|
194
|
+
.ann-confirm-cancel, .ann-confirm-del {
|
|
195
|
+
border-radius: 9px; padding: 8px 16px; font: 600 0.88rem var(--sans); cursor: pointer;
|
|
196
|
+
border: 1px solid var(--border-strong); transition: all 150ms var(--ease);
|
|
197
|
+
}
|
|
198
|
+
.ann-confirm-cancel { background: var(--card); color: var(--fg-2); }
|
|
199
|
+
.ann-confirm-cancel:hover { background: var(--bg-sunken); color: var(--fg); }
|
|
200
|
+
.ann-confirm-del { background: var(--danger); color: var(--danger-fg); border-color: var(--danger); }
|
|
201
|
+
.ann-confirm-del:hover { filter: brightness(1.06); }
|
|
159
202
|
|
|
160
203
|
/* ---------- floating selection button ---------- */
|
|
161
204
|
.ann-selbtn {
|
|
@@ -274,8 +317,20 @@
|
|
|
274
317
|
.ann-rail-list .ann-sum-row + .ann-sum-row { border-top: 1px solid var(--border); }
|
|
275
318
|
.ann-rail-list .ann-sum-row:hover { border-color: var(--accent); box-shadow: var(--shadow-card); }
|
|
276
319
|
|
|
277
|
-
/*
|
|
278
|
-
|
|
320
|
+
/* The rail NEVER auto-opens (that shifted the board unexpectedly) — it's reached
|
|
321
|
+
only via the floating Comments button, which flashes when a new comment lands. */
|
|
322
|
+
@keyframes ann-toggle-pulse {
|
|
323
|
+
0% { transform: scale(1); }
|
|
324
|
+
35% { transform: scale(1.12); box-shadow: 0 0 0 4px var(--accent-soft); }
|
|
325
|
+
100% { transform: scale(1); }
|
|
326
|
+
}
|
|
327
|
+
.ann-rail-toggle.pulse { animation: ann-toggle-pulse 600ms var(--ease); border-color: var(--accent); color: var(--accent); }
|
|
328
|
+
|
|
329
|
+
/* When opened on a roomy screen, the rail DOCKS and consumes page space (pushes
|
|
330
|
+
content) instead of covering it — so the board stays visible beside it and
|
|
331
|
+
clicking a comment reveals the highlighted item in the page. No scrim while
|
|
332
|
+
docked. Narrow screens fall back to an overlay + scrim (no room to dock). */
|
|
333
|
+
@media (min-width: 980px) {
|
|
279
334
|
.ann-rail-scrim { display: none !important; }
|
|
280
335
|
body.ann-rail-open { padding-right: 340px; }
|
|
281
336
|
}
|
package/src/ui/annotate.js
CHANGED
|
@@ -367,6 +367,14 @@
|
|
|
367
367
|
if (railOpen) closeRail();
|
|
368
368
|
else openRail();
|
|
369
369
|
}
|
|
370
|
+
// Briefly flash the floating Comments button (used when a comment is added so
|
|
371
|
+
// the user sees it landed, without auto-opening the rail / shifting layout).
|
|
372
|
+
function pulseToggle() {
|
|
373
|
+
if (!dom || !dom.railToggle) return;
|
|
374
|
+
dom.railToggle.classList.remove('pulse');
|
|
375
|
+
void dom.railToggle.offsetWidth; // reflow so the animation restarts
|
|
376
|
+
dom.railToggle.classList.add('pulse');
|
|
377
|
+
}
|
|
370
378
|
// Sync the toggle badge + rail count with the live list; hide the whole rail
|
|
371
379
|
// affordance when there are no comments.
|
|
372
380
|
function refreshRailChrome() {
|
|
@@ -486,13 +494,57 @@
|
|
|
486
494
|
dom.pop.replaceChildren();
|
|
487
495
|
}
|
|
488
496
|
|
|
497
|
+
// ---------- delete a comment (with confirm) ----------
|
|
498
|
+
// The × that used to delete a comment read as "close the popup". Delete is now
|
|
499
|
+
// a distinct trash button that opens a confirm modal; "Don't ask again"
|
|
500
|
+
// (default OFF) suppresses future confirms for the session (persisted best-effort).
|
|
501
|
+
const ICON_TRASH =
|
|
502
|
+
'<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
503
|
+
'<path d="M2.5 4h11M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/></svg>';
|
|
504
|
+
const DEL_SKIP_KEY = 'relay-ann-del-skip';
|
|
505
|
+
let delConfirmSkip = false;
|
|
506
|
+
try { delConfirmSkip = localStorage.getItem(DEL_SKIP_KEY) === '1'; } catch { /* sandbox: no storage */ }
|
|
507
|
+
|
|
508
|
+
function requestDelete(id, after) {
|
|
509
|
+
const done = () => { removeAnnotation(id); if (after) after(); };
|
|
510
|
+
if (delConfirmSkip) { done(); return; }
|
|
511
|
+
showDeleteConfirm(done);
|
|
512
|
+
}
|
|
513
|
+
function showDeleteConfirm(onConfirm) {
|
|
514
|
+
const scrim = el('div', { class: 'ann-confirm-scrim' });
|
|
515
|
+
const skip = el('input', { type: 'checkbox' });
|
|
516
|
+
const cancel = el('button', { class: 'ann-confirm-cancel', type: 'button' }, 'Cancel');
|
|
517
|
+
const del = el('button', { class: 'ann-confirm-del', type: 'button' }, 'Delete');
|
|
518
|
+
const close = () => { scrim.remove(); document.removeEventListener('keydown', onKey, true); };
|
|
519
|
+
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
|
|
520
|
+
cancel.addEventListener('click', close);
|
|
521
|
+
scrim.addEventListener('mousedown', (e) => { if (e.target === scrim) close(); });
|
|
522
|
+
del.addEventListener('click', () => {
|
|
523
|
+
if (skip.checked) { delConfirmSkip = true; try { localStorage.setItem(DEL_SKIP_KEY, '1'); } catch { /* sandbox */ } }
|
|
524
|
+
close();
|
|
525
|
+
onConfirm();
|
|
526
|
+
});
|
|
527
|
+
scrim.append(el('div', { class: 'ann-confirm', role: 'alertdialog', 'aria-label': 'Delete comment' },
|
|
528
|
+
el('div', { class: 'ann-confirm-title' }, 'Delete this comment?'),
|
|
529
|
+
el('div', { class: 'ann-confirm-msg' }, 'This removes the comment and its replies. It can’t be undone.'),
|
|
530
|
+
el('label', { class: 'ann-confirm-skip' }, skip, el('span', {}, 'Don’t ask again')),
|
|
531
|
+
el('div', { class: 'ann-confirm-actions' }, cancel, del)
|
|
532
|
+
));
|
|
533
|
+
document.body.append(scrim);
|
|
534
|
+
document.addEventListener('keydown', onKey, true);
|
|
535
|
+
setTimeout(() => { try { cancel.focus(); } catch { /* ignore */ } }, 0);
|
|
536
|
+
}
|
|
537
|
+
|
|
489
538
|
function openPopover(info, anchorEl) {
|
|
490
539
|
if (disabled) return;
|
|
491
540
|
ensureDom();
|
|
492
541
|
closePopover();
|
|
493
542
|
hidePin();
|
|
494
543
|
const pop = dom.pop;
|
|
495
|
-
|
|
544
|
+
// Header: target label + an explicit CLOSE button, so × always means "close".
|
|
545
|
+
const popClose = el('button', { class: 'ann-pop-close', type: 'button', title: 'Close', 'aria-label': 'Close' }, '×');
|
|
546
|
+
popClose.addEventListener('click', closePopover);
|
|
547
|
+
pop.replaceChildren(el('div', { class: 'ann-pop-head' }, el('div', { class: 'ann-pop-label' }, humanize(info.target)), popClose));
|
|
496
548
|
|
|
497
549
|
// Existing comments on this exact target, rendered as threads: author
|
|
498
550
|
// chip + time + delete, the comment text, its replies indented below,
|
|
@@ -502,11 +554,9 @@
|
|
|
502
554
|
const renderExisting = () => {
|
|
503
555
|
existingWrap.replaceChildren();
|
|
504
556
|
for (const a of matching(info)) {
|
|
505
|
-
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' }
|
|
506
|
-
del.
|
|
507
|
-
|
|
508
|
-
renderExisting();
|
|
509
|
-
});
|
|
557
|
+
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' });
|
|
558
|
+
del.innerHTML = ICON_TRASH;
|
|
559
|
+
del.addEventListener('click', () => requestDelete(a.id, renderExisting));
|
|
510
560
|
const thread = el('div', { class: 'ann-thread' },
|
|
511
561
|
el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), del),
|
|
512
562
|
el('div', { class: 'ann-pop-text' }, a.text)
|
|
@@ -601,7 +651,9 @@
|
|
|
601
651
|
}
|
|
602
652
|
}
|
|
603
653
|
changed();
|
|
604
|
-
|
|
654
|
+
// Don't auto-open the rail — that shifts the board layout. Just flash the
|
|
655
|
+
// floating Comments button so the user notices, and opens it when they want.
|
|
656
|
+
pulseToggle();
|
|
605
657
|
}
|
|
606
658
|
|
|
607
659
|
// Append a user reply to a top-level annotation's thread (cap 50, like the
|
|
@@ -709,11 +761,9 @@
|
|
|
709
761
|
// The rail has its own header chrome; standalone summaries get a heading.
|
|
710
762
|
if (!isRail) target.append(el('h3', { class: 'ann-sum-head' }, `Comments (${annotations.length})`));
|
|
711
763
|
for (const a of annotations) {
|
|
712
|
-
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' }
|
|
713
|
-
del.
|
|
714
|
-
|
|
715
|
-
removeAnnotation(a.id);
|
|
716
|
-
});
|
|
764
|
+
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' });
|
|
765
|
+
del.innerHTML = ICON_TRASH;
|
|
766
|
+
del.addEventListener('click', (e) => { e.stopPropagation(); requestDelete(a.id); });
|
|
717
767
|
// Thread meta: reply count, plus an "agent" chip when the latest entry
|
|
718
768
|
// in the thread (last reply, or the comment itself) is agent-authored.
|
|
719
769
|
const replyCount = Array.isArray(a.replies) ? a.replies.length : 0;
|
package/src/ui/app.js
CHANGED
|
@@ -597,7 +597,7 @@
|
|
|
597
597
|
}
|
|
598
598
|
if (q.other) {
|
|
599
599
|
otherRadio = el('input', { type: 'radio', name: q.id });
|
|
600
|
-
const text = el('
|
|
600
|
+
const text = el('textarea', { class: 'otherinput', rows: '2', placeholder: 'your own answer…' });
|
|
601
601
|
text.value = (state.other[q.id] && state.other[q.id].text) || '';
|
|
602
602
|
otherRadio.checked = otherOn();
|
|
603
603
|
const ensureOther = () => state.other[q.id] || (state.other[q.id] = { on: false, text: text.value });
|
|
@@ -666,7 +666,7 @@
|
|
|
666
666
|
if (q.other) {
|
|
667
667
|
const oth = state.other[q.id];
|
|
668
668
|
const box = el('input', { type: 'checkbox' });
|
|
669
|
-
const text = el('
|
|
669
|
+
const text = el('textarea', { class: 'otherinput', rows: '2', placeholder: 'your own answer…' });
|
|
670
670
|
box.checked = Boolean(oth && oth.on);
|
|
671
671
|
text.value = (oth && oth.text) || '';
|
|
672
672
|
const sync = () => {
|
package/src/ui/style.css
CHANGED
|
@@ -211,6 +211,8 @@ input[type="text"]:focus, textarea:focus {
|
|
|
211
211
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
212
212
|
}
|
|
213
213
|
textarea { min-height: 90px; resize: vertical; }
|
|
214
|
+
/* the inline "Other" free-text is a compact 2-row textarea, not a tall one */
|
|
215
|
+
.otherbox .otherinput { min-height: 2.6em; padding: 8px 12px; }
|
|
214
216
|
|
|
215
217
|
.qnotewrap { margin-top: 10px; }
|
|
216
218
|
.qnotewrap .qnote { font-size: 0.88rem; padding: 8px 12px; min-height: 52px; resize: vertical; }
|