@erdemtuna/doc-review 0.10.0 → 0.11.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.
@@ -5,23 +5,27 @@
5
5
  * postMessage, because the artifact iframe lives on the other loopback
6
6
  * hostname: a separate origin that can never reach this page or its token.
7
7
  */
8
- import { tidyMiddle } from "./anchor-text.js";
9
- import { clearOwned, createCommentUi, migrateCommentUi, mutationIsCurrent, ownConfirmation, ownEdit, ownMenu, newestComments, pageUrl, reconcileCommentUi, replacePage, } from "./chrome-session.js";
8
+ import { clearOwned, commentControlId as controlId, createCommentUi, migrateCommentUi, mutationIsCurrent, ownConfirmation, ownEdit, newestComments, pageUrl, reconcileCommentUi, replacePage, } from "./chrome-session.js";
10
9
  import { sanitizeClientRects, sanitizeClipRect, sanitizeRelation } from "./comment-target.js";
11
10
  import { externalHref } from "./editing.js";
12
11
  import { framePolicy } from "./frame-policy.js";
13
12
  import { createIcon } from "./icons.js";
14
- import { createComparisonView } from "./comparison-view.js";
15
- import { createExecutionControls } from "./execution-client.js";
13
+ import { executionPresentation } from "./execution-client.js";
14
+ import { createToolbarController } from "./toolbar-controller.js";
15
+ import { createRecoveryController } from "./recovery-controller.js";
16
+ import { createCommentsController } from "./comments-controller.js";
17
+ import { createComposerDraft, createContextualController } from "./contextual-controller.js";
16
18
  import { alignedCardPosition, placeContextualSurface, visibleViewport } from "./positioning.js";
17
19
  import { normalizeReviewMode, reviewConfiguration } from "./review-mode.js";
18
- import { createCaptureRequests, captureError, sameRender, draftCount, excerpt, changeKind, pendingCaptureTarget, comparisonFreshness } from "./history-client.js";
19
- import { createCaptureCoordinator, createHistoryController, historyPresentation, requireCaptureSuccess } from "./history-coordinator.js";
20
+ import { createCaptureRequests, captureError, sameRender, draftCount, pendingCaptureTarget } from "./history-client.js";
21
+ import { createCaptureCoordinator, createHistoryController, requireCaptureSuccess } from "./history-coordinator.js";
20
22
  import { createReviewApi, decodePage } from "./chrome-api.js";
21
23
  import { createFrameHost } from "./frame-host.js";
22
24
  import { createFrameController } from "./frame-controller.js";
23
25
  import { createSaveController } from "./save-controller.js";
24
26
  import { createFeedbackController } from "./feedback-controller.js";
27
+ import { createFeedbackPanelController, createNoteDraft } from "./feedback-panel-controller.js";
28
+ import { createChangesController } from "./changes-controller.js";
25
29
  import { createReviewController } from "./review-controller.js";
26
30
  const $ = (id) => document.getElementById(id);
27
31
  const ARTIFACT_HOST = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
@@ -57,6 +61,7 @@ const state = {
57
61
  token: document.body.dataset.token,
58
62
  page: null,
59
63
  compose: null,
64
+ composerDraft: createComposerDraft(),
60
65
  composeLifecycle: "closed",
61
66
  composePlacement: "hidden",
62
67
  target: null,
@@ -68,16 +73,21 @@ const state = {
68
73
  savePolicy: "writable",
69
74
  modeApplying: false,
70
75
  modeMenuOpen: false,
76
+ restoreModeFocus: false,
77
+ theme: "light",
71
78
  drawerOpen: false,
72
79
  agent: "idle",
73
80
  orphans: new Set(),
74
81
  pollCommand: "",
75
- editsExpanded: false,
82
+ noteDraft: createNoteDraft(),
76
83
  others: [],
77
84
  scroll: { x: 0, y: 0 },
78
85
  comparing: false,
79
86
  executionPreference: null,
80
87
  ended: false,
88
+ reloadVisible: false,
89
+ reloadMessage: "",
90
+ reloadFailed: false,
81
91
  };
82
92
  const diagnostic = (event, detail = {}) => {
83
93
  console.info("[doc-review]", { event, ...detail });
@@ -95,13 +105,10 @@ function advancePageEpoch(reason) {
95
105
  function commentById(id) {
96
106
  return state.page?.comments?.find((comment) => comment.id === id) || null;
97
107
  }
98
- function domId(value) {
99
- return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.codePointAt(0).toString(16)}-`);
100
- }
101
- function controlId(commentId, surface, action) {
102
- return `comment-${surface}-${domId(commentId)}-${action}`;
103
- }
104
108
  function requestControlFocus(id) {
109
+ if (state.drawerOpen && document.getElementById(id)?.closest("#cards")) {
110
+ commentsRuntime.commands.setSectionOpen(true);
111
+ }
105
112
  requestedFocus = id;
106
113
  }
107
114
  function editTextarea(commentId) {
@@ -126,7 +133,12 @@ function restoreTransientFocus(editWasFocused = false) {
126
133
  const shouldFocusEdit = editWasFocused || editFocusRequested;
127
134
  afterPaint(() => {
128
135
  if (focusId) {
129
- document.getElementById(focusId)?.focus();
136
+ const target = document.getElementById(focusId);
137
+ if (target?.closest("[hidden], [inert]")) {
138
+ $(state.comparing ? "latestVersion" : state.drawerOpen ? "commentsSection" : "commentsButton").focus();
139
+ }
140
+ else
141
+ target?.focus();
130
142
  return;
131
143
  }
132
144
  const edit = state.commentUi.edit;
@@ -138,16 +150,14 @@ function restoreTransientFocus(editWasFocused = false) {
138
150
  return;
139
151
  }
140
152
  editFocusRequested = false;
153
+ // A mounted React editor already owns its live selection; delayed focus must not reset it.
154
+ if (document.activeElement === input)
155
+ return;
141
156
  input.focus();
142
157
  input.setSelectionRange(edit.selectionStart, edit.selectionEnd);
143
158
  });
144
159
  }
145
160
  function moveTransientSurface(surface) {
146
- const menu = state.commentUi.menu;
147
- if (menu) {
148
- menu.surface = surface;
149
- menu.triggerId = controlId(menu.commentId, surface, "more");
150
- }
151
161
  if (state.commentUi.confirmation)
152
162
  state.commentUi.confirmation.surface = surface;
153
163
  }
@@ -166,29 +176,24 @@ function installStaticIcons() {
166
176
  document.querySelectorAll("[data-icon]").forEach((container) => {
167
177
  replaceIcon(container, container.dataset.icon);
168
178
  });
169
- replaceIcon($("modeChevron"), "chevronDown");
170
- replaceIcon($("composeClose"), "x");
171
- replaceIcon($("drawerClose"), "x");
172
179
  }
173
180
  function openModeMenu() {
174
181
  if (state.modeMenuOpen)
175
182
  return;
183
+ recoveryRuntime.commands.setMenuOpen(false, { restoreFocus: false });
176
184
  state.modeMenuOpen = true;
177
- $("modeMenu").hidden = false;
178
- $("modeButton").setAttribute("aria-expanded", "true");
185
+ state.restoreModeFocus = true;
186
+ toolbarRuntime.publish();
179
187
  toFrame({ type: "eh:modeMenuState", open: true });
180
- $("modeMenu").querySelector(`[data-mode="${state.reviewMode}"]`)?.focus();
181
188
  }
182
189
  function closeModeMenu(reason = "dismissed", { restoreFocus = false } = {}) {
183
190
  if (!state.modeMenuOpen)
184
191
  return false;
185
192
  state.modeMenuOpen = false;
186
- $("modeMenu").hidden = true;
187
- $("modeButton").setAttribute("aria-expanded", "false");
193
+ state.restoreModeFocus = restoreFocus;
194
+ toolbarRuntime.publish();
188
195
  toFrame({ type: "eh:modeMenuState", open: false });
189
196
  diagnostic("mode-menu-close", { reason });
190
- if (restoreFocus)
191
- $("modeButton").focus();
192
197
  return true;
193
198
  }
194
199
  function openDrawer() {
@@ -209,7 +214,6 @@ function closeDrawer() {
209
214
  state.drawerOpen = false;
210
215
  const transientOwner = state.commentUi.edit?.commentId ||
211
216
  state.commentUi.confirmation?.commentId ||
212
- state.commentUi.menu?.commentId ||
213
217
  null;
214
218
  if (transientOwner && commentById(transientOwner)) {
215
219
  state.activeSavedCommentId = transientOwner;
@@ -229,9 +233,7 @@ function setComposeLifecycle(next, reason) {
229
233
  return;
230
234
  const from = state.composeLifecycle;
231
235
  state.composeLifecycle = next;
232
- const submitting = next === "submitting";
233
- $("composeCancel").disabled = submitting;
234
- $("composeClose").disabled = submitting;
236
+ contextualRuntime.publish();
235
237
  diagnostic("composer-lifecycle-transition", { from, to: next, reason });
236
238
  }
237
239
  function setComposePlacement(next) {
@@ -239,6 +241,7 @@ function setComposePlacement(next) {
239
241
  return;
240
242
  const from = state.composePlacement;
241
243
  state.composePlacement = next;
244
+ contextualRuntime.publish();
242
245
  diagnostic("placement-transition", { from, to: next });
243
246
  }
244
247
  /**
@@ -275,8 +278,10 @@ const frameController = createFrameController({
275
278
  saveController.baseline(null);
276
279
  },
277
280
  failed(message) {
278
- $("reloadNotice").hidden = false;
279
- $("reloadMessage").textContent = `${message} Reload keeps your comment drafts.`;
281
+ state.reloadVisible = true;
282
+ state.reloadFailed = true;
283
+ state.reloadMessage = `${message} Reload keeps your comment drafts.`;
284
+ recoveryRuntime.publish();
280
285
  toast(message);
281
286
  },
282
287
  });
@@ -295,12 +300,12 @@ const feedbackController = createFeedbackController({
295
300
  sourceHash: () => frameController.state.sourceHash, save: saveController,
296
301
  policy: () => state.savePolicy, request: api,
297
302
  capture: () => captureStablePage({ timeout: 500 }), refresh: () => refreshHistory(),
298
- note: () => $("note").value,
299
- clearNote(sent) { if ($("note").value === sent)
300
- $("note").value = ""; },
303
+ note: () => state.noteDraft.text,
304
+ clearNote(sent) {
305
+ if (state.noteDraft.text === sent && !state.noteDraft.composing)
306
+ Object.assign(state.noteDraft, createNoteDraft());
307
+ },
301
308
  pauseCapture() {
302
- if ($("captureWarning"))
303
- $("captureWarning").hidden = true;
304
309
  captureCoordinator.reset();
305
310
  manualCaptureController?.abort();
306
311
  },
@@ -340,14 +345,19 @@ reviewController.own(() => frameController.dispose());
340
345
  reviewController.own(() => saveController.dispose());
341
346
  reviewController.own(() => feedbackController.dispose());
342
347
  reviewController.own(() => transport.dispose());
343
- const executionControls = createExecutionControls({
344
- api,
348
+ export const recoveryRuntime = createRecoveryController({
349
+ request: api,
345
350
  sessionId: state.sessionId,
346
- elements: {
347
- static: $("executionStatic"), auto: $("executionAuto"), status: $("executionStatus"),
348
- details: $("executionDetails"), menu: $("reviewDetails"),
349
- editDescription: $("modeMenu").querySelector('[data-mode="edit"] small'),
350
- },
351
+ read: () => ({
352
+ page: state.page, rendered: frameHost.visibleExecution, identity: frameController.identity(),
353
+ comparing: state.comparing, ended: state.ended, loading: frameController.loading,
354
+ pendingReload: frameController.state.pendingReload || !!frameHost.previous,
355
+ frameError: frameController.state.phase.kind === "failed" ? frameController.state.phase.message : null,
356
+ reload: {
357
+ visible: state.reloadVisible, message: state.reloadMessage,
358
+ error: state.reloadFailed || saveController.state.conflict,
359
+ },
360
+ }),
351
361
  changed(page) {
352
362
  if (!page || page.key !== frameController.state.key)
353
363
  return;
@@ -360,7 +370,148 @@ const executionControls = createExecutionControls({
360
370
  state.savePolicy = reviewConfiguration(state.page, state.reviewMode).savePolicy;
361
371
  render();
362
372
  },
363
- failed(error) { toast(error.message || String(error)); },
373
+ failed: toast,
374
+ menuChanged(open) {
375
+ if (open) {
376
+ closeModeMenu("recovery-menu");
377
+ }
378
+ toFrame({ type: "eh:modeMenuState", open });
379
+ },
380
+ reload: () => reloadLatest({ explicit: true }),
381
+ keepCurrent() {
382
+ state.reloadVisible = false;
383
+ announce("Keeping the current page. Return to Review to reload when ready.");
384
+ },
385
+ });
386
+ reviewController.own(() => recoveryRuntime.dispose());
387
+ function pendingFeedbackCount() {
388
+ return (state.page?.comments?.length || 0) + (state.page?.edits?.length || 0) +
389
+ (state.others || []).reduce((sum, other) => sum + other.count, 0);
390
+ }
391
+ export const toolbarRuntime = createToolbarController(() => ({
392
+ comparing: state.comparing,
393
+ mode: normalizeReviewMode(state.reviewMode),
394
+ modeDisabled: state.modeApplying || state.comparing || feedbackController.sending || !frameController.state.execution,
395
+ modeMenuOpen: state.modeMenuOpen,
396
+ restoreModeFocus: state.restoreModeFocus,
397
+ editDescription: executionPresentation(state.page, frameHost.visibleExecution).editDescription,
398
+ drawerOpen: state.drawerOpen,
399
+ feedbackCount: pendingFeedbackCount(),
400
+ theme: state.theme === "dark" ? "dark" : "light",
401
+ ended: state.ended,
402
+ }), {
403
+ setComparing: setHistoryView,
404
+ setMode: (mode) => { void setReviewMode(mode); },
405
+ setModeMenu: (open) => {
406
+ if (open)
407
+ openModeMenu();
408
+ else
409
+ closeModeMenu("dismissed", { restoreFocus: true });
410
+ },
411
+ openComments: openDrawer,
412
+ toggleTheme,
413
+ });
414
+ reviewController.own(() => toolbarRuntime.dispose());
415
+ export const commentsRuntime = createCommentsController({
416
+ read: () => ({
417
+ open: state.drawerOpen, ended: state.ended, comparing: state.comparing,
418
+ hasPage: !!state.page, composeOpen: !!state.compose,
419
+ error: frameController.state.phase.kind === "failed" ? frameController.state.phase.message : null,
420
+ comments: state.page?.comments || [], others: state.others || [],
421
+ activeId: state.activeSavedCommentId, orphans: state.orphans, ui: state.commentUi,
422
+ }),
423
+ close: closeDrawer,
424
+ activate: setActive,
425
+ edit: (id, surface) => startCommentEdit(commentById(id), surface),
426
+ save: saveCommentEdit,
427
+ cancelEdit: cancelCommentEdit,
428
+ confirm: confirmCommentDelete,
429
+ dismiss: () => dismissActiveComment("close", { restoreFocus: true }),
430
+ cancelDelete: cancelDeleteConfirmation,
431
+ remove: deleteComment,
432
+ async navigate(key) {
433
+ try {
434
+ await reviewController.navigate({ key });
435
+ }
436
+ catch (err) {
437
+ toast(`${err.message}. Stay on this page and retry.`);
438
+ }
439
+ },
440
+ });
441
+ reviewController.own(() => commentsRuntime.dispose());
442
+ export const contextualRuntime = createContextualController({
443
+ read: () => ({
444
+ open: !!state.compose, disabled: state.ended || state.comparing,
445
+ submitting: state.composeLifecycle === "submitting",
446
+ kind: state.compose?.kind || "selection", quote: state.compose?.quote || "",
447
+ placement: state.composePlacement, draft: state.composerDraft,
448
+ }),
449
+ submit: commitCompose, cancel: cancelCompose,
450
+ reveal() {
451
+ if (!state.compose || state.compose.unresolved)
452
+ return;
453
+ diagnostic("reveal-target-requested");
454
+ toFrame({ type: "eh:revealTarget", targetGeneration: state.compose.generation });
455
+ },
456
+ focus() { $("compose").classList.remove("pass-through"); },
457
+ measure: scheduleContextualPosition,
458
+ });
459
+ reviewController.own(() => contextualRuntime.dispose());
460
+ export const feedbackRuntime = createFeedbackPanelController({
461
+ read: () => ({
462
+ note: state.noteDraft, ended: state.ended,
463
+ available: !!state.page && !frameController.loading && !state.comparing,
464
+ identity: JSON.stringify([frameController.state.key, frameController.state.renderId, frameController.state.generation, state.pageEpoch, frameController.state.pendingReload]),
465
+ source: frameController.state.sourceHash,
466
+ edits: state.page?.edits || [],
467
+ total: pendingFeedbackCount(),
468
+ drafts: draftCount(state), agent: state.agent,
469
+ prompt: handoffPrompt(state.pollCommand || state.page?.pollCommand),
470
+ filename: state.page?.filename || "", kind: state.page?.kind || "", markdown: !!state.page?.markdown,
471
+ save: saveController.getSnapshot(), delivery: feedbackController.getSnapshot(),
472
+ }),
473
+ send: () => feedbackController.send(),
474
+ async flush(action) {
475
+ if (action === "revert") {
476
+ // Revert owns save ordering and can discard edits that failed to persist.
477
+ await frameController.flush(true);
478
+ }
479
+ else {
480
+ await flushFrame({ strict: true });
481
+ await saveController.settled();
482
+ }
483
+ },
484
+ revert: () => saveController.revert(),
485
+ async end() {
486
+ await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
487
+ showEnded();
488
+ },
489
+ copy: (text) => navigator.clipboard.writeText(text),
490
+ failed: toast,
491
+ });
492
+ reviewController.own(() => feedbackRuntime.dispose());
493
+ reviewController.own(frameController.subscribe(() => feedbackRuntime.publish()));
494
+ let contextualFrame = null;
495
+ function scheduleContextualPosition() {
496
+ if (contextualFrame !== null || uiLifetime.signal.aborted)
497
+ return;
498
+ contextualFrame = requestAnimationFrame(() => {
499
+ contextualFrame = null;
500
+ if (uiLifetime.signal.aborted)
501
+ return;
502
+ positionCompose();
503
+ positionAlignedCard();
504
+ });
505
+ }
506
+ const contextualObserver = new ResizeObserver(scheduleContextualPosition);
507
+ for (const host of [$("compose"), $("alignedCard"), $("noticesRoot"), document.querySelector(".document-host")]) {
508
+ contextualObserver.observe(host);
509
+ }
510
+ reviewController.own(() => {
511
+ contextualObserver.disconnect();
512
+ if (contextualFrame !== null)
513
+ cancelAnimationFrame(contextualFrame);
514
+ contextualFrame = null;
364
515
  });
365
516
  const toolbar = document.querySelector(".toolbar");
366
517
  const toolbarObserver = new ResizeObserver(() => {
@@ -369,11 +520,9 @@ const toolbarObserver = new ResizeObserver(() => {
369
520
  toolbarObserver.observe(toolbar);
370
521
  reviewController.own(() => toolbarObserver.disconnect());
371
522
  function renderExecution() {
372
- executionControls.render(state.page, frameHost.visibleExecution, {
373
- pendingReload: frameController.state.pendingReload || !!frameHost.previous,
374
- loading: frameController.loading,
375
- error: frameController.state.phase.kind === "failed" ? frameController.state.phase.message : null,
376
- });
523
+ recoveryRuntime.publish();
524
+ toolbarRuntime.publish();
525
+ commentsRuntime.publish();
377
526
  }
378
527
  const persistEdit = (key, payload) => saveController.persistEdit(key, payload);
379
528
  const settleEditPersistence = (key) => saveController.settleEdits(key);
@@ -422,7 +571,7 @@ function configureFrame() {
422
571
  }
423
572
  async function setReviewMode(nextMode) {
424
573
  const next = normalizeReviewMode(nextMode);
425
- closeModeMenu("selection");
574
+ closeModeMenu("selection", { restoreFocus: true });
426
575
  if (next === state.reviewMode || state.modeApplying)
427
576
  return;
428
577
  state.modeApplying = true;
@@ -454,6 +603,8 @@ async function setReviewMode(nextMode) {
454
603
  }
455
604
  async function loadPage(key, { reload = true } = {}) {
456
605
  const returning = state.page;
606
+ state.reloadVisible = false;
607
+ state.reloadFailed = false;
457
608
  advancePageEpoch(returning ? "navigation" : "reload");
458
609
  const generation = beginFrameTransition(key);
459
610
  frameController.resetRetries();
@@ -466,6 +617,7 @@ async function loadPage(key, { reload = true } = {}) {
466
617
  frameController.setPolicy(framePolicy(state.page, ARTIFACT_ORIGIN));
467
618
  state.orphans = new Set();
468
619
  state.compose = null;
620
+ state.composerDraft = createComposerDraft();
469
621
  setComposeLifecycle("closed", "page-change");
470
622
  setComposePlacement("hidden");
471
623
  state.target = null;
@@ -486,26 +638,17 @@ async function loadPage(key, { reload = true } = {}) {
486
638
  toast(`${edits} ${edits === 1 ? "edit is" : "edits are"} queued for the agent to apply to the source`);
487
639
  }
488
640
  }
489
- // -------------------------------------------------------------------- clock
490
- function ago(ts) {
491
- const secs = Math.max(0, Math.round((Date.now() - ts) / 1000));
492
- if (secs < 45)
493
- return "just now";
494
- const mins = Math.round(secs / 60);
495
- if (mins < 60)
496
- return `${mins}m`;
497
- const hours = Math.round(mins / 60);
498
- if (hours < 24)
499
- return `${hours}h`;
500
- return `${Math.round(hours / 24)}d`;
501
- }
502
641
  // -------------------------------------------------------------------- render
503
642
  function render() {
504
643
  if (state.ended)
505
644
  return;
645
+ toolbarRuntime.publish();
506
646
  const page = state.page;
507
- if (!page)
647
+ if (!page) {
648
+ commentsRuntime.publish();
649
+ contextualRuntime.publish();
508
650
  return;
651
+ }
509
652
  // Source updates cannot change the policy of a frame awaiting draft-safe reload.
510
653
  if (frameController.state.execution)
511
654
  Object.assign(page, frameController.state.execution);
@@ -515,150 +658,18 @@ function render() {
515
658
  syncFrameInteraction();
516
659
  const editWasFocused = skipEditCaptureOnce ? false : captureEditState();
517
660
  skipEditCaptureOnce = false;
661
+ commentsRuntime.publish();
518
662
  document.title = page.filename || 'doc-review';
519
- const comments = newestComments(page.comments);
520
- const edits = page.edits || [];
521
- $("count").textContent = String(comments.length);
522
- $("toolbarCount").textContent = String(comments.length);
523
- $("empty").hidden = comments.length > 0 || !!state.compose;
524
- $("modeLabel").textContent = state.reviewMode === "edit" ? "Edit" : "View";
525
- replaceIcon($("modeIcon"), state.reviewMode === "edit" ? "pencil" : "eye");
526
- $("modeButton").disabled = state.modeApplying || state.comparing || feedbackController.sending || !frameController.state.execution;
527
- for (const item of $("modeMenu").querySelectorAll("[data-mode]")) {
528
- const checked = item.dataset.mode === state.reviewMode;
529
- item.setAttribute("aria-checked", String(checked));
530
- const check = item.querySelector(".menu-check");
531
- check.textContent = "";
532
- if (checked)
533
- check.append(createIcon("check"));
534
- }
535
- $("drawer").classList.toggle("open", state.drawerOpen);
536
- $("drawer").setAttribute("aria-hidden", String(!state.drawerOpen));
537
- $("commentsButton").setAttribute("aria-expanded", String(state.drawerOpen));
538
- $("drawerBackdrop").hidden = !state.drawerOpen;
539
- // --- compose
540
- const composeWrap = $("compose");
541
- if (state.compose) {
542
- composeWrap.hidden = false;
543
- $("composeKind").textContent = state.compose.kind === "element" ? "Element" : "Selection";
544
- $("composeQuote").textContent = tidyMiddle(state.compose.quote, 260);
545
- afterPaint(positionCompose);
546
- }
547
- else {
548
- composeWrap.hidden = true;
549
- $("composeText").value = "";
550
- $("composeError").hidden = true;
551
- }
552
- // --- comment cards
553
- const list = $("cards");
554
- list.textContent = "";
555
- for (const comment of comments) {
556
- list.append(renderCommentCard(comment, "drawer"));
557
- }
558
- renderAlignedCard(comments);
559
- // --- your edits
560
- const box = $("editsBox");
561
- box.hidden = edits.length === 0;
562
- if (edits.length) {
563
- $("editCount").textContent = String(edits.length);
564
- const rows = $("editList");
565
- rows.textContent = "";
566
- const LIMIT = 5;
567
- const shown = state.editsExpanded ? edits : edits.slice(0, LIMIT);
568
- for (const edit of shown) {
569
- const row = document.createElement("div");
570
- row.className = `edit-row${edit.kind === "deleted" ? " deleted" : ""}`;
571
- const pip = document.createElement("span");
572
- pip.className = "pip";
573
- const label = document.createElement("span");
574
- label.className = "label";
575
- label.textContent = edit.label;
576
- const kind = document.createElement("span");
577
- kind.className = "kind";
578
- kind.textContent = edit.kind;
579
- row.append(pip, label, kind);
580
- rows.append(row);
581
- }
582
- if (edits.length > LIMIT) {
583
- const more = document.createElement("button");
584
- more.type = "button";
585
- more.className = "edit-more";
586
- more.textContent = state.editsExpanded ? "Show fewer" : `${edits.length - LIMIT} more…`;
587
- more.addEventListener("click", () => {
588
- state.editsExpanded = !state.editsExpanded;
589
- render();
590
- });
591
- rows.append(more);
592
- }
593
- renderSave();
594
- }
595
- // --- pages you left feedback on but are not looking at
596
- const others = state.others || [];
597
- const othersBox = $("othersBox");
598
- othersBox.hidden = others.length === 0;
599
- if (others.length) {
600
- $("othersCount").textContent = String(others.length);
601
- const list = $("othersList");
602
- list.textContent = "";
603
- for (const other of others) {
604
- const row = document.createElement("button");
605
- row.type = "button";
606
- row.className = "edit-row other-row";
607
- const label = document.createElement("span");
608
- label.className = "label";
609
- label.textContent = other.filename;
610
- const count = document.createElement("span");
611
- count.className = "kind";
612
- count.textContent = String(other.count);
613
- row.append(label, count);
614
- row.addEventListener("click", async () => {
615
- try {
616
- await reviewController.navigate({ key: other.key });
617
- }
618
- catch (err) {
619
- toast(`${err.message}. Stay on this page and retry.`);
620
- }
621
- });
622
- list.append(row);
623
- }
624
- }
625
- // --- send
626
- const otherTotal = others.reduce((sum, o) => sum + o.count, 0);
627
- const total = comments.length + edits.length + otherTotal;
628
- // An overall note is sendable on its own — the server already accepts
629
- // note-only batches; the button must not stay dead while one is typed.
630
- const hasNote = $("note").value.trim().length > 0;
631
- const send = $("send");
632
- const delivered = state.agent === "working";
633
- const stranded = state.agent === "stranded";
634
- const busy = delivered || stranded || feedbackController.sent;
635
- send.disabled = (total === 0 && !hasNote) || busy || feedbackController.sending;
636
- send.textContent = feedbackController.sending ? "Sending feedback…" : delivered
637
- ? "Feedback delivered"
638
- : stranded
639
- ? "Sent — agent is not listening"
640
- : feedbackController.sent
641
- ? "Sent — waiting for agent"
642
- : total
643
- ? `Send ${total} to agent`
644
- : hasNote
645
- ? "Send note to agent"
646
- : "Nothing to send yet";
647
- // After sending, say what happens next. If nothing is polling, the loop would
648
- // otherwise dead-end silently, so hand over the exact command to run.
649
- $("agentLine").hidden = !delivered;
650
- $("agentText").textContent = "Feedback delivered — page reloads when fixes land";
651
- // Server-authoritative, so it survives a browser refresh.
652
- $("handoff").hidden = !stranded;
653
- if (stranded)
654
- $("handoffCmd").textContent = handoffPrompt(state.pollCommand || page.pollCommand);
655
- const excluded = draftCount(state);
656
- $("draftWarning").hidden = excluded === 0;
657
- $("draftWarning").textContent = `${excluded} open ${excluded === 1 ? "draft is" : "drafts are"} not included. Save comments explicitly before sending.`;
663
+ // Stable outer hosts are the bounded geometry adapter; React owns their controls.
664
+ $("compose").hidden = !state.compose;
665
+ contextualRuntime.publish();
666
+ scheduleContextualPosition();
667
+ feedbackRuntime.publish();
658
668
  restoreTransientFocus(editWasFocused);
659
669
  }
660
670
  function positionCompose() {
661
671
  if (!state.compose || state.composeLifecycle === "closed") {
672
+ $("compose").hidden = true;
662
673
  setComposePlacement("hidden");
663
674
  return;
664
675
  }
@@ -667,7 +678,9 @@ function positionCompose() {
667
678
  surface.hidden = false;
668
679
  surface.classList.add("sheet");
669
680
  surface.classList.remove("edge-top", "edge-bottom");
670
- $("composeDirection").hidden = true;
681
+ surface.style.left = "";
682
+ surface.style.top = "";
683
+ surface.style.width = "";
671
684
  setComposePlacement("sheet");
672
685
  return;
673
686
  }
@@ -688,13 +701,6 @@ function positionCompose() {
688
701
  surface.classList.toggle("edge-top", placement.kind === "edge-top");
689
702
  surface.classList.toggle("edge-bottom", placement.kind === "edge-bottom");
690
703
  setComposePlacement(placement.kind);
691
- const edge = placement.kind === "edge-top" || placement.kind === "edge-bottom";
692
- $("composeDirection").hidden = !edge;
693
- $("composeDirectionText").textContent = placement.kind === "edge-top"
694
- ? "Selection is above"
695
- : placement.kind === "edge-bottom"
696
- ? "Selection is below"
697
- : "";
698
704
  surface.hidden = placement.kind === "hidden";
699
705
  if (placement.kind !== "sheet" && placement.kind !== "hidden") {
700
706
  surface.style.left = `${placement.left}px`;
@@ -707,273 +713,15 @@ function positionCompose() {
707
713
  surface.style.width = "";
708
714
  }
709
715
  }
710
- function makeAction(label, className = "card-action") {
711
- const button = document.createElement("button");
712
- button.type = "button";
713
- button.className = className;
714
- button.textContent = label;
715
- return button;
716
- }
717
- function makeWho(comment, surface) {
718
- const who = document.createElement("span");
719
- who.className = "who";
720
- who.append("You");
721
- if (surface === "drawer") {
722
- const sep = document.createElement("span");
723
- sep.className = "sep";
724
- sep.textContent = "·";
725
- const when = document.createElement("span");
726
- when.className = "when";
727
- when.textContent = ago(comment.updatedAt || comment.createdAt);
728
- who.append(sep, when);
729
- }
730
- if (comment.correction) {
731
- const badge = document.createElement("span");
732
- badge.className = "badge correction";
733
- badge.textContent = "correction";
734
- who.append(badge);
735
- }
736
- else if (comment.updatedAt) {
737
- const badge = document.createElement("span");
738
- badge.className = "badge";
739
- badge.textContent = "edited";
740
- who.append(badge);
741
- }
742
- if (state.orphans.has(comment.id)) {
743
- const badge = document.createElement("span");
744
- badge.className = "badge";
745
- badge.textContent = "orphaned";
746
- who.append(badge);
747
- }
748
- return who;
749
- }
750
- function commentUiKind(commentId) {
751
- if (state.commentUi.edit?.commentId === commentId)
752
- return "edit";
753
- if (state.commentUi.confirmation?.commentId === commentId)
754
- return "confirmation";
755
- if (state.commentUi.menu?.commentId === commentId)
756
- return "menu";
757
- return "normal";
758
- }
759
- function renderMore(comment, surface, open, disabled = false) {
760
- const triggerId = controlId(comment.id, surface, "more");
761
- const menuId = controlId(comment.id, surface, "menu");
762
- const trigger = makeAction("More", "card-action more-trigger");
763
- trigger.id = triggerId;
764
- trigger.setAttribute("aria-haspopup", "menu");
765
- trigger.setAttribute("aria-expanded", String(open));
766
- trigger.setAttribute("aria-controls", menuId);
767
- trigger.disabled = disabled;
768
- trigger.prepend(createIcon("moreHorizontal", { size: 14 }));
769
- trigger.addEventListener("click", (event) => {
770
- event.stopPropagation();
771
- if (open) {
772
- closeCommentMenu({ restoreFocus: true });
773
- }
774
- else {
775
- if (!ownMenu(state.commentUi, comment.id, surface, triggerId)) {
776
- focusCurrentCommentEdit();
777
- return;
778
- }
779
- toFrame({ type: "eh:modeMenuState", open: true });
780
- requestControlFocus(controlId(comment.id, surface, "delete-item"));
781
- render();
782
- }
783
- });
784
- return trigger;
785
- }
786
- function renderCommentCard(comment, surface) {
787
- const card = document.createElement(surface === "aligned" ? "article" : "div");
788
- if (surface === "drawer") {
789
- card.className = `comment${state.activeSavedCommentId === comment.id ? " active" : ""}`;
790
- }
791
- card.dataset.id = comment.id;
792
- card.dataset.surface = surface;
793
- const kind = commentUiKind(comment.id);
794
- const editOwnedElsewhere = !!(state.commentUi.edit &&
795
- state.commentUi.edit.commentId !== comment.id);
796
- const head = document.createElement("div");
797
- head.className = "comment-head";
798
- head.append(makeWho(comment, surface));
799
- if (kind === "normal") {
800
- if (surface === "drawer") {
801
- const jump = makeAction("Jump to");
802
- jump.disabled = editOwnedElsewhere;
803
- jump.addEventListener("click", (event) => {
804
- event.stopPropagation();
805
- setActive(comment.id, true);
806
- });
807
- head.append(jump);
808
- }
809
- const edit = makeAction("Edit");
810
- edit.id = controlId(comment.id, surface, "edit");
811
- edit.setAttribute("aria-label", "Edit comment");
812
- edit.disabled = editOwnedElsewhere;
813
- edit.addEventListener("click", (event) => {
814
- event.stopPropagation();
815
- startCommentEdit(comment, surface);
816
- });
817
- head.append(edit);
818
- if (surface === "aligned") {
819
- const close = makeAction("Close");
820
- close.setAttribute("aria-label", "Close comment card");
821
- close.addEventListener("click", (event) => {
822
- event.stopPropagation();
823
- dismissActiveComment("close", { restoreFocus: true });
824
- });
825
- head.append(close);
826
- }
827
- head.append(renderMore(comment, surface, false, editOwnedElsewhere));
828
- }
829
- else if (kind === "menu") {
830
- head.append(renderMore(comment, surface, true));
831
- }
832
- const quote = document.createElement("p");
833
- quote.className = "quote";
834
- quote.textContent = tidyMiddle(comment.quote, 140);
835
- card.append(head, quote);
836
- if (kind === "edit") {
837
- const edit = state.commentUi.edit;
838
- const input = document.createElement("textarea");
839
- input.className = "body-edit";
840
- input.rows = 3;
841
- input.value = edit.draft;
842
- input.readOnly = edit.status === "saving";
843
- input.dataset.commentEdit = comment.id;
844
- input.setAttribute("aria-label", "Edit comment text");
845
- input.addEventListener("input", () => {
846
- edit.draft = input.value;
847
- edit.selectionStart = input.selectionStart;
848
- edit.selectionEnd = input.selectionEnd;
849
- edit.validation = "";
850
- });
851
- const rememberSelection = () => {
852
- edit.selectionStart = input.selectionStart;
853
- edit.selectionEnd = input.selectionEnd;
854
- };
855
- input.addEventListener("select", rememberSelection);
856
- input.addEventListener("keyup", rememberSelection);
857
- input.addEventListener("click", (event) => {
858
- event.stopPropagation();
859
- rememberSelection();
860
- });
861
- input.addEventListener("compositionstart", () => {
862
- edit.composing = true;
863
- });
864
- input.addEventListener("compositionend", () => {
865
- edit.composing = false;
866
- rememberSelection();
867
- });
868
- input.addEventListener("keydown", (event) => {
869
- event.stopPropagation();
870
- rememberSelection();
871
- if (event.key === "Enter" && !event.shiftKey) {
872
- if (edit.composing || event.isComposing)
873
- return;
874
- event.preventDefault();
875
- void saveCommentEdit();
876
- }
877
- else if (event.key === "Escape") {
878
- event.preventDefault();
879
- cancelCommentEdit();
880
- }
881
- });
882
- card.append(input);
883
- const helper = document.createElement("p");
884
- helper.className = "edit-help";
885
- helper.textContent = "Enter to save · Shift+Enter for new line";
886
- card.append(helper);
887
- if (edit.validation) {
888
- const validation = document.createElement("p");
889
- validation.className = "comment-validation";
890
- validation.setAttribute("role", "alert");
891
- validation.textContent = edit.validation;
892
- card.append(validation);
893
- }
894
- const actions = document.createElement("div");
895
- actions.className = "comment-edit-actions";
896
- const save = makeAction(edit.status === "saving" ? "Saving…" : "Save", "btn-primary");
897
- save.disabled = edit.status === "saving";
898
- save.addEventListener("click", (event) => {
899
- event.stopPropagation();
900
- void saveCommentEdit();
901
- });
902
- const cancel = makeAction("Cancel", "btn-ghost");
903
- cancel.disabled = edit.status === "saving";
904
- cancel.addEventListener("click", (event) => {
905
- event.stopPropagation();
906
- cancelCommentEdit();
907
- });
908
- actions.append(save, cancel);
909
- card.append(actions);
910
- }
911
- else if (kind === "confirmation") {
912
- const confirmation = state.commentUi.confirmation;
913
- const prompt = document.createElement("p");
914
- prompt.className = "delete-prompt";
915
- prompt.textContent = "Delete this comment?";
916
- const actions = document.createElement("div");
917
- actions.className = "delete-actions";
918
- const cancel = makeAction("Cancel", "btn-ghost");
919
- cancel.disabled = confirmation.status === "deleting";
920
- cancel.addEventListener("click", (event) => {
921
- event.stopPropagation();
922
- cancelDeleteConfirmation();
923
- });
924
- const remove = makeAction(confirmation.status === "deleting" ? "Deleting…" : "Delete", "btn-danger");
925
- remove.id = controlId(comment.id, surface, "confirm-delete");
926
- remove.disabled = confirmation.status === "deleting";
927
- remove.addEventListener("click", (event) => {
928
- event.stopPropagation();
929
- void deleteComment(comment.id);
930
- });
931
- actions.append(cancel, remove);
932
- card.append(prompt, actions);
933
- }
934
- else {
935
- const body = document.createElement("p");
936
- body.className = "body";
937
- body.textContent = comment.feedback;
938
- body.title = "Click to edit";
939
- if (!editOwnedElsewhere) {
940
- body.addEventListener("click", (event) => {
941
- event.stopPropagation();
942
- startCommentEdit(comment, surface);
943
- });
944
- }
945
- card.append(body);
946
- }
947
- if (kind === "menu") {
948
- const menu = document.createElement("div");
949
- menu.id = controlId(comment.id, surface, "menu");
950
- menu.className = "comment-menu";
951
- menu.setAttribute("role", "menu");
952
- menu.setAttribute("aria-labelledby", controlId(comment.id, surface, "more"));
953
- const remove = makeAction("Delete", "comment-menu-item");
954
- remove.id = controlId(comment.id, surface, "delete-item");
955
- remove.setAttribute("role", "menuitem");
956
- remove.prepend(createIcon("trash", { size: 14 }));
957
- remove.addEventListener("click", (event) => {
958
- event.stopPropagation();
959
- toFrame({ type: "eh:modeMenuState", open: false });
960
- if (!ownConfirmation(state.commentUi, comment.id, surface)) {
961
- focusCurrentCommentEdit();
962
- return;
963
- }
964
- requestControlFocus(controlId(comment.id, surface, "confirm-delete"));
965
- render();
966
- });
967
- menu.append(remove);
968
- card.append(menu);
969
- }
970
- if (surface === "drawer") {
971
- card.addEventListener("click", () => {
972
- if (kind === "normal" && !editOwnedElsewhere)
973
- setActive(comment.id, false);
974
- });
716
+ function confirmCommentDelete(id, surface) {
717
+ if (!commentById(id))
718
+ return;
719
+ if (!ownConfirmation(state.commentUi, id, surface)) {
720
+ focusCurrentCommentEdit();
721
+ return;
975
722
  }
976
- return card;
723
+ requestControlFocus(controlId(id, surface, "confirm-delete"));
724
+ render();
977
725
  }
978
726
  function positionAlignedCard() {
979
727
  const host = $("alignedCard");
@@ -987,6 +735,8 @@ function positionAlignedCard() {
987
735
  const position = alignedCardPosition(geometry.rects, {
988
736
  frameRect: frameHost.current.getBoundingClientRect(),
989
737
  viewport,
738
+ width: host.offsetWidth || 300,
739
+ height: host.offsetHeight || 180,
990
740
  });
991
741
  if (!position) {
992
742
  host.hidden = true;
@@ -997,19 +747,6 @@ function positionAlignedCard() {
997
747
  host.hidden = false;
998
748
  return true;
999
749
  }
1000
- function renderAlignedCard(comments) {
1001
- const host = $("alignedCard");
1002
- host.textContent = "";
1003
- const comment = comments.find((item) => item.id === state.activeSavedCommentId);
1004
- if (!comment) {
1005
- host.hidden = true;
1006
- return;
1007
- }
1008
- const card = renderCommentCard(comment, "aligned");
1009
- while (card.firstChild)
1010
- host.append(card.firstChild);
1011
- positionAlignedCard();
1012
- }
1013
750
  async function deleteComment(id) {
1014
751
  if (deleteFlights.has(id))
1015
752
  return deleteFlights.get(id);
@@ -1019,6 +756,7 @@ async function deleteComment(id) {
1019
756
  confirmation.status = "deleting";
1020
757
  render();
1021
758
  const startEpoch = state.pageEpoch;
759
+ const index = newestComments(state.page.comments).findIndex((comment) => comment.id === id);
1022
760
  const flight = (async () => {
1023
761
  try {
1024
762
  const result = await api(`/api/page/${frameController.state.key}/comment/${id}`, { method: "DELETE" });
@@ -1029,6 +767,11 @@ async function deleteComment(id) {
1029
767
  state.activeGeometry.delete(id);
1030
768
  if (state.activeSavedCommentId === id)
1031
769
  state.activeSavedCommentId = null;
770
+ if (state.drawerOpen) {
771
+ const remaining = newestComments(state.page.comments);
772
+ const next = remaining[Math.min(index, remaining.length - 1)];
773
+ requestControlFocus(next ? controlId(next.id, "drawer", "delete") : "commentsSection");
774
+ }
1032
775
  render();
1033
776
  return true;
1034
777
  }
@@ -1052,7 +795,7 @@ function cancelDeleteConfirmation() {
1052
795
  const confirmation = state.commentUi.confirmation;
1053
796
  if (!confirmation || confirmation.status === "deleting")
1054
797
  return false;
1055
- const triggerId = controlId(confirmation.commentId, confirmation.surface, "more");
798
+ const triggerId = controlId(confirmation.commentId, confirmation.surface, "delete");
1056
799
  clearOwned(state.commentUi, "confirmation");
1057
800
  requestControlFocus(triggerId);
1058
801
  render();
@@ -1144,6 +887,8 @@ async function executeCommentPatch(edit, startEpoch) {
1144
887
  }
1145
888
  reconcilePage(result.page, { reason: "mutation" });
1146
889
  clearOwned(state.commentUi, "edit");
890
+ if (state.drawerOpen)
891
+ requestControlFocus(controlId(replacement?.id || id, "drawer", "edit"));
1147
892
  if (result.delivery === "updated-pending") {
1148
893
  toast("Updated the feedback waiting for your agent");
1149
894
  }
@@ -1176,37 +921,7 @@ async function executeCommentPatch(edit, startEpoch) {
1176
921
  }
1177
922
  }
1178
923
  function renderSave() {
1179
- const line = $("saveLine");
1180
- if (saveController.state.conflict) {
1181
- line.className = "save-line failed";
1182
- $("saveText").textContent = "Source changed — reload latest before saving. Your page edits remain in this tab.";
1183
- return;
1184
- }
1185
- if (state.page && state.page.kind === "url") {
1186
- line.className = "save-line dynamic";
1187
- $("saveText").textContent = "Localhost page — your direct edits go to the agent for source updates";
1188
- return;
1189
- }
1190
- if (state.page && state.page.markdown) {
1191
- line.className = "save-line dynamic";
1192
- $("saveText").textContent = "Markdown source — edits go to the agent as feedback";
1193
- return;
1194
- }
1195
- if (saveController.state.dynamic) {
1196
- // The page's own scripts render it, so writing the live DOM back would
1197
- // corrupt the file. Edits still reach the agent as feedback.
1198
- line.className = "save-line dynamic";
1199
- $("saveText").textContent = "Live page — edits go to the agent, the file is left alone";
1200
- return;
1201
- }
1202
- line.className = `save-line ${saveController.state.status === "saving" ? "saving" : saveController.state.status === "failed" ? "failed" : ""}`;
1203
- const name = state.page ? state.page.filename : "";
1204
- if (saveController.state.status === "saving")
1205
- $("saveText").textContent = `Saving to ${name}…`;
1206
- else if (saveController.state.status === "failed")
1207
- $("saveText").textContent = "Couldn't save — retry before sending";
1208
- else
1209
- $("saveText").textContent = saveController.state.savedAt ? `Saved to ${name} · ${saveController.state.savedAt}` : `Saved to ${name}`;
924
+ feedbackRuntime.publish();
1210
925
  }
1211
926
  function toast(message) {
1212
927
  const el = document.createElement("div");
@@ -1216,6 +931,8 @@ function toast(message) {
1216
931
  afterDelay(() => el.remove(), 3200);
1217
932
  }
1218
933
  function setActive(id, scroll) {
934
+ if (id && state.drawerOpen)
935
+ commentsRuntime.commands.setSectionOpen(true);
1219
936
  state.activeSavedCommentId = id;
1220
937
  toFrame({ type: "eh:activate", id, scroll: !!scroll });
1221
938
  render();
@@ -1236,13 +953,15 @@ function dismissActiveComment(reason, { restoreFocus = false } = {}) {
1236
953
  }
1237
954
  // ------------------------------------------------------------------ compose
1238
955
  async function openCompose(detail) {
956
+ const identity = currentRender();
957
+ const pageKey = frameController.state.key;
1239
958
  if (state.compose && state.compose.generation === detail.generation) {
1240
959
  if (state.composeLifecycle === "closed")
1241
960
  setComposeLifecycle("open", "accepted");
1242
961
  $("composeText").focus();
1243
962
  return true;
1244
963
  }
1245
- if (state.compose && $("composeText").value.trim()) {
964
+ if (state.compose && state.composerDraft.text.trim()) {
1246
965
  const submitted = await commitCompose();
1247
966
  if (!submitted) {
1248
967
  diagnostic("comment-retarget-blocked", { reason: "submit-failed" });
@@ -1252,12 +971,12 @@ async function openCompose(detail) {
1252
971
  else if (state.compose) {
1253
972
  cancelCompose({ restoreFocus: false, preserveRetarget: true });
1254
973
  }
974
+ if (state.ended || pageKey !== frameController.state.key || !sameRender(identity, currentRender()))
975
+ return false;
1255
976
  dismissActiveComment("composer-open");
1256
977
  state.compose = detail;
978
+ state.composerDraft = createComposerDraft();
1257
979
  setComposeLifecycle("open", "accepted");
1258
- $("composeText").value = "";
1259
- $("composeError").hidden = true;
1260
- $("composeAddLabel").textContent = "Comment";
1261
980
  render();
1262
981
  afterPaint(() => {
1263
982
  positionCompose();
@@ -1272,6 +991,7 @@ function cancelCompose({ restoreFocus = true, preserveRetarget = false } = {}) {
1272
991
  const generation = state.compose.generation;
1273
992
  const discardThroughGeneration = Math.max(generation, Number(state.compose.rejectedTargetGeneration) || generation);
1274
993
  state.compose = null;
994
+ state.composerDraft = createComposerDraft();
1275
995
  setComposeLifecycle("closed", "cancel");
1276
996
  setComposePlacement("hidden");
1277
997
  toFrame({
@@ -1297,26 +1017,40 @@ function commitCompose() {
1297
1017
  }
1298
1018
  async function executeComposeSubmit() {
1299
1019
  const compose = state.compose;
1300
- const feedback = $("composeText").value.trim();
1301
- if (!compose || !feedback)
1020
+ const draft = state.composerDraft;
1021
+ const feedback = draft.text.trim();
1022
+ if (!compose || !feedback || draft.composing || state.ended)
1302
1023
  return false;
1303
- const button = $("composeAdd");
1304
- button.disabled = true;
1024
+ const startEpoch = state.pageEpoch;
1025
+ const identity = currentRender();
1026
+ const targetGeneration = compose.generation;
1027
+ const current = () => !state.ended && state.compose === compose && state.composerDraft === draft;
1028
+ draft.error = "";
1305
1029
  setComposeLifecycle("submitting", "submit");
1306
1030
  diagnostic("comment-submit-executed");
1307
- $("composeError").hidden = true;
1308
1031
  try {
1309
- const result = await api(`/api/page/${frameController.state.key}/comment`, {
1032
+ const result = await api(`/api/page/${identity.key}/comment`, {
1310
1033
  method: "POST",
1311
1034
  body: JSON.stringify({ kind: compose.kind, quote: compose.quote, anchor: compose.unresolved ? null : compose.anchor, feedback }),
1312
1035
  });
1313
- toFrame({
1314
- type: "eh:commit",
1315
- id: result.comment.id,
1316
- targetGeneration: compose.generation,
1317
- restoreFocus: true,
1318
- });
1036
+ if (!current())
1037
+ return false;
1038
+ if (startEpoch !== state.pageEpoch || !sameRender(identity, currentRender())) {
1039
+ draft.error = "The page changed while this comment was saving. Your draft is preserved; check the comments before retrying.";
1040
+ draft.retry = true;
1041
+ setComposeLifecycle("open", "stale-submit");
1042
+ render();
1043
+ return false;
1044
+ }
1045
+ if (!compose.unresolved)
1046
+ toFrame({
1047
+ type: "eh:commit",
1048
+ id: result.comment.id,
1049
+ targetGeneration,
1050
+ restoreFocus: true,
1051
+ });
1319
1052
  state.compose = null;
1053
+ state.composerDraft = createComposerDraft();
1320
1054
  setComposeLifecycle("closed", "submitted");
1321
1055
  setComposePlacement("hidden");
1322
1056
  state.page = result.page;
@@ -1326,24 +1060,22 @@ async function executeComposeSubmit() {
1326
1060
  return true;
1327
1061
  }
1328
1062
  catch (err) {
1329
- $("composeError").textContent = `${err.message}. Retry or cancel.`;
1330
- $("composeError").hidden = false;
1331
- $("composeAddLabel").textContent = "Retry";
1063
+ if (!current())
1064
+ return false;
1065
+ draft.error = `${err.message}. Retry or cancel.`;
1066
+ draft.retry = true;
1332
1067
  diagnostic("comment-request-failure", { status: err.status || 0 });
1333
1068
  announce("Comment could not be saved. Your draft is still here.");
1334
1069
  setComposeLifecycle("open", "submit-failed");
1070
+ render();
1335
1071
  $("compose").classList.remove("pass-through");
1336
1072
  afterPaint(() => $("composeText").focus());
1337
1073
  return false;
1338
1074
  }
1339
- finally {
1340
- button.disabled = false;
1341
- }
1342
1075
  }
1343
1076
  // ------------------------------------------------------------ review history
1344
1077
  const currentRender = () => frameController.identity();
1345
1078
  const captures = createCaptureRequests({ send: toFrame, current: currentRender });
1346
- const comparisonView = createComparisonView($("changeDetail"));
1347
1079
  const historyUrl = (suffix = "") => `/api/session/${state.sessionId}/history${suffix}`;
1348
1080
  const historyController = createHistoryController({
1349
1081
  request: (suffix) => api(historyUrl(suffix)), changed: renderHistory, refreshed: scheduleResultCapture,
@@ -1371,164 +1103,42 @@ function selectedTarget() {
1371
1103
  const targets = roundTargets(history.round);
1372
1104
  return targets.find((target) => target.key === history.targetKey) || targets[0] || null;
1373
1105
  }
1374
- function targetComparison(target) { return target?.comparison || {}; }
1375
- function comparisonItems() {
1376
- const comparison = targetComparison(selectedTarget());
1377
- const value = history.mode === "source" ? comparison.source : comparison.content;
1378
- return value?.changes || value?.items || (history.mode === "content" ? comparison.changes || comparison.items : []) || [];
1379
- }
1380
1106
  const refreshHistory = () => state.ended ? Promise.resolve(false) : historyController.refresh();
1107
+ export const changesRuntime = createChangesController({
1108
+ read: () => ({
1109
+ history, ended: state.ended, comparing: state.comparing, sending: feedbackController.sending,
1110
+ current: {
1111
+ key: frameController.state.key, kind: state.page?.kind,
1112
+ ready: !frameController.loading && !!frameController.state.readyAt,
1113
+ pendingReload: frameController.state.pendingReload,
1114
+ dirty: saveController.state.dirty || saveController.state.conflict,
1115
+ sourceHash: frameController.state.sourceHash || saveController.state.baseHash,
1116
+ sessionId: state.sessionId, generation: frameController.state.generation,
1117
+ },
1118
+ }),
1119
+ selectRound: (id) => historyController.selectRound(id),
1120
+ selectTarget: (key) => historyController.selectTarget(key),
1121
+ selectMode(mode) {
1122
+ history.preferredMode = mode;
1123
+ history.mode = mode;
1124
+ history.index = 0;
1125
+ renderHistory();
1126
+ },
1127
+ selectIndex(index) { history.index = index; renderHistory(); },
1128
+ capture: () => captureResult(history.round, selectedTarget()),
1129
+ finish: () => finishCapture(),
1130
+ refresh: refreshHistory,
1131
+ failed: toast,
1132
+ });
1133
+ reviewController.own(() => changesRuntime.dispose());
1134
+ reviewController.own(frameController.subscribe(() => changesRuntime.publish()));
1135
+ reviewController.own(saveController.subscribe(() => changesRuntime.publish()));
1136
+ reviewController.own(feedbackController.subscribe(() => changesRuntime.publish()));
1381
1137
  function renderHistory() {
1382
1138
  if (state.ended)
1383
1139
  return;
1384
1140
  syncFrameInteraction();
1385
- const focusedMode = document.activeElement?.dataset.comparisonMode;
1386
- const picker = $("roundPicker");
1387
- picker.replaceChildren();
1388
- for (const [index, round] of history.rounds.entries()) {
1389
- const option = document.createElement("option");
1390
- option.value = roundId(round);
1391
- option.textContent = `Round ${round.ordinal || round.number || history.rounds.length - index} · ${round.captureStatus === "ready" ? "completed" : round.captureStatus || round.status || round.feedbackStatus || "pending"}`;
1392
- picker.append(option);
1393
- }
1394
- picker.value = history.selectedId || "";
1395
- picker.disabled = !history.rounds.length;
1396
- const targets = roundTargets(history.round);
1397
- const target = selectedTarget();
1398
- if (history.round)
1399
- history.targetKey = target?.key || null;
1400
- $("historyTarget").replaceChildren();
1401
- for (const item of targets) {
1402
- const option = document.createElement("option");
1403
- option.value = item.key;
1404
- option.textContent = item.filename || item.label || item.key;
1405
- $("historyTarget").append(option);
1406
- }
1407
- $("historyTarget").value = history.targetKey || "";
1408
- $("historyTargetLabel").hidden = targets.length < 2;
1409
- const comparison = targetComparison(target);
1410
- const failedCapture = history.failures.get(`${roundId(history.round)}:${target?.key}`);
1411
- const presentation = historyPresentation({ ...history, target, comparison, failure: failedCapture });
1412
- const modes = presentation.modes;
1413
- history.mode = modes.includes(history.preferredMode) ? history.preferredMode : modes[0] || "content";
1414
- const displayedComparison = comparison[history.mode] || {};
1415
- $("historyUnavailable").replaceChildren();
1416
- for (const mode of ["content", "source"]) {
1417
- const representation = comparison[mode];
1418
- if (representation && representation.available === false) {
1419
- const reason = document.createElement("p");
1420
- reason.textContent = `${mode === "content" ? "Content" : "Source"} comparison unavailable: ${representation.reason || "This representation could not be compared."}`;
1421
- $("historyUnavailable").append(reason);
1422
- }
1423
- }
1424
- $("historyUnavailable").hidden = !$("historyUnavailable").childElementCount;
1425
- $("historyTiming").replaceChildren();
1426
- for (const [label, timestamp] of [
1427
- ["Before captured", displayedComparison.beforeCapturedAt],
1428
- ["Agent acknowledged", history.round?.acknowledgedAt],
1429
- ["After captured", displayedComparison.afterCapturedAt],
1430
- ]) {
1431
- const row = document.createElement("div");
1432
- const term = document.createElement("dt");
1433
- term.textContent = label;
1434
- const value = document.createElement("dd");
1435
- const date = timestamp == null ? null : new Date(timestamp);
1436
- value.textContent = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : "Not available";
1437
- row.append(term, value);
1438
- $("historyTiming").append(row);
1439
- }
1440
- $("historyTiming").hidden = !history.round;
1441
- const afterAt = displayedComparison.afterCapturedAt == null ? NaN : new Date(displayedComparison.afterCapturedAt).getTime();
1442
- const acknowledgedAt = history.round?.acknowledgedAt == null ? NaN : new Date(history.round.acknowledgedAt).getTime();
1443
- const delay = afterAt - acknowledgedAt;
1444
- $("historyCaptureDelay").hidden = !Number.isFinite(delay);
1445
- $("historyCaptureDelay").textContent = delay >= 0
1446
- ? `Result captured ${Math.round(delay / 1000)} seconds after acknowledgment—not at the acknowledgment instant. A delayed capture can include later changes.`
1447
- : "This result snapshot predates acknowledgment. It is not proof of the page state at acknowledgment.";
1448
- $("historyCurrentStatus").hidden = !target?.resultRevisionId && !displayedComparison.afterCapturedAt;
1449
- $("historyCurrentStatus").textContent = comparisonFreshness(displayedComparison, target?.key, {
1450
- key: frameController.state.key, kind: state.page?.kind, ready: !frameController.loading && !!frameController.state.readyAt,
1451
- pendingReload: frameController.state.pendingReload, dirty: saveController.state.dirty || saveController.state.conflict,
1452
- sourceHash: frameController.state.sourceHash || saveController.state.baseHash,
1453
- sessionId: state.sessionId, generation: frameController.state.generation,
1454
- });
1455
- const viewComparison = history.mode === "content" ? displayedComparison.viewComparison : null;
1456
- $("historyViewCoverage").hidden = !viewComparison?.message;
1457
- $("historyViewCoverage").textContent = viewComparison?.message || "";
1458
- $("historyViewCoverage").dataset.status = viewComparison?.status || "";
1459
- $("comparisonModes").replaceChildren();
1460
- for (const mode of modes) {
1461
- const button = makeAction(mode === "source" ? "Source" : "Content", "btn-ghost");
1462
- button.dataset.comparisonMode = mode;
1463
- button.setAttribute("aria-pressed", String(history.mode === mode));
1464
- button.addEventListener("click", () => {
1465
- history.preferredMode = mode;
1466
- history.mode = mode;
1467
- history.index = 0;
1468
- renderHistory();
1469
- $("comparisonModes").querySelector(`[data-comparison-mode="${mode}"]`)?.focus({ preventScroll: true });
1470
- });
1471
- $("comparisonModes").append(button);
1472
- }
1473
- if (focusedMode)
1474
- $("comparisonModes").querySelector(`[data-comparison-mode="${focusedMode}"]`)?.focus({ preventScroll: true });
1475
- if ($("historyStatus").textContent !== presentation.message)
1476
- $("historyStatus").textContent = presentation.message;
1477
- $("historyStatus").dataset.state = presentation.state;
1478
- const detail = history.error?.message || failedCapture || target?.capture?.error;
1479
- $("historyError").hidden = !detail;
1480
- $("historyError").textContent = detail || "";
1481
- const captureAllowed = target && !target.resultRevisionId && target.capture?.status !== "unavailable" && history.round?.feedbackStatus === "acknowledged";
1482
- $("captureResult").hidden = !captureAllowed;
1483
- $("captureResult").disabled = history.captureBusy || history.finalizing || target?.key !== frameController.state.key || frameController.loading;
1484
- $("captureResult").textContent = history.captureBusy ? "Capturing result…" : "Capture result";
1485
- const canFinalize = target && !target.resultRevisionId && history.round?.feedbackStatus === "acknowledged" &&
1486
- ["pending", "failed"].includes(target.capture?.status || target.captureStatus || "pending");
1487
- $("finishCapture").hidden = !canFinalize;
1488
- $("finishCaptureHelp").hidden = !canFinalize;
1489
- $("finishCapture").disabled = history.finalizing || history.captureBusy;
1490
- $("finishCapture").setAttribute("aria-describedby", "finishCaptureHelp");
1491
- $("finishCapture").textContent = history.finalizing ? "Finishing…" : "Finish with available snapshots";
1492
- const items = comparisonItems();
1493
- const limitations = [...new Set([
1494
- ...(comparison.content?.limitations || []),
1495
- ...(comparison.source?.limitations || []),
1496
- ])];
1497
- $("historyLimitations").hidden = limitations.length === 0;
1498
- $("historyLimitationsList").replaceChildren();
1499
- for (const limitation of limitations) {
1500
- const line = document.createElement("li");
1501
- line.textContent = String(limitation).replaceAll("-", " ").replaceAll("_", " ");
1502
- $("historyLimitationsList").append(line);
1503
- }
1504
- const counts = displayedComparison.counts;
1505
- $("historyCounts").textContent = modes.length && counts
1506
- ? `${counts.added} Added · ${counts.modified} Modified · ${counts.removed} Removed`
1507
- : "";
1508
- $("historyCounts").hidden = !counts;
1509
- history.index = Math.max(0, Math.min(history.index, items.length - 1));
1510
- $("changeDetail").hidden = !modes.length;
1511
- const comparisonHeader = $("historyPanel").querySelector(".comparison-header");
1512
- if (comparisonHeader)
1513
- comparisonHeader.hidden = !modes.length;
1514
- if (modes.length) {
1515
- comparisonView.render({ ...displayedComparison, changes: items }, {
1516
- mode: history.mode, key: `${history.selectedId}:${history.targetKey}`,
1517
- });
1518
- comparisonView.select(history.index);
1519
- }
1520
- $("changeJump").replaceChildren();
1521
- for (const [index, item] of items.entries()) {
1522
- const option = document.createElement("option");
1523
- option.value = index;
1524
- option.textContent = `${index + 1}. ${changeKind(item)} · ${tidyMiddle(item.label || excerpt(item.after) || excerpt(item.before) || "Structure", 65)}`;
1525
- $("changeJump").append(option);
1526
- }
1527
- $("changeJump").value = String(history.index);
1528
- $("changeNavigation").hidden = items.length < 2;
1529
- $("previousChange").disabled = history.index === 0;
1530
- $("nextChange").disabled = history.index >= items.length - 1;
1531
- $("changePosition").textContent = `${items.length ? history.index + 1 : 0} of ${items.length}`;
1141
+ changesRuntime.publish();
1532
1142
  }
1533
1143
  async function captureResult(round, target, automatic = false, signal) {
1534
1144
  if (history.captureBusy || !round || target?.key !== frameController.state.key)
@@ -1583,8 +1193,7 @@ async function captureResult(round, target, automatic = false, signal) {
1583
1193
  diagnostic("capture-failure-report-failed", { message: reportError.message });
1584
1194
  });
1585
1195
  }
1586
- $("historyError").hidden = false;
1587
- $("historyError").textContent = `${err.message} Use Capture result to retry; existing captures are unchanged.`;
1196
+ history.failures.set(`${roundId(round)}:${target.key}`, `${err.message} Use Capture result to retry; existing captures are unchanged.`);
1588
1197
  if (automatic)
1589
1198
  throw err;
1590
1199
  }
@@ -1602,35 +1211,21 @@ function scheduleResultCapture() {
1602
1211
  function setHistoryView(comparing) {
1603
1212
  captureEditState();
1604
1213
  state.comparing = comparing;
1214
+ changesRuntime.publish();
1605
1215
  closeModeMenu("history-switch");
1606
1216
  document.body.classList.toggle("comparing", comparing);
1607
1217
  syncFrameInteraction();
1608
1218
  frameHost.current.setAttribute("aria-hidden", String(comparing));
1609
1219
  $("historyPanel").hidden = !comparing;
1610
- $("latestVersion").setAttribute("aria-pressed", String(!comparing));
1611
- $("seeChanges").setAttribute("aria-pressed", String(comparing));
1612
1220
  if (!comparing && frameController.state.pendingReload)
1613
- $("reloadNotice").hidden = false;
1221
+ state.reloadVisible = true;
1614
1222
  if (comparing) {
1615
1223
  closeDrawer();
1616
1224
  void refreshHistory();
1617
1225
  }
1618
1226
  render();
1619
1227
  }
1620
- listen($("latestVersion"), "click", () => setHistoryView(false));
1621
- listen($("seeChanges"), "click", () => setHistoryView(true));
1622
- listen($("roundPicker"), "change", () => void historyController.selectRound($("roundPicker").value));
1623
- listen($("historyTarget"), "change", () => void historyController.selectTarget($("historyTarget").value));
1624
- function jumpToChange(index) {
1625
- history.index = index;
1626
- renderHistory();
1627
- comparisonView.select(history.index, { scroll: true });
1628
- }
1629
- listen($("previousChange"), "click", () => jumpToChange(history.index - 1));
1630
- listen($("nextChange"), "click", () => jumpToChange(history.index + 1));
1631
- listen($("changeJump"), "change", (event) => jumpToChange(Number(event.target.value)));
1632
- listen($("captureResult"), "click", () => void captureResult(history.round, selectedTarget()));
1633
- listen($("finishCapture"), "click", async () => {
1228
+ async function finishCapture() {
1634
1229
  const target = selectedTarget();
1635
1230
  const round = history.round;
1636
1231
  if (!target || !round || history.finalizing || history.captureBusy)
@@ -1638,23 +1233,23 @@ listen($("finishCapture"), "click", async () => {
1638
1233
  history.finalizing = true;
1639
1234
  renderHistory();
1640
1235
  try {
1641
- await api(historyUrl(`/${roundId(round)}/capture`), {
1236
+ const result = await api(historyUrl(`/${roundId(round)}/capture`), {
1642
1237
  method: "POST",
1643
1238
  body: JSON.stringify({ key: target.key, manual: true, finalUnavailable: true }),
1644
1239
  });
1240
+ requireCaptureSuccess(result, target.key);
1645
1241
  history.failures.delete(`${roundId(round)}:${target.key}`);
1646
1242
  await refreshHistory();
1647
1243
  announce("Finished with available snapshots. Missing Content remains unavailable.");
1648
1244
  }
1649
1245
  catch (err) {
1650
- $("historyError").hidden = false;
1651
- $("historyError").textContent = `${err.message} The capture is still open; you can retry.`;
1246
+ history.failures.set(`${roundId(round)}:${target.key}`, `${err.message} The capture is still open; you can retry.`);
1652
1247
  }
1653
1248
  finally {
1654
1249
  history.finalizing = false;
1655
1250
  renderHistory();
1656
1251
  }
1657
- });
1252
+ }
1658
1253
  // -------------------------------------------------------------------- saving
1659
1254
  const saveNow = (html) => saveController.save(html);
1660
1255
  // ------------------------------------------------------------ frame messages
@@ -1776,7 +1371,7 @@ listen(window, "message", async (event) => {
1776
1371
  state.compose.relation = geometry.relation;
1777
1372
  state.compose.clip = geometry.clip;
1778
1373
  state.compose.horizontal = geometry.horizontal ?? state.compose.horizontal;
1779
- positionCompose();
1374
+ scheduleContextualPosition();
1780
1375
  }
1781
1376
  break;
1782
1377
  case "eh:commentGeometry": {
@@ -1796,7 +1391,7 @@ listen(window, "message", async (event) => {
1796
1391
  visible: msg.visible !== false && rects.length > 0,
1797
1392
  });
1798
1393
  if (state.activeSavedCommentId === id) {
1799
- positionAlignedCard();
1394
+ scheduleContextualPosition();
1800
1395
  restoreTransientFocus(false);
1801
1396
  }
1802
1397
  break;
@@ -1809,10 +1404,10 @@ listen(window, "message", async (event) => {
1809
1404
  break;
1810
1405
  case "eh:interaction":
1811
1406
  closeModeMenu("frame-interaction");
1812
- closeCommentMenu();
1407
+ recoveryRuntime.commands.setMenuOpen(false, { restoreFocus: false });
1813
1408
  break;
1814
1409
  case "eh:dismiss":
1815
- if (!$("composeText").value.trim())
1410
+ if (!state.composerDraft.text.trim())
1816
1411
  cancelCompose();
1817
1412
  break;
1818
1413
  case "eh:activate":
@@ -1920,118 +1515,18 @@ listen(window, "message", async (event) => {
1920
1515
  }
1921
1516
  });
1922
1517
  // --------------------------------------------------------------- UI wiring
1923
- let composeComposing = false;
1924
- listen($("composeAdd"), "click", commitCompose);
1925
- listen($("composeCancel"), "click", cancelCompose);
1926
- listen($("composeClose"), "click", cancelCompose);
1927
- listen($("composeReveal"), "click", () => {
1928
- if (!state.compose)
1929
- return;
1930
- diagnostic("reveal-target-requested");
1931
- toFrame({ type: "eh:revealTarget", targetGeneration: state.compose.generation });
1932
- });
1933
- listen($("compose"), "mousedown", (event) => {
1934
- if (event.target.closest("button"))
1935
- return;
1936
- if (event.target !== $("composeText"))
1937
- $("composeText").focus();
1938
- });
1939
- listen($("composeText"), "compositionstart", () => {
1940
- composeComposing = true;
1941
- });
1942
- listen($("composeText"), "compositionend", () => {
1943
- composeComposing = false;
1944
- });
1945
- listen($("composeText"), "keydown", (event) => {
1946
- if (event.key === "Enter") {
1947
- event.stopPropagation();
1948
- if (composeComposing || event.isComposing || event.shiftKey)
1949
- return;
1950
- event.preventDefault();
1951
- void commitCompose();
1952
- }
1953
- if (event.key === "Escape") {
1954
- event.stopPropagation();
1955
- event.preventDefault();
1956
- cancelCompose();
1957
- }
1958
- });
1959
- listen($("modeButton"), "click", () => {
1960
- if ($("modeMenu").hidden)
1961
- openModeMenu();
1962
- else
1963
- closeModeMenu("trigger", { restoreFocus: true });
1964
- });
1965
- listen($("modeMenu"), "click", (event) => {
1966
- const item = event.target.closest("[data-mode]");
1967
- if (item)
1968
- void setReviewMode(item.dataset.mode);
1969
- });
1970
- listen($("modeMenu"), "keydown", (event) => {
1971
- const items = [...$("modeMenu").querySelectorAll("[data-mode]")];
1972
- const current = items.indexOf(document.activeElement);
1973
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1974
- event.preventDefault();
1975
- const delta = event.key === "ArrowDown" ? 1 : -1;
1976
- items[(current + delta + items.length) % items.length].focus();
1977
- }
1978
- else if (event.key === "Escape") {
1979
- event.preventDefault();
1980
- event.stopPropagation();
1981
- closeModeMenu("escape", { restoreFocus: true });
1982
- }
1983
- });
1984
- listen($("commentsButton"), "click", openDrawer);
1985
- listen($("drawerClose"), "click", closeDrawer);
1986
- listen($("drawerBackdrop"), "click", closeDrawer);
1987
- const dismissModeMenuOutside = (event) => {
1988
- if (!state.modeMenuOpen || event.target.closest(".mode-control"))
1989
- return;
1990
- closeModeMenu(event.type === "focusin" ? "parent-focus" : "parent-pointer");
1991
- };
1992
- listen(document, "pointerdown", dismissModeMenuOutside, true);
1993
- listen(document, "focusin", dismissModeMenuOutside, true);
1994
- function closeCommentMenu({ restoreFocus = false } = {}) {
1995
- const menu = state.commentUi.menu;
1996
- if (!menu)
1997
- return false;
1998
- clearOwned(state.commentUi, "menu");
1999
- toFrame({ type: "eh:modeMenuState", open: false });
2000
- if (restoreFocus)
2001
- requestControlFocus(menu.triggerId);
2002
- render();
2003
- return true;
2004
- }
2005
- const dismissCommentMenuOutside = (event) => {
2006
- const menu = state.commentUi.menu;
2007
- if (!menu)
2008
- return;
2009
- const menuElement = document.getElementById(controlId(menu.commentId, menu.surface, "menu"));
2010
- const trigger = document.getElementById(menu.triggerId);
2011
- if (menuElement?.contains(event.target) || trigger?.contains(event.target))
2012
- return;
2013
- closeCommentMenu();
2014
- };
2015
- listen(document, "pointerdown", dismissCommentMenuOutside, true);
2016
- listen(document, "focusin", dismissCommentMenuOutside, true);
2017
- const sendFeedback = () => feedbackController.send();
2018
- listen($("send"), "click", () => void sendFeedback());
2019
- listen($("revert"), "click", async () => {
2020
- const count = state.page.edits.length;
2021
- if (!window.confirm(`Discard all ${count} of your edits?`))
2022
- return;
2023
- try {
2024
- await saveController.revert();
2025
- }
2026
- catch (err) {
2027
- toast(err.message);
2028
- }
2029
- });
2030
1518
  /** The session is over: freeze the page and say so. Feedback is already safe. */
2031
1519
  function showEnded() {
2032
1520
  if (document.querySelector(".ended"))
2033
1521
  return;
2034
1522
  state.ended = true;
1523
+ state.modeMenuOpen = false;
1524
+ toolbarRuntime.publish();
1525
+ recoveryRuntime.publish();
1526
+ commentsRuntime.publish();
1527
+ contextualRuntime.publish();
1528
+ feedbackRuntime.publish();
1529
+ changesRuntime.publish();
2035
1530
  captureCoordinator.stop();
2036
1531
  manualCaptureController?.abort();
2037
1532
  captures.cancel("Review ended");
@@ -2046,60 +1541,25 @@ function showEnded() {
2046
1541
  overlay.append(title, line);
2047
1542
  document.body.append(overlay);
2048
1543
  }
2049
- listen($("endReview"), "click", async () => {
2050
- const page = state.page;
2051
- const otherTotal = (state.others || []).reduce((sum, o) => sum + o.count, 0);
2052
- const unsent = page ? (page.comments || []).length + (page.edits || []).length + otherTotal : 0;
2053
- const message = unsent
2054
- ? `End this review? ${unsent} unsent ${unsent === 1 ? "item" : "items"} will be kept for next time.`
2055
- : "End this review? The waiting agent will be told to stop polling.";
2056
- if (!window.confirm(message))
2057
- return;
2058
- try {
2059
- // Ship anything still sitting in the SDK's debounce windows first.
2060
- await flushFrame();
2061
- await api(`/api/session/${state.sessionId}/end`, { method: "POST" });
2062
- showEnded();
2063
- }
2064
- catch (err) {
2065
- toast(err.message);
2066
- }
2067
- });
2068
- listen($("handoffCopy"), "click", async (event) => {
2069
- const button = event.currentTarget;
2070
- try {
2071
- await navigator.clipboard.writeText($("handoffCmd").textContent);
2072
- button.textContent = "Copied";
2073
- afterDelay(() => {
2074
- button.textContent = "Copy prompt";
2075
- }, 1600);
2076
- }
2077
- catch {
2078
- toast("Couldn't copy — select the prompt and copy it manually");
2079
- }
2080
- });
2081
- listen($("note"), "input", (event) => {
2082
- const el = event.target;
2083
- el.style.height = "auto";
2084
- el.style.height = `${Math.min(el.scrollHeight + 2, window.innerHeight * 0.4)}px`;
2085
- render(); // keep the send button in step with note-only feedback
2086
- });
2087
- listen($("theme"), "click", () => {
1544
+ function toggleTheme() {
2088
1545
  const dark = document.documentElement.dataset.theme !== "dark";
2089
1546
  applyTheme(dark);
2090
1547
  try {
2091
1548
  localStorage.setItem("doc-review:theme", dark ? "dark" : "light");
2092
1549
  }
2093
- catch { }
2094
- });
1550
+ catch (error) {
1551
+ diagnostic("theme-preference-save-failed", { message: error.message });
1552
+ announce("Theme changed for this review, but the preference could not be saved.");
1553
+ }
1554
+ }
2095
1555
  function applyTheme(dark) {
2096
1556
  document.documentElement.dataset.theme = dark ? "dark" : "light";
2097
- const button = $("theme");
2098
- button.title = dark ? "Switch chrome to light" : "Switch chrome to dark";
2099
- button.setAttribute("aria-label", button.title);
2100
- replaceIcon(button, dark ? "sun" : "moon");
1557
+ state.theme = dark ? "dark" : "light";
1558
+ toolbarRuntime.publish();
2101
1559
  }
2102
1560
  listen(document, "keydown", (event) => {
1561
+ if (event.defaultPrevented || feedbackRuntime.getSnapshot().dialog)
1562
+ return;
2103
1563
  const meta = event.metaKey || event.ctrlKey;
2104
1564
  // ⌘S is reassurance only: flush pending keystrokes, never a state change.
2105
1565
  if (meta && event.key.toLowerCase() === "s") {
@@ -2108,25 +1568,11 @@ listen(document, "keydown", (event) => {
2108
1568
  renderSave();
2109
1569
  return;
2110
1570
  }
2111
- if (event.key === "Tab" && state.commentUi.menu) {
2112
- closeCommentMenu();
2113
- return;
2114
- }
2115
1571
  if (event.key !== "Escape")
2116
1572
  return;
2117
- const recoveryMenu = event.target.closest?.("#reviewDetails");
2118
- if (recoveryMenu) {
2119
- recoveryMenu.open = false;
2120
- recoveryMenu.querySelector("summary")?.focus({ preventScroll: true });
2121
- event.preventDefault();
2122
- event.stopPropagation();
2123
- return;
2124
- }
2125
1573
  let handled = false;
2126
1574
  if (state.commentUi.confirmation)
2127
1575
  handled = cancelDeleteConfirmation();
2128
- else if (state.commentUi.menu)
2129
- handled = closeCommentMenu({ restoreFocus: true });
2130
1576
  else if (state.commentUi.edit)
2131
1577
  handled = cancelCommentEdit();
2132
1578
  else if (state.compose)
@@ -2148,8 +1594,9 @@ listen(document, "keydown", (event) => {
2148
1594
  function holdReload() {
2149
1595
  frameController.holdReload();
2150
1596
  saveController.hold();
2151
- $("reloadNotice").hidden = false;
2152
- $("reloadMessage").textContent = saveController.state.dirty || saveController.state.conflict
1597
+ state.reloadVisible = true;
1598
+ state.reloadFailed = false;
1599
+ state.reloadMessage = saveController.state.dirty || saveController.state.conflict
2153
1600
  ? "The source changed. This page has unsaved edits; reload discards those page edits, but keeps open comment drafts. Stale edits will not overwrite the new source."
2154
1601
  : "The source changed. Reload keeps your comment drafts as unresolved excerpts so they cannot attach to the wrong content.";
2155
1602
  captureCoordinator.reset();
@@ -2175,9 +1622,9 @@ async function reloadLatest({ explicit = false } = {}) {
2175
1622
  }
2176
1623
  const generation = beginFrameTransition();
2177
1624
  frameController.resetRetries();
1625
+ state.reloadVisible = false;
2178
1626
  frameController.startReload();
2179
1627
  saveController.reset();
2180
- $("reloadNotice").hidden = true;
2181
1628
  try {
2182
1629
  const page = await api(pageUrl(key, state.sessionId), undefined, decodePage);
2183
1630
  if (frameController.state.key !== key || frameController.state.generation !== generation)
@@ -2186,13 +1633,10 @@ async function reloadLatest({ explicit = false } = {}) {
2186
1633
  // Keep parent-owned drafts rather than replacing their text on a source update.
2187
1634
  replacePage(state, page);
2188
1635
  saveController.reset();
2189
- render();
2190
1636
  if (state.compose) {
2191
- $("compose").hidden = false;
2192
- $("compose").classList.add("sheet");
2193
- $("composeError").hidden = false;
2194
- $("composeError").textContent = "The source changed. This draft keeps its original excerpt; cancel it to select a new target.";
1637
+ state.composerDraft.error = "The source changed. This draft keeps its original excerpt; cancel it to select a new target.";
2195
1638
  }
1639
+ render();
2196
1640
  await registerFrame(key, generation);
2197
1641
  }
2198
1642
  catch (err) {
@@ -2200,10 +1644,8 @@ async function reloadLatest({ explicit = false } = {}) {
2200
1644
  failFrame(err.message);
2201
1645
  }
2202
1646
  }
2203
- listen($("safeReload"), "click", () => void reloadLatest({ explicit: true }));
2204
- listen($("keepCurrent"), "click", () => { $("reloadNotice").hidden = true; announce("Keeping the current page. Return to Latest version to reload when ready."); });
2205
1647
  listen(window, "beforeunload", (event) => {
2206
- if (!saveController.state.dirty && !draftCount(state, $("note").value) && !saveController.queued(frameController.state.key))
1648
+ if (!saveController.state.dirty && !draftCount(state, state.noteDraft.text) && !saveController.queued(frameController.state.key))
2207
1649
  return;
2208
1650
  event.preventDefault();
2209
1651
  event.returnValue = "";
@@ -2212,6 +1654,12 @@ listen(window, "pagehide", (event) => {
2212
1654
  if (event.persisted)
2213
1655
  return;
2214
1656
  state.ended = true;
1657
+ state.modeMenuOpen = false;
1658
+ toolbarRuntime.publish();
1659
+ recoveryRuntime.publish();
1660
+ commentsRuntime.publish();
1661
+ contextualRuntime.publish();
1662
+ changesRuntime.publish();
2215
1663
  captureCoordinator.stop();
2216
1664
  manualCaptureController?.abort();
2217
1665
  captures.cancel("Review closed");
@@ -2225,7 +1673,10 @@ const connect = () => reviewController.connect();
2225
1673
  try {
2226
1674
  applyTheme(localStorage.getItem("doc-review:theme") === "dark");
2227
1675
  }
2228
- catch { }
1676
+ catch (error) {
1677
+ applyTheme(false);
1678
+ diagnostic("theme-preference-read-failed", { message: error.message });
1679
+ }
2229
1680
  const bootstrap = await api(`/api/session/${state.sessionId}/page`);
2230
1681
  if (bootstrap && bootstrap.page)
2231
1682
  state.pollCommand = bootstrap.page.pollCommand;
@@ -2238,18 +1689,12 @@ const connect = () => reviewController.connect();
2238
1689
  })().catch((error) => {
2239
1690
  failFrame(`Review could not start: ${error.message}`);
2240
1691
  });
2241
- listen(window, "resize", () => {
2242
- positionCompose();
2243
- if (state.activeSavedCommentId)
2244
- positionAlignedCard();
2245
- });
1692
+ listen(window, "resize", scheduleContextualPosition);
2246
1693
  if (window.visualViewport) {
2247
- listen(window.visualViewport, "resize", positionCompose);
2248
- listen(window.visualViewport, "scroll", positionCompose);
1694
+ listen(window.visualViewport, "resize", scheduleContextualPosition);
1695
+ listen(window.visualViewport, "scroll", scheduleContextualPosition);
2249
1696
  }
2250
1697
  reviewController.own(frameHost.onFocus(() => {
2251
- closeCommentMenu();
2252
1698
  if (state.compose)
2253
1699
  $("compose").classList.add("pass-through");
2254
1700
  }));
2255
- listen($("compose"), "focusin", () => $("compose").classList.remove("pass-through"));