@1agh/maude 0.15.0 → 0.17.1

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.
Files changed (53) hide show
  1. package/README.md +4 -2
  2. package/cli/commands/design.mjs +108 -2
  3. package/package.json +12 -18
  4. package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
  5. package/plugins/design/dev-server/annotations-layer.tsx +8 -10
  6. package/plugins/design/dev-server/api.ts +227 -3
  7. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
  8. package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
  9. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
  10. package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
  11. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
  12. package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
  13. package/plugins/design/dev-server/canvas-lib.tsx +12 -13
  14. package/plugins/design/dev-server/canvas-shell.tsx +111 -9
  15. package/plugins/design/dev-server/client/app.jsx +71 -143
  16. package/plugins/design/dev-server/client/comments-overlay.css +381 -0
  17. package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
  18. package/plugins/design/dev-server/client/styles/4-components.css +5 -161
  19. package/plugins/design/dev-server/client/styles.css +5 -160
  20. package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
  21. package/plugins/design/dev-server/context-menu.tsx +36 -9
  22. package/plugins/design/dev-server/dist/client.bundle.js +52 -211
  23. package/plugins/design/dev-server/dist/styles.css +1 -218
  24. package/plugins/design/dev-server/export-dialog.tsx +401 -0
  25. package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
  26. package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
  27. package/plugins/design/dev-server/exporters/canva.ts +126 -0
  28. package/plugins/design/dev-server/exporters/html.ts +103 -0
  29. package/plugins/design/dev-server/exporters/index.ts +135 -0
  30. package/plugins/design/dev-server/exporters/pdf.ts +109 -0
  31. package/plugins/design/dev-server/exporters/png.ts +136 -0
  32. package/plugins/design/dev-server/exporters/pptx.ts +263 -0
  33. package/plugins/design/dev-server/exporters/scope.ts +196 -0
  34. package/plugins/design/dev-server/exporters/svg.ts +122 -0
  35. package/plugins/design/dev-server/exporters/zip.ts +109 -0
  36. package/plugins/design/dev-server/http.ts +109 -0
  37. package/plugins/design/dev-server/input-router.tsx +21 -0
  38. package/plugins/design/dev-server/inspect.ts +1 -1
  39. package/plugins/design/dev-server/server.mjs +1 -1
  40. package/plugins/design/dev-server/test/canvas-meta-api.test.ts +0 -10
  41. package/plugins/design/dev-server/test/comments-api.test.ts +229 -0
  42. package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
  43. package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
  44. package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
  45. package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
  46. package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
  47. package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
  48. package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
  49. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  50. package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
  51. package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
  52. package/plugins/design/dev-server/tool-palette.tsx +34 -16
  53. package/plugins/design/templates/_shell.html +33 -0
@@ -42,6 +42,7 @@ import {
42
42
  type ViewportControllerHandle,
43
43
  useViewportControllerContext,
44
44
  } from './canvas-lib.tsx';
45
+ import { CommentsOverlay } from './comments-overlay.tsx';
45
46
  import {
46
47
  ContextMenuProvider,
47
48
  type ContextRegistry,
@@ -50,6 +51,7 @@ import {
50
51
  type MenuItem,
51
52
  useContextMenu,
52
53
  } from './context-menu.tsx';
54
+ import { ExportDialogProvider } from './export-dialog.tsx';
53
55
  import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
54
56
  import { ToolPalette } from './tool-palette.tsx';
55
57
  import {
@@ -202,9 +204,11 @@ function CanvasCore({
202
204
  );
203
205
 
204
206
  return (
205
- <ContextMenuProvider registry={registry}>
206
- <CanvasRouter hostRef={hostRef}>{children}</CanvasRouter>
207
- </ContextMenuProvider>
207
+ <ExportDialogProvider>
208
+ <ContextMenuProvider registry={registry}>
209
+ <CanvasRouter hostRef={hostRef}>{children}</CanvasRouter>
210
+ </ContextMenuProvider>
211
+ </ExportDialogProvider>
208
212
  );
209
213
  }
210
214
 
@@ -262,6 +266,23 @@ function buildRegistry(deps: {
262
266
  onSelect: () => controller?.reset(),
263
267
  };
264
268
 
269
+ // Phase 6.5 — context-menu → ExportDialog. Each entry dispatches a custom
270
+ // event the dialog provider listens for; this avoids prop-drilling the
271
+ // dialog handle through every menu callback. The scope arg prefills the
272
+ // dialog's scope dropdown so the user lands on the right resolution.
273
+ const exportItem = (id: string, label: string, scope: string, shortcut?: string): MenuItem => ({
274
+ id,
275
+ label,
276
+ shortcut,
277
+ onSelect: () => {
278
+ try {
279
+ window.dispatchEvent(new CustomEvent('maude:open-export', { detail: { scope } }));
280
+ } catch {
281
+ /* non-window environments */
282
+ }
283
+ },
284
+ });
285
+
265
286
  return {
266
287
  element: [
267
288
  [
@@ -297,6 +318,7 @@ function buildRegistry(deps: {
297
318
  },
298
319
  },
299
320
  ],
321
+ [exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
300
322
  [
301
323
  {
302
324
  id: 'hide',
@@ -333,8 +355,15 @@ function buildRegistry(deps: {
333
355
  fitItem,
334
356
  resetItem,
335
357
  ],
358
+ [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
359
+ ],
360
+ world: [
361
+ [fitItem, resetItem],
362
+ [
363
+ exportItem('export-canvas', 'Export canvas as separate…', 'canvas-as-separate'),
364
+ exportItem('export-project', 'Export project (ZIP)…', 'project-raw'),
365
+ ],
336
366
  ],
337
- world: [[fitItem, resetItem]],
338
367
  overlay: [],
339
368
  };
340
369
  }
@@ -469,17 +498,89 @@ function CanvasRouter({
469
498
  setHoverEl(null);
470
499
  },
471
500
  onDropComment: ({ clientX, clientY }) => {
472
- const target = resolveHoverTarget(document, clientX, clientY, { deep: true });
473
- if (!target) return;
501
+ // First try deep mode preferred when the user clicks exactly on
502
+ // a stamped element. When the deep hit lands on an element with
503
+ // `pointer-events: none` (decorative <svg> children, overlay icons),
504
+ // elementFromPoint propagates past it and `resolveHoverTarget`
505
+ // returns null because the next-closest hit is `.dc-artboard-body`
506
+ // itself.
507
+ let target = resolveHoverTarget(document, clientX, clientY, { deep: true });
508
+ if (!target) target = resolveHoverTarget(document, clientX, clientY, { deep: false });
509
+ // Phase 6 fallback — when both resolveHoverTarget passes bail (the
510
+ // `hit === bodyEl` early-exit triggers on `pointer-events: none`
511
+ // decorations), enumerate every element under the click point and
512
+ // climb the first one that has `data-cd-id`. This is how clicks on
513
+ // SVG logos / icon glyphs land on the actual stamped wrapper.
514
+ if (!target && typeof document.elementsFromPoint === 'function') {
515
+ const stack = document.elementsFromPoint(clientX, clientY);
516
+ for (const candidate of stack) {
517
+ const stamped = (candidate as Element).closest?.('[data-cd-id]') as HTMLElement | null;
518
+ if (!stamped) continue;
519
+ if (!stamped.closest('.dc-artboard-body')) continue;
520
+ const artboardEl = stamped.closest('[data-dc-screen]');
521
+ target = {
522
+ el: stamped,
523
+ cdId: stamped.getAttribute('data-cd-id'),
524
+ artboardId: artboardEl?.getAttribute('data-dc-screen') ?? null,
525
+ };
526
+ break;
527
+ }
528
+ }
529
+ if (!target) {
530
+ // Floating comment fallback — no element anchor, just a click
531
+ // point. The overlay still renders a pin at the stored bounds.
532
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
533
+ const floatingSel: Selection = {
534
+ file: deriveFile(),
535
+ id: undefined,
536
+ selector: '',
537
+ artboardId: null,
538
+ tag: '',
539
+ classes: '',
540
+ text: '',
541
+ dom_path: [],
542
+ bounds: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
543
+ html: '',
544
+ };
545
+ try {
546
+ document.dispatchEvent(
547
+ new CustomEvent('cm:open-composer', {
548
+ detail: { selection: floatingSel, clientX, clientY },
549
+ })
550
+ );
551
+ } catch {
552
+ /* ignore */
553
+ }
554
+ try {
555
+ window.parent.postMessage({ dgn: 'comment-compose', selection: floatingSel }, '*');
556
+ } catch {
557
+ /* parent detached */
558
+ }
559
+ return;
560
+ }
474
561
  const sel = hoverTargetToSelection(target);
475
562
  // Commit the target to the selection set so the halo persists while
476
- // the shell-side composer is open. The user clears by:
477
- // - submit / cancel on the composer (shell posts `force-clear`)
563
+ // the composer is open. The user clears by:
564
+ // - submit / cancel on the composer (overlay dispatches force-clear)
478
565
  // - pressing Esc inside the canvas (router's onEscape → clear)
479
566
  // - clicking another element in comment mode (this handler runs
480
567
  // again and replaces)
481
568
  selSet.replace(sel);
482
- if (typeof window === 'undefined') return;
569
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
570
+ // Phase 6 — open the in-place composer inside the iframe at the click
571
+ // point. Custom event is iframe-local so the overlay can subscribe
572
+ // without round-tripping through the parent shell.
573
+ try {
574
+ document.dispatchEvent(
575
+ new CustomEvent('cm:open-composer', {
576
+ detail: { selection: sel, clientX, clientY },
577
+ })
578
+ );
579
+ } catch {
580
+ /* CustomEvent absent — fall through to legacy parent path */
581
+ }
582
+ // Still post to parent for back-compat with any legacy `.html` mocks
583
+ // whose inspector script consumes `comment-compose`.
483
584
  try {
484
585
  window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
485
586
  } catch {
@@ -492,6 +593,7 @@ function CanvasRouter({
492
593
  return (
493
594
  <>
494
595
  {children}
596
+ <CommentsOverlay />
495
597
  <AnnotationsLayer />
496
598
  <ToolPalette />
497
599
  <HoverHalo el={hoverEl} />
@@ -1208,64 +1208,21 @@ function Gallery({ title, items, onOpen, kind }) {
1208
1208
 
1209
1209
  // ---------- Comment composer / viewer ----------
1210
1210
 
1211
- function CommentBar({ activePath, selected, comments, focusedId, draft, setDraft, onSubmit, onCancel, onResolve, onReopen, onDelete, onFocusPin }) {
1211
+ function CommentBar({ activePath, comments }) {
1212
+ // Phase 6 — the shell-side composer + chip strip + focused-row chrome were
1213
+ // removed. The iframe overlay (comments-overlay.tsx) owns composer + pin
1214
+ // bubbles + thread popover. BottomBar shrinks to a live open-count summary
1215
+ // so the shell still surfaces total review activity at a glance.
1212
1216
  if (!activePath) return null;
1213
- const focused = focusedId ? comments.find(c => c.id === focusedId) : null;
1214
1217
  const openComments = (comments || []).filter(c => c.status !== 'resolved');
1218
+ if (openComments.length === 0) return null;
1215
1219
  return (
1216
1220
  <div className="comment-bar">
1217
- {draft && draft.file === activePath && (
1218
- <div className="composer">
1219
- <div className="composer-head">
1220
- <span className="cb-label">Comment on</span>
1221
- <code className="composer-selector" title={(draft.dom_path || []).join(' > ')}>{draft.selector || '(canvas)'}</code>
1222
- </div>
1223
- <textarea
1224
- autoFocus
1225
- className="composer-textarea"
1226
- value={draft.text}
1227
- placeholder="What should change here? (⌘↵ save · Esc cancel)"
1228
- onChange={e => setDraft({ ...draft, text: e.target.value })}
1229
- onKeyDown={e => {
1230
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); }
1231
- else if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
1232
- }}
1233
- rows={4}
1234
- />
1235
- <div className="composer-actions">
1236
- <button className="cb-secondary" onClick={onCancel}>Cancel</button>
1237
- <button className="cb-primary" disabled={!draft.text.trim()} onClick={onSubmit}>Save · ⌘↵</button>
1238
- </div>
1239
- </div>
1240
- )}
1241
-
1242
- {focused && (
1243
- <div className="cb-row focused">
1244
- <span className="cb-pinno">#{(comments || []).filter(c => c.selector).findIndex(c => c.id === focused.id) + 1}</span>
1245
- <span className="cb-text">{focused.text}</span>
1246
- <span className="cb-target" title={focused.dom_path ? focused.dom_path.join(' > ') : ''}>
1247
- <code>{focused.selector || '—'}</code>
1248
- </span>
1249
- {focused.status === 'resolved'
1250
- ? <button className="cb-secondary" onClick={() => onReopen(focused.id)}>Reopen</button>
1251
- : <button className="cb-primary" onClick={() => onResolve(focused.id)}>✓ Resolve</button>}
1252
- <button className="cb-secondary" onClick={() => onDelete(focused.id)}>Delete</button>
1253
- </div>
1254
- )}
1255
-
1256
- {!draft && !focused && openComments.length > 0 && (
1257
- <div className="cb-row strip">
1258
- <span className="cb-label">{openComments.length} open comment{openComments.length === 1 ? '' : 's'}</span>
1259
- <div className="cb-pin-strip">
1260
- {openComments.slice(0, 12).map((c, i) => (
1261
- <button key={c.id} className="cb-pin-chip" title={c.text} onClick={() => onFocusPin(c.id)}>
1262
- {i + 1}
1263
- </button>
1264
- ))}
1265
- {openComments.length > 12 && <span className="cb-more">+{openComments.length - 12}</span>}
1266
- </div>
1267
- </div>
1268
- )}
1221
+ <div className="cb-row strip">
1222
+ <span className="cb-label">
1223
+ {openComments.length} open comment{openComments.length === 1 ? '' : 's'}
1224
+ </span>
1225
+ </div>
1269
1226
  </div>
1270
1227
  );
1271
1228
  }
@@ -1278,7 +1235,7 @@ function StatusBarSlot({ label, children, className = '' }) {
1278
1235
  );
1279
1236
  }
1280
1237
 
1281
- function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected, onAddComment, hasDraft }) {
1238
+ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected }) {
1282
1239
  const isSystem = activePath === SYSTEM_TAB;
1283
1240
  const text = selected && selected.selector
1284
1241
  ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
@@ -1297,9 +1254,6 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
1297
1254
  <StatusBarSlot label="Selected element" className="sb-selected">
1298
1255
  <span className="sb-dot" aria-hidden="true">●</span>
1299
1256
  <span className="sb-sel-text" title={title}>{text}</span>
1300
- {!hasDraft && (
1301
- <button type="button" className="sb-add-comment" onClick={onAddComment} title="Add comment on selected element (⌘⇧+click in canvas)">+ comment</button>
1302
- )}
1303
1257
  <button type="button" className="sb-clear-sel" onClick={onClearSelected} title="Clear (Esc inside iframe)" aria-label="Clear selection">×</button>
1304
1258
  </StatusBarSlot>
1305
1259
  )}
@@ -1454,7 +1408,8 @@ function App() {
1454
1408
  return () => { cancelled = true; };
1455
1409
  }, []);
1456
1410
  const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
1457
- const [draft, setDraft] = useState(null); // { file, selector, dom_path, bounds, tag, classes, html, text }
1411
+ // Phase 6 the in-iframe composer owns drafting; the shell no longer holds
1412
+ // a `draft` state. Mutations route through postMessage → WS instead.
1458
1413
  const [focusedCommentId, setFocusedCommentId] = useState(null);
1459
1414
  const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
1460
1415
  const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
@@ -1609,7 +1564,6 @@ function App() {
1609
1564
  });
1610
1565
  setActivePath(path);
1611
1566
  setFocusedCommentId(null);
1612
- setDraft(null);
1613
1567
  }, []);
1614
1568
 
1615
1569
  const openSystem = useCallback(() => {
@@ -1672,30 +1626,6 @@ function App() {
1672
1626
  try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
1673
1627
  }, [activePath, commentsByFile]);
1674
1628
 
1675
- // ----- Comment composer helpers -----
1676
- // Declared BEFORE the inbound-message useEffect that references them — under
1677
- // ES build (no var-style hoisting) these are real TDZ violations otherwise.
1678
- const startDraftFor = useCallback((sel) => {
1679
- const file = (sel && sel.file) || activePath;
1680
- if (!file || file === SYSTEM_TAB) return;
1681
- setDraft({
1682
- file,
1683
- selector: sel?.selector || '',
1684
- dom_path: sel?.dom_path || [],
1685
- tag: sel?.tag || '',
1686
- classes: sel?.classes || '',
1687
- bounds: sel?.bounds || null,
1688
- html: sel?.html || '',
1689
- text: '',
1690
- });
1691
- setFocusedCommentId(null);
1692
- }, [activePath]);
1693
-
1694
- const startDraftFromSelection = useCallback(() => {
1695
- if (!selected || !selected.selector) return;
1696
- startDraftFor(selected);
1697
- }, [selected, startDraftFor]);
1698
-
1699
1629
  // ----- Inbound messages from iframes -----
1700
1630
  useEffect(() => {
1701
1631
  function onMessage(e) {
@@ -1728,13 +1658,40 @@ function App() {
1728
1658
  wsSend({ type: 'clear-select' });
1729
1659
  setSelected(null);
1730
1660
  } else if (m.dgn === 'comment-compose' && m.selection) {
1731
- // Canvas C-tool / right-click "Add comment" converge here. The shell
1732
- // opens a composer for the target.
1733
- startDraftFor(m.selection);
1734
- } else if (m.dgn === 'comment-shortcut') {
1735
- // Carry-over for any legacy `.html` mock or external embed that
1736
- // still posts this. Canvas-shell uses `comment-compose` directly.
1737
- startDraftFromSelection();
1661
+ // Phase 6 the iframe overlay owns the composer surface now. The
1662
+ // shell just mirrors `selected` so the StatusBar / sidebar still
1663
+ // reflect the target, and skips the legacy `startDraftFor` path that
1664
+ // opened the shell-side composer. Legacy `.html` mocks (no
1665
+ // canvas-shell mount) fall through to the same path; they lose the
1666
+ // shell composer in this phase. Acceptable per Phase 6 scope.
1667
+ setSelected(m.selection);
1668
+ } else if (m.dgn === 'comment-submit' && m.payload && typeof m.payload.text === 'string') {
1669
+ // Phase 6 — iframe overlay finished composing. Relay through the
1670
+ // existing WS `comments-add` channel; server-side persistence +
1671
+ // broadcast back are identical to the legacy shell-composer flow.
1672
+ const p = m.payload;
1673
+ const txt = String(p.text).trim();
1674
+ if (txt) {
1675
+ wsSend({
1676
+ type: 'comments-add',
1677
+ payload: {
1678
+ file: p.file,
1679
+ selector: p.selector,
1680
+ dom_path: p.dom_path,
1681
+ tag: p.tag,
1682
+ classes: p.classes,
1683
+ bounds: p.bounds,
1684
+ html_excerpt: p.html_excerpt,
1685
+ text: txt,
1686
+ },
1687
+ });
1688
+ }
1689
+ } else if (m.dgn === 'comment-patch' && m.id && m.patch && typeof m.patch === 'object') {
1690
+ // Phase 6 — thread popover routes resolve / reopen through here.
1691
+ wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
1692
+ } else if (m.dgn === 'comment-delete' && m.id) {
1693
+ wsSend({ type: 'comments-delete', id: m.id });
1694
+ setFocusedCommentId(prev => (prev === m.id ? null : prev));
1738
1695
  } else if (m.dgn === 'comment-click' && m.id) {
1739
1696
  setFocusedCommentId(m.id);
1740
1697
  } else if (m.dgn === 'loaded' && m.file) {
@@ -1751,7 +1708,7 @@ function App() {
1751
1708
  }
1752
1709
  window.addEventListener('message', onMessage);
1753
1710
  return () => window.removeEventListener('message', onMessage);
1754
- }, [commentsByFile, focusedCommentId, startDraftFromSelection, startDraftFor]);
1711
+ }, [commentsByFile, focusedCommentId]);
1755
1712
 
1756
1713
  // Tell the active canvas iframe to drop any persistent selection (canvas
1757
1714
  // SelectionSet) — used when the comment composer closes via submit /
@@ -1765,27 +1722,6 @@ function App() {
1765
1722
  }
1766
1723
  }, [activePath]);
1767
1724
 
1768
- const submitDraft = useCallback(() => {
1769
- if (!draft || !draft.text.trim()) return;
1770
- wsSend({ type: 'comments-add', payload: {
1771
- file: draft.file,
1772
- selector: draft.selector,
1773
- dom_path: draft.dom_path,
1774
- tag: draft.tag,
1775
- classes: draft.classes,
1776
- bounds: draft.bounds,
1777
- html_excerpt: draft.html,
1778
- text: draft.text.trim(),
1779
- }});
1780
- setDraft(null);
1781
- clearActiveCanvasSelection();
1782
- }, [draft, clearActiveCanvasSelection]);
1783
-
1784
- const cancelDraft = useCallback(() => {
1785
- setDraft(null);
1786
- clearActiveCanvasSelection();
1787
- }, [clearActiveCanvasSelection]);
1788
-
1789
1725
  const resolveComment = useCallback((id) => {
1790
1726
  wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
1791
1727
  }, []);
@@ -1797,16 +1733,6 @@ function App() {
1797
1733
  setFocusedCommentId(prev => (prev === id ? null : prev));
1798
1734
  }, []);
1799
1735
 
1800
- const focusPinFromBar = useCallback((id) => {
1801
- setFocusedCommentId(id);
1802
- if (activePath && activePath !== SYSTEM_TAB) {
1803
- const el = iframesRef.current.get(activePath);
1804
- if (el && el.contentWindow) {
1805
- try { el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*'); } catch {}
1806
- }
1807
- }
1808
- }, [activePath]);
1809
-
1810
1736
  // Jump from right-sidebar list to a comment: open file tab if needed, focus pin.
1811
1737
  // The iframe may be freshly mounted; the loaded handler also re-sends focus if focusedCommentId matches.
1812
1738
  const jumpToComment = useCallback((file, id) => {
@@ -1918,15 +1844,15 @@ function App() {
1918
1844
  setHelpOpen(true);
1919
1845
  return;
1920
1846
  }
1921
- // Esc — close composer (in addition to its own textarea handler) or clear focused pin
1847
+ // Esc — clear focused pin. The in-place composer (Phase 6) and thread
1848
+ // popover handle their own Esc inside the iframe.
1922
1849
  if (e.key === 'Escape') {
1923
- if (draft) { setDraft(null); clearActiveCanvasSelection(); return; }
1924
1850
  if (focusedCommentId) { setFocusedCommentId(null); return; }
1925
1851
  }
1926
1852
  }
1927
1853
  window.addEventListener('keydown', onKey);
1928
1854
  return () => window.removeEventListener('keydown', onKey);
1929
- }, [reloadActive, selected, activePath, startDraftFromSelection, draft, focusedCommentId, sidebarOpen, openSystem, closeTab, clearActiveCanvasSelection]);
1855
+ }, [reloadActive, selected, activePath, focusedCommentId, sidebarOpen, openSystem, closeTab, clearActiveCanvasSelection]);
1930
1856
 
1931
1857
  const registerIframe = useCallback((path, el) => {
1932
1858
  if (el) iframesRef.current.set(path, el);
@@ -1935,8 +1861,25 @@ function App() {
1935
1861
  const activeFileComments = (activePath && activePath !== SYSTEM_TAB) ? (commentsByFile[activePath] || []) : [];
1936
1862
  const totalOpen = totalCounts(commentsByFile).open;
1937
1863
 
1864
+ // Suppress the native browser context menu across the shell — the canvas
1865
+ // input-router already handles right-click inside the canvas host, but
1866
+ // sidebar / menubar / statusbar / floating chrome would otherwise leak the
1867
+ // native menu on top of our `.dc-context-menu` (or alone, outside canvas).
1868
+ // Editable fields (search box, future text inputs) keep the native menu so
1869
+ // copy/paste still works.
1870
+ const onShellContextMenu = useCallback((e) => {
1871
+ const t = e.target;
1872
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || (t.isContentEditable))) {
1873
+ return;
1874
+ }
1875
+ e.preventDefault();
1876
+ }, []);
1877
+
1938
1878
  return (
1939
- <div className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}>
1879
+ <div
1880
+ className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
1881
+ onContextMenu={onShellContextMenu}
1882
+ >
1940
1883
  <Sidebar
1941
1884
  groups={groups}
1942
1885
  activePath={activePath}
@@ -1979,20 +1922,7 @@ function App() {
1979
1922
  cfg={cfg}
1980
1923
  />
1981
1924
  {activePath && activePath !== SYSTEM_TAB && (
1982
- <CommentBar
1983
- activePath={activePath}
1984
- selected={selected}
1985
- comments={activeFileComments}
1986
- focusedId={focusedCommentId}
1987
- draft={draft && draft.file === activePath ? draft : null}
1988
- setDraft={setDraft}
1989
- onSubmit={submitDraft}
1990
- onCancel={cancelDraft}
1991
- onResolve={resolveComment}
1992
- onReopen={reopenComment}
1993
- onDelete={deleteComment}
1994
- onFocusPin={focusPinFromBar}
1995
- />
1925
+ <CommentBar activePath={activePath} comments={activeFileComments} />
1996
1926
  )}
1997
1927
  <StatusBar
1998
1928
  activePath={activePath}
@@ -2002,8 +1932,6 @@ function App() {
2002
1932
  theme={theme}
2003
1933
  onToggleTheme={toggleTheme}
2004
1934
  onClearSelected={clearSelected}
2005
- onAddComment={startDraftFromSelection}
2006
- hasDraft={!!(draft && draft.file === activePath)}
2007
1935
  />
2008
1936
  </div>
2009
1937
  {commentsPanelOpen && (