@khanglvm/relay 0.15.0 → 0.16.0

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/src/server.js CHANGED
@@ -5,13 +5,28 @@ import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
8
+ import {
9
+ loadBoard,
10
+ saveBoard,
11
+ saveRunning,
12
+ removeRunning,
13
+ loadPref,
14
+ savePref,
15
+ saveBoardArtifact,
16
+ artifactDirPath,
17
+ } from './store.js';
9
18
  import { openUrl } from './open.js';
10
19
  import { assertSpecReady } from './spec.js';
11
20
 
12
21
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
13
22
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
14
23
  const VENDOR_DIR = path.join(PKG_ROOT, 'vendor');
24
+ const ARTIFACT_MAX_BYTES = 4 * 1024 * 1024;
25
+ const ARTIFACT_MIMES = {
26
+ 'image/png': 'png',
27
+ 'image/jpeg': 'jpg',
28
+ 'image/webp': 'webp',
29
+ };
15
30
 
16
31
  const escapeHtml = (s) =>
17
32
  s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -262,7 +277,51 @@ function sanitizeReplies(value) {
262
277
  // Drops anything that isn't a well-formed annotation object; caps at 500.
263
278
  // Each annotation may carry an optional author ('user'|'agent', default
264
279
  // 'user') and a threaded replies array (validated + capped at 50).
265
- function sanitizeAnnotations(value) {
280
+ function sanitizeAnnotationCrop(value, boardId) {
281
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !boardId) return null;
282
+ const rawPath = typeof value.path === 'string' ? value.path : '';
283
+ if (!rawPath) return null;
284
+ const base = path.resolve(artifactDirPath(boardId)) + path.sep;
285
+ const resolved = path.resolve(rawPath);
286
+ if (!resolved.startsWith(base)) return null;
287
+ let stat;
288
+ try { stat = fs.statSync(resolved); } catch { return null; }
289
+ if (!stat.isFile() || stat.size < 1 || stat.size > ARTIFACT_MAX_BYTES) return null;
290
+ const mime = ARTIFACT_MIMES[value.mime] ? value.mime : 'image/png';
291
+ return {
292
+ path: resolved,
293
+ name: path.basename(resolved),
294
+ mime,
295
+ bytes: stat.size,
296
+ width: Math.max(1, Math.min(10000, Number.parseInt(value.width, 10) || 1)),
297
+ height: Math.max(1, Math.min(10000, Number.parseInt(value.height, 10) || 1)),
298
+ };
299
+ }
300
+
301
+ function sanitizeAnnotationTarget(value, boardId) {
302
+ const clean = { ...value };
303
+ delete clean.cropDataUrl;
304
+ if (clean.kind !== 'image-region') {
305
+ delete clean.crop;
306
+ return clean;
307
+ }
308
+ const unit = (v) => Math.max(0, Math.min(1, Number(v) || 0));
309
+ clean.x = unit(clean.x);
310
+ clean.y = unit(clean.y);
311
+ clean.w = unit(clean.w);
312
+ clean.h = unit(clean.h);
313
+ if (clean.x + clean.w > 1) clean.w = 1 - clean.x;
314
+ if (clean.y + clean.h > 1) clean.h = 1 - clean.y;
315
+ clean.label = limitString(clean.label, 200);
316
+ if (clean.side === 'before' || clean.side === 'after') clean.side = clean.side;
317
+ else delete clean.side;
318
+ const crop = sanitizeAnnotationCrop(clean.crop, boardId);
319
+ if (crop) clean.crop = crop;
320
+ else delete clean.crop;
321
+ return clean;
322
+ }
323
+
324
+ function sanitizeAnnotations(value, boardId) {
266
325
  if (!Array.isArray(value)) return [];
267
326
  const out = [];
268
327
  for (const a of value) {
@@ -270,7 +329,11 @@ function sanitizeAnnotations(value) {
270
329
  if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
271
330
  if (typeof a.text !== 'string' || a.text.length > 5000) continue;
272
331
  if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
273
- const clean = { ...a, author: a.author === 'agent' ? 'agent' : 'user' };
332
+ const clean = {
333
+ ...a,
334
+ target: sanitizeAnnotationTarget(a.target, boardId),
335
+ author: a.author === 'agent' ? 'agent' : 'user',
336
+ };
274
337
  if (a.replies !== undefined) clean.replies = sanitizeReplies(a.replies);
275
338
  out.push(clean);
276
339
  }
@@ -628,6 +691,14 @@ function readBody(req, limit = 5 * 1024 * 1024) {
628
691
  });
629
692
  }
630
693
 
694
+ function artifactBytesMatch(mime, bytes) {
695
+ if (!Buffer.isBuffer(bytes) || bytes.length < 4) return false;
696
+ if (mime === 'image/png') return bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
697
+ if (mime === 'image/jpeg') return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9;
698
+ if (mime === 'image/webp') return bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP';
699
+ return false;
700
+ }
701
+
631
702
  // Push-wake: run the agent's own local shell command after a board finishes.
632
703
  // The full result JSON is written to the command's stdin; RLY_BOARD_ID /
633
704
  // RLY_STATUS / RLY_URL are exported. Failures are swallowed (best effort) —
@@ -673,7 +744,7 @@ function runOnResult(cmd, result, { quiet = false } = {}) {
673
744
  // (submitted / acknowledged / timeout / cancelled). The result is also
674
745
  // persisted into the board record so `rly wait` / `rly result` can read it
675
746
  // from another process.
676
- export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, quiet = false, keepAliveOnTimeout = false }) {
747
+ export async function runBoard({ id, port = 0, open = true, timeoutSec = 86400, quiet = false, keepAliveOnTimeout = false }) {
677
748
  const record = loadBoard(id);
678
749
  if (!record) throw new Error(`board ${id} not found`);
679
750
  const spec = record.spec;
@@ -933,7 +1004,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
933
1004
  const block = findHtmlBlock(record.spec, blockId);
934
1005
  if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
935
1006
  const access = accessFor(req, reqUrl);
936
- sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true));
1007
+ sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true && record.spec.responseRequired !== false));
937
1008
  } else if (req.method === 'GET' && pathname.startsWith('/img/b/')) {
938
1009
  // Embedded image bytes (image blocks authored from local files).
939
1010
  const blockId = decodeURIComponent(pathname.slice('/img/b/'.length));
@@ -942,6 +1013,32 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
942
1013
  if (!m) return sendJson(res, 404, { error: `no embedded image block "${blockId}"` });
943
1014
  res.writeHead(200, { 'content-type': m[1], 'cache-control': 'no-store' });
944
1015
  res.end(Buffer.from(m[2], 'base64'));
1016
+ } else if (req.method === 'POST' && pathname === '/api/artifact') {
1017
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not collect feedback' });
1018
+ if (!sameOrigin(req, actualPort, [advertisedHost])) return sendJson(res, 403, { error: 'cross-origin artifact upload rejected' });
1019
+ const access = accessFor(req, reqUrl);
1020
+ if (!access.canComment) return sendJson(res, 403, { error: 'this share cannot add comments' });
1021
+ const body = JSON.parse((await readBody(req, 6 * 1024 * 1024)) || '{}');
1022
+ const blockId = typeof body.blockId === 'string' ? body.blockId : '';
1023
+ const block = findBlock(record.spec, blockId, 'image') || findBlock(record.spec, blockId, 'compare');
1024
+ if (!block) return sendJson(res, 404, { error: `no image or comparison block "${blockId}"` });
1025
+ const match = typeof body.dataUrl === 'string'
1026
+ ? body.dataUrl.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/)
1027
+ : null;
1028
+ if (!match || !ARTIFACT_MIMES[match[1]]) return sendJson(res, 400, { error: 'artifact must be a PNG, JPEG, or WebP data URL' });
1029
+ const bytes = Buffer.from(match[2], 'base64');
1030
+ if (bytes.length < 1 || bytes.length > ARTIFACT_MAX_BYTES) return sendJson(res, 400, { error: 'artifact image is empty or larger than 4 MB' });
1031
+ if (!artifactBytesMatch(match[1], bytes)) return sendJson(res, 400, { error: 'artifact bytes do not match the declared image type' });
1032
+ const target = saveBoardArtifact(record.id, bytes, ARTIFACT_MIMES[match[1]]);
1033
+ sendJson(res, 200, {
1034
+ ok: true,
1035
+ path: target,
1036
+ name: path.basename(target),
1037
+ mime: match[1],
1038
+ bytes: bytes.length,
1039
+ width: Math.max(1, Math.min(10000, Number.parseInt(body.width, 10) || 1)),
1040
+ height: Math.max(1, Math.min(10000, Number.parseInt(body.height, 10) || 1)),
1041
+ });
945
1042
  } else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/video/b/')) {
946
1043
  // Local video bytes, Range-streamed so the <video> element can seek.
947
1044
  const blockId = decodeURIComponent(pathname.slice('/video/b/'.length));
@@ -958,14 +1055,14 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
958
1055
  // Legacy alias → the board's first html block.
959
1056
  const block = firstBoardHtml(record.spec);
960
1057
  const access = accessFor(req, reqUrl);
961
- sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
1058
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true && record.spec.responseRequired !== false));
962
1059
  } else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
963
1060
  const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
964
1061
  const q = record.spec.questions.find((q) => q.id === qid);
965
1062
  if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
966
1063
  const block = firstQuestionHtml(q);
967
1064
  const access = accessFor(req, reqUrl);
968
- sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
1065
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true && record.spec.responseRequired !== false));
969
1066
  } else if (req.method === 'POST' && pathname === '/api/pref') {
970
1067
  const body = JSON.parse((await readBody(req)) || '{}');
971
1068
  if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
@@ -998,6 +1095,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
998
1095
  if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
999
1096
  sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
1000
1097
  } else if (req.method === 'POST' && pathname === '/api/draft') {
1098
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not collect feedback' });
1001
1099
  const access = accessFor(req, reqUrl);
1002
1100
  if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
1003
1101
  if (!access.canEditAnswers && !access.canComment && !access.canEditBlocks) {
@@ -1011,7 +1109,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1011
1109
  answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
1012
1110
  comment: typeof body.comment === 'string' ? body.comment : '',
1013
1111
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1014
- annotations: sanitizeAnnotations(body.annotations),
1112
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1015
1113
  blockEdits: {},
1016
1114
  draftRev: priorRev + 1,
1017
1115
  updatedAt: new Date().toISOString(),
@@ -1022,7 +1120,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1022
1120
  answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
1023
1121
  comment: typeof body.comment === 'string' ? body.comment : '',
1024
1122
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1025
- annotations: sanitizeAnnotations(body.annotations),
1123
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1026
1124
  blockEdits: sanitizeBlockEdits(body.blockEdits),
1027
1125
  updatedAt: new Date().toISOString(),
1028
1126
  };
@@ -1034,6 +1132,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1034
1132
  : draftRev;
1035
1133
  sendJson(res, 200, { ok: true, draftRev: accessDraftRev });
1036
1134
  } else if (req.method === 'POST' && pathname === '/api/submit') {
1135
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not accept submissions' });
1037
1136
  const access = accessFor(req, reqUrl);
1038
1137
  if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
1039
1138
  if (!access.canSubmit) return sendJson(res, 403, { error: 'this share is read only' });
@@ -1052,7 +1151,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1052
1151
  skipped,
1053
1152
  comment: typeof body.comment === 'string' ? body.comment : '',
1054
1153
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1055
- annotations: sanitizeAnnotations(body.annotations),
1154
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1056
1155
  blockEdits: {},
1057
1156
  submittedAt: new Date().toISOString(),
1058
1157
  };
@@ -1076,7 +1175,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1076
1175
  skipped,
1077
1176
  comment: typeof body.comment === 'string' ? body.comment : '',
1078
1177
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1079
- annotations: sanitizeAnnotations(body.annotations),
1178
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1080
1179
  blockEdits: sanitizeBlockEdits(body.blockEdits),
1081
1180
  });
1082
1181
  } else if (req.method === 'GET' && pathname === '/api/share') {
package/src/spec.js CHANGED
@@ -853,6 +853,7 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
853
853
  allowPartial: raw.allowPartial !== false,
854
854
  note: raw.note !== false,
855
855
  autoClose: raw.autoClose !== false,
856
+ responseRequired: raw.responseRequired !== false,
856
857
  questions: [],
857
858
  submitLabel: '',
858
859
  };
@@ -997,6 +998,9 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
997
998
  if (!spec.questions.length && !spec.blocks.length) {
998
999
  throw new CliError('Spec needs "questions" and/or "blocks"/"html" — nothing to show.');
999
1000
  }
1001
+ if (!spec.responseRequired && spec.questions.length) {
1002
+ throw new CliError('responseRequired:false is display-only and cannot contain questions. Use responseRequired:true when answers are needed.');
1003
+ }
1000
1004
  spec.submitLabel = asStr(raw.submitLabel).trim() || (spec.questions.length ? 'Submit' : 'Acknowledge');
1001
1005
  return spec;
1002
1006
  }
@@ -1126,6 +1130,7 @@ export const SPEC_SCHEMA = {
1126
1130
  allowPartial: { type: 'boolean', default: true, description: 'When true, users may submit with unanswered questions (returned in "skipped").' },
1127
1131
  note: { type: 'boolean', default: true, description: 'Show an optional free-text note box ("Anything else?") returned as "comment".' },
1128
1132
  autoClose: { type: 'boolean', default: true, description: 'Try to close the browser tab automatically after submit.' },
1133
+ responseRequired: { type: 'boolean', default: true, description: 'Set false for a display-only board: no questions, note box, comments, or Submit/Acknowledge action are shown, and the presenting agent should continue immediately.' },
1129
1134
  submitLabel: { type: 'string', description: 'Submit button label. Defaults: "Submit", or "Acknowledge" when there are no questions.' },
1130
1135
  questions: {
1131
1136
  type: 'array',
package/src/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import crypto from 'node:crypto';
4
5
 
5
6
  // All state lives under one dir so multiple boards/instances never collide.
6
7
  // RLY_HOME override exists for tests and sandboxed agents.
@@ -21,6 +22,19 @@ export function newId() {
21
22
 
22
23
  const boardPath = (id) => path.join(BOARDS_DIR, `${id}.json`);
23
24
  const runningPath = (id) => path.join(RUNNING_DIR, `${id}.json`);
25
+ export const artifactDirPath = (id) => path.join(BOARDS_DIR, `${id}.artifacts`);
26
+
27
+ export function saveBoardArtifact(id, bytes, ext = 'png') {
28
+ ensureDirs();
29
+ if (!/^b-[a-z0-9]+$/i.test(String(id))) throw new Error('invalid board id');
30
+ const cleanExt = /^(?:png|jpe?g|webp)$/i.test(String(ext)) ? String(ext).toLowerCase() : 'png';
31
+ const dir = artifactDirPath(id);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const file = `region-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}.${cleanExt}`;
34
+ const target = path.join(dir, file);
35
+ fs.writeFileSync(target, bytes);
36
+ return target;
37
+ }
24
38
 
25
39
  export function createBoard(spec) {
26
40
  ensureDirs();
@@ -72,6 +86,7 @@ export function deleteBoard(id) {
72
86
  try {
73
87
  fs.unlinkSync(boardPath(id));
74
88
  try { fs.unlinkSync(path.join(BOARDS_DIR, `${id}.result.json`)); } catch { /* no sidecar */ }
89
+ try { fs.rmSync(artifactDirPath(id), { recursive: true, force: true }); } catch { /* no artifacts */ }
75
90
  return true;
76
91
  } catch {
77
92
  return false;
@@ -48,6 +48,11 @@
48
48
  return truncate(t.label || 'Image', 50);
49
49
  case 'image-point':
50
50
  return (t.label ? truncate(t.label, 30) + ' · ' : 'Image · ') + 'pin ' + Math.round((t.x || 0) * 100) + '%, ' + Math.round((t.y || 0) * 100) + '%';
51
+ case 'image-region': {
52
+ const source = t.side ? t.side.charAt(0).toUpperCase() + t.side.slice(1) + ' · ' : '';
53
+ const area = `${Math.round((t.w || 0) * 100)}×${Math.round((t.h || 0) * 100)}% area`;
54
+ return (t.label ? truncate(t.label, 30) + ' · ' : 'Image · ') + source + area;
55
+ }
51
56
  case 'table-cell': {
52
57
  let s = `Table · row ${(Number(t.row) || 0) + 1} · ${t.col}`;
53
58
  if (t.value !== undefined && t.value !== null && t.value !== '') s += ` — “${truncate(t.value, 30)}”`;
@@ -109,8 +114,10 @@
109
114
  let pinTimer = null;
110
115
  let pinEntry = null;
111
116
  let popOpen = false;
112
- let popScrollY = 0;
113
117
  let popSave = null;
118
+ let popAnchorEl = null;
119
+ let popPositionFrame = 0;
120
+ const commentDrafts = new Map();
114
121
  // Frozen by the host (e.g. relay's connection-lost block): suppress every way
115
122
  // to START a comment, so the user can't type feedback that won't be saved.
116
123
  let disabled = false;
@@ -341,16 +348,21 @@
341
348
  });
342
349
 
343
350
  // Capture-phase: also fires for nested scroll containers (mermaid pane…).
351
+ // Keep an open composer re-anchored instead of closing it: scrolling is a
352
+ // normal review action, and DOM-only draft text must never disappear merely
353
+ // because the user looked elsewhere on the board.
344
354
  window.addEventListener('scroll', (e) => {
345
355
  hidePin();
346
356
  hideSelBtn();
347
357
  if (!popOpen) return;
348
358
  if (dom.pop.contains(e.target)) return; // textarea scrolling inside
349
- const isPage = e.target === document || e.target === document.documentElement || e.target === document.body;
350
- if (!isPage || Math.abs(window.scrollY - popScrollY) > 80) closePopover();
359
+ schedulePositionPopover();
351
360
  }, true);
352
361
 
353
- window.addEventListener('resize', scheduleBadgeRefresh);
362
+ window.addEventListener('resize', () => {
363
+ scheduleBadgeRefresh();
364
+ schedulePositionPopover();
365
+ });
354
366
  }
355
367
 
356
368
  // ---------- comments rail ----------
@@ -491,10 +503,33 @@
491
503
  }
492
504
 
493
505
  // ---------- popover ----------
506
+ function positionPopover() {
507
+ popPositionFrame = 0;
508
+ if (!popOpen || !dom || !popAnchorEl || !popAnchorEl.isConnected) return;
509
+ const pop = dom.pop;
510
+ const rect = popAnchorEl.getBoundingClientRect();
511
+ const pw = pop.offsetWidth;
512
+ const ph = pop.offsetHeight;
513
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
514
+ let top = rect.bottom + 8;
515
+ if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
516
+ top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
517
+ pop.style.left = left + 'px';
518
+ pop.style.top = top + 'px';
519
+ }
520
+
521
+ function schedulePositionPopover() {
522
+ if (!popOpen || popPositionFrame) return;
523
+ popPositionFrame = requestAnimationFrame(positionPopover);
524
+ }
525
+
494
526
  function closePopover() {
495
527
  if (!popOpen) return;
496
528
  popOpen = false;
497
529
  popSave = null;
530
+ popAnchorEl = null;
531
+ if (popPositionFrame) cancelAnimationFrame(popPositionFrame);
532
+ popPositionFrame = 0;
498
533
  dom.pop.style.display = 'none';
499
534
  dom.pop.replaceChildren();
500
535
  }
@@ -548,6 +583,7 @@
548
583
  if (disabled) return;
549
584
  ensureDom();
550
585
  const hasExisting = matching(info).length > 0;
586
+ const draftKey = sigOf(info.blockId, info.target);
551
587
  // Read-only viewers can open existing threads from their badges, but a
552
588
  // target with no feedback has nothing to show and must not expose an empty
553
589
  // add-comment dialog.
@@ -632,34 +668,35 @@
632
668
  pop.append(existingWrap);
633
669
 
634
670
  const ta = el('textarea', { class: 'ann-ta', placeholder: 'Add a comment…', rows: '3' });
671
+ ta.value = commentDrafts.get(draftKey) || '';
672
+ ta.addEventListener('input', () => {
673
+ if (ta.value) commentDrafts.set(draftKey, ta.value);
674
+ else commentDrafts.delete(draftKey);
675
+ });
635
676
  const save = el('button', { class: 'ann-save', type: 'button' }, 'Save');
636
677
  const cancel = el('button', { class: 'ann-cancel', type: 'button' }, 'Cancel');
637
678
  popSave = () => {
638
679
  const text = ta.value.trim();
639
680
  if (text) addAnnotation(info, text);
681
+ commentDrafts.delete(draftKey);
640
682
  closePopover();
641
683
  };
642
684
  save.addEventListener('click', () => popSave && popSave());
643
- cancel.addEventListener('click', closePopover);
685
+ cancel.addEventListener('click', () => {
686
+ commentDrafts.delete(draftKey);
687
+ closePopover();
688
+ });
644
689
  if (permissions.add) pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
645
690
 
646
- // Position: prefer below the anchor, flip above when out of room,
647
- // clamp to the viewport with an 8px margin. Fixed positioning, so we
648
- // recompute on open and simply close on big scrolls.
691
+ // Position: prefer below the anchor, flip above when out of room, and clamp
692
+ // to the viewport. Scroll/resize re-run this through one animation frame so
693
+ // the composer tracks browser scrolling smoothly without losing its draft.
649
694
  pop.style.display = 'block';
650
695
  pop.style.visibility = 'hidden';
651
- const rect = anchorEl.getBoundingClientRect();
652
- const pw = pop.offsetWidth;
653
- const ph = pop.offsetHeight;
654
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
655
- let top = rect.bottom + 8;
656
- if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
657
- top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
658
- pop.style.left = left + 'px';
659
- pop.style.top = top + 'px';
660
- pop.style.visibility = '';
661
696
  popOpen = true;
662
- popScrollY = window.scrollY;
697
+ popAnchorEl = anchorEl;
698
+ positionPopover();
699
+ pop.style.visibility = '';
663
700
  if (permissions.add) ta.focus();
664
701
  else popClose.focus();
665
702
  }
@@ -921,6 +958,7 @@
921
958
  registered = [];
922
959
  textRoots.clear();
923
960
  highlightMap.clear();
961
+ commentDrafts.clear();
924
962
  if (dom) {
925
963
  dom.pin.remove();
926
964
  dom.selBtn.remove();
package/src/ui/app.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  const boot = JSON.parse(document.getElementById('boot').textContent);
5
5
  const spec = boot.spec;
6
+ const responseRequired = spec.responseRequired !== false;
6
7
  const access = boot.access || {
7
8
  role: 'owner',
8
9
  canShare: true,
@@ -29,7 +30,7 @@
29
30
  if (access.reviewSessionId) headers['x-relay-review-session'] = access.reviewSessionId;
30
31
  return headers;
31
32
  }
32
- const canPersistFeedback = Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
33
+ const canPersistFeedback = responseRequired && Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
33
34
 
34
35
  // ---------- helpers ----------
35
36
  function el(tag, attrs = {}, ...children) {
@@ -541,7 +542,7 @@
541
542
  // RelayAnnotate owns the live annotation list; mirror it into state on every
542
543
  // change so payload()/autosave/submit carry it exactly like answers.
543
544
  const Annotate = typeof window.RelayAnnotate !== 'undefined' ? window.RelayAnnotate : null;
544
- if (Annotate) {
545
+ if (Annotate && responseRequired) {
545
546
  Annotate.init({
546
547
  initial: state.annotations,
547
548
  permissions: {
@@ -558,6 +559,8 @@
558
559
  if (window.__relayBroadcastCounts) window.__relayBroadcastCounts();
559
560
  },
560
561
  });
562
+ } else if (Annotate && !responseRequired && typeof Annotate.teardown === 'function') {
563
+ Annotate.teardown();
561
564
  }
562
565
 
563
566
  // Editable-mermaid: record (or clear) the user's edit for a block, then
@@ -575,6 +578,17 @@
575
578
  onBlockEdit(d.blockId, d.value);
576
579
  });
577
580
 
581
+ async function saveRegionArtifact(artifact) {
582
+ const res = await fetch('/api/artifact', {
583
+ method: 'POST',
584
+ headers: authHeaders({ 'content-type': 'application/json' }),
585
+ body: JSON.stringify(artifact),
586
+ });
587
+ const body = await res.json().catch(() => null);
588
+ if (!res.ok || !body || !body.path) throw new Error(body && body.error ? body.error : 'could not save image crop');
589
+ return body;
590
+ }
591
+
578
592
  // ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
579
593
  // the editable-mermaid plumbing (edits map + onBlockEdit callback).
580
594
  function blockCtx(questionId) {
@@ -586,8 +600,9 @@
586
600
  return '/html/b/' + encodeURIComponent(blockId) + '?' + params.toString();
587
601
  },
588
602
  questionId: questionId == null ? null : questionId,
589
- annotate: Annotate,
590
- canComment: access.canComment !== false,
603
+ annotate: responseRequired ? Annotate : null,
604
+ canComment: responseRequired && access.canComment !== false,
605
+ saveArtifact: responseRequired && access.canComment !== false ? saveRegionArtifact : null,
591
606
  edits: state.blockEdits,
592
607
  onBlockEdit,
593
608
  canEditBlocks: access.canEditBlocks !== false,
@@ -1145,7 +1160,7 @@
1145
1160
  app.append(card);
1146
1161
  });
1147
1162
 
1148
- if (spec.note) {
1163
+ if (spec.note && responseRequired) {
1149
1164
  const note = el('textarea', { placeholder: 'optional note back to the agent…' });
1150
1165
  note.value = state.comment || '';
1151
1166
  note.addEventListener('input', () => {
@@ -1167,6 +1182,10 @@
1167
1182
  const hint = el('span', { class: 'hint' },
1168
1183
  QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
1169
1184
  const submitbar = el('div', { class: 'submitbar' }, submitBtn, hint, saveEl);
1185
+ if (!responseRequired) {
1186
+ submitbar.replaceChildren(el('span', { class: 'hint' }, 'Display only · no response requested'));
1187
+ submitbar.classList.add('display-only');
1188
+ }
1170
1189
  app.append(submitbar);
1171
1190
 
1172
1191
  function copyText(text, btn) {
@@ -1415,7 +1434,7 @@
1415
1434
  // parent → {relay:'annotate-counts', counts:{ref:n}} it draws badges
1416
1435
  // We own the annotation state, popover, and the submitted result; the iframe
1417
1436
  // owns hover/pin/badges over its own (cross-origin) DOM.
1418
- if (Annotate) {
1437
+ if (Annotate && responseRequired) {
1419
1438
  const frameOf = (source) => {
1420
1439
  for (const f of document.querySelectorAll('iframe.viz')) {
1421
1440
  if (f.contentWindow === source) return f;
package/src/ui/blocks.css CHANGED
@@ -648,26 +648,35 @@
648
648
  }
649
649
  .blk-imagewrap .blk-img {
650
650
  display: block;
651
- max-width: 100%;
651
+ width: 100%;
652
+ max-width: none;
652
653
  height: auto;
653
654
  border-radius: 6px;
654
655
  object-fit: contain;
655
656
  object-position: left top;
656
657
  }
658
+ .blk-imgstage {
659
+ position: relative;
660
+ width: 100%;
661
+ max-width: 100%;
662
+ line-height: 0;
663
+ }
657
664
  /* In full-screen a per-block height must never keep the image small — that
658
665
  height only shapes the inline preview; the detail view needs every pixel.
659
666
  (Belt-and-suspenders alongside the viewer's reapply() on full-screen toggle.) */
660
667
  .blk-imagewrap.blk-full .blk-img { max-height: none !important; }
661
668
 
662
669
  /* ---------- viewer controls (full-screen) ---------- */
663
- /* The full-screen button lives in a reserved strip ABOVE the visual (user
664
- feedback: never overlay the content). padding-top reserves the strip;
665
- the bar is a full-width transparent row with the button at the right. */
666
- .blk-viewer { position: relative; padding-top: 34px; }
670
+ /* The controls participate in layout above the visual and use native sticky
671
+ positioning. The previous JS counter-translation chased scrollTop one event
672
+ behind the compositor, which made the bar visibly trail trackpad scrolling. */
673
+ .blk-viewer { position: relative; }
667
674
  .blk-tools {
668
- position: absolute; top: 4px; left: 4px; right: 4px; height: 26px;
675
+ position: sticky; top: 0; left: 0; width: 100%; height: 34px;
669
676
  display: flex; gap: 2px; align-items: center; justify-content: flex-end;
670
- z-index: 5;
677
+ box-sizing: border-box; padding: 4px;
678
+ background: var(--bg-sunken); border-bottom: 1px solid var(--border);
679
+ z-index: 16;
671
680
  }
672
681
  .blk-tools button {
673
682
  background: var(--card); border: 1px solid var(--border); color: var(--fg-2);
@@ -693,6 +702,7 @@ body.blk-full-open { overflow: hidden; }
693
702
  /* detach the toolbar from the scrolling content and fix it to the viewport top */
694
703
  .blk-full > .blk-tools {
695
704
  position: fixed; top: 0; left: 0; right: 0; height: 44px;
705
+ width: auto;
696
706
  margin: 0; padding: 0 12px;
697
707
  background: var(--card); border-bottom: 1px solid var(--border);
698
708
  z-index: 51;
@@ -727,6 +737,7 @@ body.blk-full-open { overflow: hidden; }
727
737
  .blk-plantuml.blk-full > svg,
728
738
  .blk-plantuml.blk-full > .blk-plantuml-img,
729
739
  .blk-imagewrap.blk-full > .blk-img { margin: auto; }
740
+ .blk-imagewrap.blk-full > .blk-imgstage { margin: auto; max-width: none; }
730
741
 
731
742
  /* Small frame on charts + mermaid so they read as a deliberate card, matching
732
743
  the graphviz/plantuml/image blocks (already framed). Scoped to the inner
@@ -898,7 +909,7 @@ body.blk-full-open { overflow: hidden; }
898
909
  20% { box-shadow: 0 0 0 3px var(--accent); }
899
910
  }
900
911
 
901
- /* ---------- image coordinate pins (block.pins) ---------- */
912
+ /* ---------- image point + area comments ---------- */
902
913
  .blk-img-pinnable { cursor: crosshair; }
903
914
  .blk-imgpins { position: absolute; inset: 0; pointer-events: none; }
904
915
  .blk-imgpin {
@@ -912,3 +923,42 @@ body.blk-full-open { overflow: hidden; }
912
923
  display: grid; place-items: center;
913
924
  }
914
925
  .blk-imgpin:hover { filter: brightness(1.08); }
926
+ .blk-region-layer { position: absolute; inset: 0; pointer-events: none; }
927
+ .blk-region-selection,
928
+ .blk-imgregion {
929
+ position: absolute;
930
+ border: 2px solid var(--accent);
931
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
932
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.75) inset;
933
+ border-radius: 5px;
934
+ }
935
+ .blk-region-selection { display: none; z-index: 7; }
936
+ .blk-imgregion {
937
+ z-index: 6;
938
+ pointer-events: auto;
939
+ cursor: pointer;
940
+ color: var(--accent-fg);
941
+ padding: 0;
942
+ min-width: 10px;
943
+ min-height: 10px;
944
+ }
945
+ .blk-imgregion::after {
946
+ content: attr(data-count);
947
+ position: absolute; top: -11px; right: -11px;
948
+ min-width: 20px; height: 20px; padding: 0 5px;
949
+ display: grid; place-items: center;
950
+ border: 2px solid #fff; border-radius: 999px;
951
+ background: var(--accent); color: #fff;
952
+ font: 600 0.68rem/1 var(--sans);
953
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
954
+ }
955
+ .blk-region-arming { cursor: crosshair !important; }
956
+ .blk-region-selecting { cursor: crosshair !important; touch-action: none; }
957
+ .blk-region-status {
958
+ position: absolute; left: 50%; bottom: 12px; z-index: 9;
959
+ transform: translateX(-50%);
960
+ padding: 6px 10px; border-radius: 8px;
961
+ background: rgba(28,27,25,0.86); color: #fff;
962
+ font: 500 0.74rem/1.2 var(--sans);
963
+ pointer-events: none;
964
+ }