@khanglvm/relay 0.14.2 → 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/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 ----------
@@ -393,7 +405,7 @@
393
405
 
394
406
  // ---------- hover pin ----------
395
407
  function showPin(entry) {
396
- if (disabled) return;
408
+ if (disabled || !permissions.add) return;
397
409
  clearTimeout(pinTimer);
398
410
  pinEntry = entry;
399
411
  const rect = entry.el.getBoundingClientRect();
@@ -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
  }
@@ -547,6 +582,12 @@
547
582
  function openPopover(info, anchorEl) {
548
583
  if (disabled) return;
549
584
  ensureDom();
585
+ const hasExisting = matching(info).length > 0;
586
+ const draftKey = sigOf(info.blockId, info.target);
587
+ // Read-only viewers can open existing threads from their badges, but a
588
+ // target with no feedback has nothing to show and must not expose an empty
589
+ // add-comment dialog.
590
+ if (!permissions.add && !hasExisting) return;
550
591
  closePopover();
551
592
  hidePin();
552
593
  const pop = dom.pop;
@@ -586,7 +627,10 @@
586
627
  ta.focus();
587
628
  });
588
629
  // agent replies aren't editable here — only the top-level user comment
589
- const actions = el('div', { class: 'ann-thread-actions' }, a.author === 'user' ? edit : null, del);
630
+ const actions = el('div', { class: 'ann-thread-actions' },
631
+ permissions.edit && a.author === 'user' ? edit : null,
632
+ permissions.delete ? del : null
633
+ );
590
634
  const thread = el('div', { class: 'ann-thread' },
591
635
  el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), actions),
592
636
  textEl
@@ -616,7 +660,7 @@
616
660
  submitReply();
617
661
  }
618
662
  });
619
- thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
663
+ if (permissions.reply) thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
620
664
  existingWrap.append(thread);
621
665
  }
622
666
  };
@@ -624,35 +668,37 @@
624
668
  pop.append(existingWrap);
625
669
 
626
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
+ });
627
676
  const save = el('button', { class: 'ann-save', type: 'button' }, 'Save');
628
677
  const cancel = el('button', { class: 'ann-cancel', type: 'button' }, 'Cancel');
629
678
  popSave = () => {
630
679
  const text = ta.value.trim();
631
680
  if (text) addAnnotation(info, text);
681
+ commentDrafts.delete(draftKey);
632
682
  closePopover();
633
683
  };
634
684
  save.addEventListener('click', () => popSave && popSave());
635
- cancel.addEventListener('click', closePopover);
636
- pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
685
+ cancel.addEventListener('click', () => {
686
+ commentDrafts.delete(draftKey);
687
+ closePopover();
688
+ });
689
+ if (permissions.add) pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
637
690
 
638
- // Position: prefer below the anchor, flip above when out of room,
639
- // clamp to the viewport with an 8px margin. Fixed positioning, so we
640
- // 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.
641
694
  pop.style.display = 'block';
642
695
  pop.style.visibility = 'hidden';
643
- const rect = anchorEl.getBoundingClientRect();
644
- const pw = pop.offsetWidth;
645
- const ph = pop.offsetHeight;
646
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
647
- let top = rect.bottom + 8;
648
- if (top + ph > window.innerHeight - 8 && rect.top - ph - 8 >= 8) top = rect.top - ph - 8;
649
- top = Math.max(8, Math.min(top, window.innerHeight - ph - 8));
650
- pop.style.left = left + 'px';
651
- pop.style.top = top + 'px';
652
- pop.style.visibility = '';
653
696
  popOpen = true;
654
- popScrollY = window.scrollY;
655
- ta.focus();
697
+ popAnchorEl = anchorEl;
698
+ positionPopover();
699
+ pop.style.visibility = '';
700
+ if (permissions.add) ta.focus();
701
+ else popClose.focus();
656
702
  }
657
703
 
658
704
  // ---------- mutations ----------
@@ -742,7 +788,7 @@
742
788
  }
743
789
 
744
790
  function maybeShowSelBtn(rootEl, baseInfo) {
745
- if (disabled) return hideSelBtn();
791
+ if (disabled || !permissions.add) return hideSelBtn();
746
792
  const sel = window.getSelection();
747
793
  if (!sel || sel.isCollapsed || !sel.rangeCount) return hideSelBtn();
748
794
  if (!rootEl.contains(sel.anchorNode) || !rootEl.contains(sel.focusNode)) return hideSelBtn();
@@ -821,7 +867,7 @@
821
867
  el('div', { class: 'ann-sum-text' }, a.text),
822
868
  meta.length ? el('div', { class: 'ann-sum-meta' }, meta) : null
823
869
  ),
824
- del
870
+ permissions.delete ? del : null
825
871
  );
826
872
  // Clicking a row jumps to (and flashes) the matching element: a
827
873
  // registered block element, or the inline highlight for a text comment.
@@ -912,6 +958,7 @@
912
958
  registered = [];
913
959
  textRoots.clear();
914
960
  highlightMap.clear();
961
+ commentDrafts.clear();
915
962
  if (dom) {
916
963
  dom.pin.remove();
917
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,
@@ -11,6 +12,8 @@
11
12
  canComment: true,
12
13
  canEditComments: true,
13
14
  canDeleteComments: true,
15
+ canEditBlocks: true,
16
+ canFinalize: true,
14
17
  };
15
18
  if (access.role) document.documentElement.dataset.accessRole = access.role;
16
19
  const QS = spec.questions || [];
@@ -23,8 +26,11 @@
23
26
  let seenDraftRev = typeof boot.draftRev === 'number' ? boot.draftRev : 0;
24
27
 
25
28
  function authHeaders(extra = {}) {
26
- return access.token ? { ...extra, 'x-relay-share-token': access.token } : extra;
29
+ const headers = access.token ? { ...extra, 'x-relay-share-token': access.token } : { ...extra };
30
+ if (access.reviewSessionId) headers['x-relay-review-session'] = access.reviewSessionId;
31
+ return headers;
27
32
  }
33
+ const canPersistFeedback = responseRequired && Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
28
34
 
29
35
  // ---------- helpers ----------
30
36
  function el(tag, attrs = {}, ...children) {
@@ -133,14 +139,17 @@
133
139
  applyFontScale();
134
140
 
135
141
  // ---------- localStorage draft mirror ----------
136
- // Every autosave is ALSO written to localStorage, keyed by board id. This is
142
+ // Every autosave is ALSO written to localStorage, keyed by board id + access
143
+ // role/session. This keeps a reviewer side draft from leaking into a
144
+ // collaborator/read-only tab on the same LAN origin.
137
145
  // the durability layer the server file alone can't provide: if the connection
138
146
  // drops and the user keeps typing, the in-memory state is mirrored locally, so
139
147
  // even a tab reload / browser restart / a freshly opened tab on the same board
140
148
  // prefills the LATEST input instead of a blank board or a stale server save.
141
149
  // Guards (per design): newest-of-(local,server) wins; the mirror is discarded
142
150
  // if the board's spec rev changed (agent edited it); cleared on submit.
143
- const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown');
151
+ const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown') + '-' +
152
+ (access.role || 'owner') + (access.reviewSessionId ? '-' + access.reviewSessionId : '');
144
153
  function writeLocalDraft(p, updatedAt) {
145
154
  try {
146
155
  localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify({
@@ -352,7 +361,7 @@
352
361
  // to detect recovery from the Retry button / heartbeat.
353
362
  async function probeServer() {
354
363
  try {
355
- const r = await fetch('/api/status', { cache: 'no-store' });
364
+ const r = await fetch('/api/status', { cache: 'no-store', headers: authHeaders() });
356
365
  return r.ok;
357
366
  } catch {
358
367
  return false;
@@ -407,6 +416,7 @@
407
416
  for (const node of app.querySelectorAll('input, textarea, button, select')) {
408
417
  node.disabled = false;
409
418
  }
419
+ applyAccessRestrictions();
410
420
  if (lostNote) lostNote.remove();
411
421
  // Re-arm the heartbeat (it stops itself when it confirms loss) and persist
412
422
  // everything typed during the outage. saveDraft() updates the save label.
@@ -454,7 +464,7 @@
454
464
  // probe → block. Any success resets it.
455
465
  let saveFailures = 0;
456
466
  function scheduleSave() {
457
- if (submitted) return;
467
+ if (submitted || !canPersistFeedback) return;
458
468
  // Mirror to localStorage SYNCHRONOUSLY on every edit, before (and regardless
459
469
  // of) the network save. This is what survives a tab reload / crash / a new
460
470
  // tab during a connection outage — it must happen even while blocked.
@@ -465,6 +475,7 @@
465
475
  saveTimer = setTimeout(saveDraft, 450);
466
476
  }
467
477
  async function saveDraft() {
478
+ if (!canPersistFeedback) return;
468
479
  const seq = ++saveSeq;
469
480
  // Keep the local mirror current on every flush too (covers programmatic
470
481
  // saveDraft() calls that don't go through scheduleSave, e.g. recovery flush).
@@ -516,7 +527,7 @@
516
527
  if (submitted) return;
517
528
  fetch('/api/ping', {
518
529
  method: 'POST',
519
- headers: { 'content-type': 'application/json' },
530
+ headers: authHeaders({ 'content-type': 'application/json' }),
520
531
  body: JSON.stringify({
521
532
  visible: !document.hidden,
522
533
  focused: document.hasFocus(),
@@ -531,7 +542,7 @@
531
542
  // RelayAnnotate owns the live annotation list; mirror it into state on every
532
543
  // change so payload()/autosave/submit carry it exactly like answers.
533
544
  const Annotate = typeof window.RelayAnnotate !== 'undefined' ? window.RelayAnnotate : null;
534
- if (Annotate) {
545
+ if (Annotate && responseRequired) {
535
546
  Annotate.init({
536
547
  initial: state.annotations,
537
548
  permissions: {
@@ -548,6 +559,8 @@
548
559
  if (window.__relayBroadcastCounts) window.__relayBroadcastCounts();
549
560
  },
550
561
  });
562
+ } else if (Annotate && !responseRequired && typeof Annotate.teardown === 'function') {
563
+ Annotate.teardown();
551
564
  }
552
565
 
553
566
  // Editable-mermaid: record (or clear) the user's edit for a block, then
@@ -565,16 +578,34 @@
565
578
  onBlockEdit(d.blockId, d.value);
566
579
  });
567
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
+
568
592
  // ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
569
593
  // the editable-mermaid plumbing (edits map + onBlockEdit callback).
570
594
  function blockCtx(questionId) {
571
595
  return {
572
596
  theme: effectiveTheme,
573
- htmlSrc: (blockId) => '/html/b/' + encodeURIComponent(blockId) + '?theme=' + effectiveTheme(),
597
+ htmlSrc: (blockId) => {
598
+ const params = new URLSearchParams({ theme: effectiveTheme() });
599
+ if (access.token) params.set('token', access.token);
600
+ return '/html/b/' + encodeURIComponent(blockId) + '?' + params.toString();
601
+ },
574
602
  questionId: questionId == null ? null : questionId,
575
- annotate: Annotate,
603
+ annotate: responseRequired ? Annotate : null,
604
+ canComment: responseRequired && access.canComment !== false,
605
+ saveArtifact: responseRequired && access.canComment !== false ? saveRegionArtifact : null,
576
606
  edits: state.blockEdits,
577
607
  onBlockEdit,
608
+ canEditBlocks: access.canEditBlocks !== false,
578
609
  };
579
610
  }
580
611
 
@@ -807,6 +838,7 @@
807
838
  const byVal = new Map(q.options.map((o) => [o.value, o]));
808
839
  let dragFrom = null;
809
840
  function move(from, to) {
841
+ if (!access.canEditAnswers) return;
810
842
  const arr = state.answers[q.id];
811
843
  if (!Array.isArray(arr) || to < 0 || to >= arr.length || from === to) return;
812
844
  const [x] = arr.splice(from, 1);
@@ -827,7 +859,7 @@
827
859
  down.disabled = i === order.length - 1;
828
860
  up.addEventListener('click', () => move(i, i - 1));
829
861
  down.addEventListener('click', () => move(i, i + 1));
830
- const item = el('div', { class: 'rank-item', draggable: 'true' },
862
+ const item = el('div', { class: 'rank-item', draggable: access.canEditAnswers ? 'true' : 'false' },
831
863
  el('span', { class: 'rank-badge', 'aria-hidden': 'true' }, String(i + 1)),
832
864
  el('div', { class: 'rank-body' },
833
865
  el('div', { class: 'ol' }, o.label),
@@ -835,6 +867,7 @@
835
867
  el('div', { class: 'rank-ctrls' }, up, down)
836
868
  );
837
869
  item.addEventListener('dragstart', (e) => {
870
+ if (!access.canEditAnswers) { e.preventDefault(); return; }
838
871
  dragFrom = i; item.classList.add('dragging');
839
872
  try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); } catch (_) {}
840
873
  });
@@ -1127,7 +1160,7 @@
1127
1160
  app.append(card);
1128
1161
  });
1129
1162
 
1130
- if (spec.note) {
1163
+ if (spec.note && responseRequired) {
1131
1164
  const note = el('textarea', { placeholder: 'optional note back to the agent…' });
1132
1165
  note.value = state.comment || '';
1133
1166
  note.addEventListener('input', () => {
@@ -1149,6 +1182,10 @@
1149
1182
  const hint = el('span', { class: 'hint' },
1150
1183
  QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
1151
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
+ }
1152
1189
  app.append(submitbar);
1153
1190
 
1154
1191
  function copyText(text, btn) {
@@ -1182,7 +1219,11 @@
1182
1219
  ),
1183
1220
  el('button', { class: 'share-choice', type: 'button', 'data-role': 'review' },
1184
1221
  el('span', { class: 'share-choice-title' }, 'Reviewer'),
1185
- el('span', { class: 'share-choice-sub' }, 'Can add comments only. No submit, answer edits, edit, or delete.')
1222
+ el('span', { class: 'share-choice-sub' }, 'Can answer, comment, and submit a reference-only side review. Does not finalize the board.')
1223
+ ),
1224
+ el('button', { class: 'share-choice', type: 'button', 'data-role': 'read' },
1225
+ el('span', { class: 'share-choice-title' }, 'Read only'),
1226
+ el('span', { class: 'share-choice-sub' }, 'Can view the board and existing feedback. Cannot answer, comment, edit, or submit.')
1186
1227
  ),
1187
1228
  el('div', { class: 'share-result' })
1188
1229
  );
@@ -1207,7 +1248,7 @@
1207
1248
  const copy = el('button', { class: 'share-copy', type: 'button' }, 'Copy link');
1208
1249
  copy.addEventListener('click', () => copyText(body.url, copy));
1209
1250
  result.replaceChildren(
1210
- el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : 'Reviewer link active'),
1251
+ el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : role === 'review' ? 'Reviewer link active' : 'Read-only link active'),
1211
1252
  el('a', { class: 'share-url', href: body.url, target: '_blank', rel: 'noreferrer' }, body.url),
1212
1253
  copy
1213
1254
  );
@@ -1226,18 +1267,33 @@
1226
1267
  return footer;
1227
1268
  }
1228
1269
 
1229
- if (!access.canSubmit) {
1230
- submitbar.style.display = 'none';
1231
- showNotice('Reviewer mode: comments are saved live. Answers and submission are disabled.', 'info');
1232
- } else if (access.role === 'collab') {
1233
- showNotice('Editor mode: answers, comments, and submit are enabled for this shared board.', 'info');
1234
- }
1235
- if (!access.canEditAnswers) {
1236
- document.documentElement.classList.add('relay-review');
1237
- for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
1238
- node.disabled = true;
1270
+ function applyAccessRestrictions() {
1271
+ if (!access.canSubmit) submitbar.style.display = 'none';
1272
+ if (!access.canEditAnswers) {
1273
+ for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
1274
+ // Viewer controls inside question/option blocks remain useful in a
1275
+ // read-only board (zoom, fit, full-screen, filtering, diff view). Block
1276
+ // mutation controls are independently gated by canEditBlocks.
1277
+ if (node.closest('.blocks')) continue;
1278
+ node.disabled = true;
1279
+ }
1280
+ for (const item of app.querySelectorAll('.rank-item')) {
1281
+ item.draggable = false;
1282
+ item.setAttribute('aria-disabled', 'true');
1283
+ }
1239
1284
  }
1285
+ document.documentElement.classList.toggle('relay-review', access.canEditComments === false || access.canDeleteComments === false);
1286
+ }
1287
+ if (access.role === 'review') {
1288
+ submitBtn.textContent = 'Submit side review';
1289
+ hint.textContent = 'Reference only — the owner still submits the final answer.';
1290
+ showNotice('Reviewer mode: your answers and comments are saved separately as a reference-only side review. Submitting here does not finalize the board or notify the waiting agent.', 'info');
1291
+ } else if (access.role === 'read') {
1292
+ showNotice('Read-only mode: you can view the board and existing feedback, but answers, comments, edits, and submission are disabled.', 'info');
1293
+ } else if (access.role === 'collab') {
1294
+ showNotice('Editor mode: answers, comments, and final submission are enabled for this shared board.', 'info');
1240
1295
  }
1296
+ applyAccessRestrictions();
1241
1297
  app.append(buildFooter());
1242
1298
 
1243
1299
  // ---------- validation & submit ----------
@@ -1253,7 +1309,7 @@
1253
1309
  return !firstBad;
1254
1310
  }
1255
1311
 
1256
- function showDone(closing) {
1312
+ function showDone(closing, sideReview = false) {
1257
1313
  stopHeartbeat();
1258
1314
  // Annotation pins/badges/popover float on <body> with elevated z-index —
1259
1315
  // remove them so they don't leak over the submitted screen.
@@ -1267,7 +1323,9 @@
1267
1323
  // If the agent already stopped waiting (soft timeout / dropped connection),
1268
1324
  // the submission won't be picked up automatically — tell the user to nudge
1269
1325
  // the agent. Otherwise the normal hand-back copy applies.
1270
- const note = handedBack
1326
+ const note = sideReview
1327
+ ? 'Saved for reference. This did not finalize the board or notify the waiting agent; the owner still needs to submit the final answer.'
1328
+ : handedBack
1271
1329
  ? 'Saved. Your agent had stopped waiting — send it a message so it picks up your answers.'
1272
1330
  : closing
1273
1331
  ? 'Handing back to your agent — this tab will close itself…'
@@ -1275,7 +1333,7 @@
1275
1333
  app.replaceChildren(
1276
1334
  el('div', { class: 'done' },
1277
1335
  el('div', { class: 'mark' }, '✓'),
1278
- el('h2', {}, QS.length ? 'Submitted' : 'Acknowledged'),
1336
+ el('h2', {}, sideReview ? 'Side review saved' : QS.length ? 'Submitted' : 'Acknowledged'),
1279
1337
  el('p', { id: 'done-note' }, note)
1280
1338
  )
1281
1339
  );
@@ -1302,14 +1360,16 @@
1302
1360
  throw netErr;
1303
1361
  }
1304
1362
  if (!res.ok) throw new Error('submit rejected');
1363
+ const submitResult = await res.json().catch(() => null);
1364
+ const sideReview = Boolean(submitResult && submitResult.sideReview === true && submitResult.final === false);
1305
1365
  submitted = true;
1306
1366
  // Submitted successfully → the local mirror is no longer needed and would
1307
1367
  // otherwise resurrect stale answers on a future reopen. Clear it.
1308
1368
  clearLocalDraft();
1309
1369
  // Don't auto-close when the agent had stopped waiting — the user needs to
1310
1370
  // read the "send your agent a message" note and act on it.
1311
- const autoClose = spec.autoClose && !handedBack;
1312
- showDone(autoClose);
1371
+ const autoClose = !sideReview && spec.autoClose && !handedBack;
1372
+ showDone(autoClose, sideReview);
1313
1373
  if (autoClose) {
1314
1374
  setTimeout(() => {
1315
1375
  window.close();
@@ -1324,7 +1384,7 @@
1324
1384
  } catch {
1325
1385
  // Restore the button so the user can retry.
1326
1386
  submitBtn.disabled = false;
1327
- submitBtn.textContent = spec.submitLabel;
1387
+ submitBtn.textContent = access.role === 'review' ? 'Submit side review' : spec.submitLabel;
1328
1388
  if (!reached) {
1329
1389
  // The connection is gone — the submit (and any further input) can't be
1330
1390
  // persisted. Block hard so the user stops adding feedback that would be
@@ -1362,7 +1422,7 @@
1362
1422
  // the server had nothing — e.g. a freshly reopened board the user had typed
1363
1423
  // into in another tab during an outage), the server doesn't yet have this
1364
1424
  // input. Flush it once so a brand-new tab's view is also the server's truth.
1365
- if (initialPrefill && initialPrefill.__from === 'local' && !submitted) {
1425
+ if (initialPrefill && initialPrefill.__from === 'local' && !submitted && canPersistFeedback) {
1366
1426
  saveDraft();
1367
1427
  }
1368
1428
 
@@ -1374,7 +1434,7 @@
1374
1434
  // parent → {relay:'annotate-counts', counts:{ref:n}} it draws badges
1375
1435
  // We own the annotation state, popover, and the submitted result; the iframe
1376
1436
  // owns hover/pin/badges over its own (cross-origin) DOM.
1377
- if (Annotate) {
1437
+ if (Annotate && responseRequired) {
1378
1438
  const frameOf = (source) => {
1379
1439
  for (const f of document.querySelectorAll('iframe.viz')) {
1380
1440
  if (f.contentWindow === source) return f;
@@ -1456,7 +1516,7 @@
1456
1516
  // Piggyback presence on the heartbeat (best-effort; no-ops after submit).
1457
1517
  pingPresence();
1458
1518
  try {
1459
- const r = await fetch('/api/status', { cache: 'no-store' });
1519
+ const r = await fetch('/api/status', { cache: 'no-store', headers: authHeaders() });
1460
1520
  if (!r.ok) throw new Error('bad status');
1461
1521
  misses = 0;
1462
1522
  // The heartbeat reaching the server is itself proof persistence is back —
@@ -1476,10 +1536,14 @@
1476
1536
  // user knows to prompt the agent after submitting.
1477
1537
  if (body && body.softTimedOut && !submitted) {
1478
1538
  handedBack = true;
1479
- showNotice(
1480
- 'You’ve had this open a while, so the agent stopped waiting. Your changes save automatically — submit when you’re ready, then prompt the agent to pick them up.',
1481
- 'info'
1482
- );
1539
+ if (access.role === 'review') {
1540
+ showNotice('This remains a reference-only side review. Submit when ready; the owner still needs to provide the final answer.', 'info');
1541
+ } else if (access.role !== 'read') {
1542
+ showNotice(
1543
+ 'You’ve had this open a while, so the agent stopped waiting. Your changes save automatically — submit when you’re ready, then prompt the agent to pick them up.',
1544
+ 'info'
1545
+ );
1546
+ }
1483
1547
  }
1484
1548
  if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
1485
1549
  // Don't yank the board out from under someone mid-comment: an open