@1agh/maude 0.60.4 → 0.60.6

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 (34) hide show
  1. package/apps/studio/annotations-layer.tsx +21 -12
  2. package/apps/studio/api.ts +54 -5
  3. package/apps/studio/build.ts +10 -0
  4. package/apps/studio/canvas-comment-mount.tsx +7 -1
  5. package/apps/studio/canvas-icons.tsx +30 -2
  6. package/apps/studio/canvas-lib.tsx +41 -6
  7. package/apps/studio/canvas-shell.tsx +39 -43
  8. package/apps/studio/client/app.jsx +20 -13
  9. package/apps/studio/dist/client.bundle.js +1 -1
  10. package/apps/studio/dist/comment-mount.js +2 -2
  11. package/apps/studio/dom-selection.ts +67 -0
  12. package/apps/studio/hmr-broadcast.ts +40 -2
  13. package/apps/studio/input-router.tsx +26 -2
  14. package/apps/studio/sync/agent.ts +64 -13
  15. package/apps/studio/sync/codec.ts +47 -0
  16. package/apps/studio/sync/cold-start.ts +96 -0
  17. package/apps/studio/sync/migrate-seed.ts +52 -7
  18. package/apps/studio/sync/projection.ts +10 -1
  19. package/apps/studio/test/canvas-meta-api.test.ts +114 -3
  20. package/apps/studio/test/canvas-wheel-delta.test.ts +42 -0
  21. package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
  22. package/apps/studio/test/comment-mount.test.ts +10 -1
  23. package/apps/studio/test/dom-selection.test.ts +69 -1
  24. package/apps/studio/test/hmr-broadcast.test.ts +6 -4
  25. package/apps/studio/test/hmr-classify.test.ts +18 -1
  26. package/apps/studio/test/input-router.test.ts +69 -0
  27. package/apps/studio/test/mode-toggle.test.tsx +226 -0
  28. package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
  29. package/apps/studio/test/use-tool-mode.test.tsx +33 -3
  30. package/apps/studio/tool-palette.tsx +86 -24
  31. package/apps/studio/use-tool-mode.tsx +113 -14
  32. package/apps/studio/whats-new.json +45 -0
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +31 -0
@@ -956,7 +956,7 @@ function scaleStrokeInGroup(
956
956
  export function AnnotationsLayer() {
957
957
  countRender('annotationRenders');
958
958
  ensureAnnotStyles();
959
- const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
959
+ const { tool, setTool, resetTool, sticky, tools, shapeKind } = useToolMode();
960
960
  const theme = useCanvasChromeTheme();
961
961
  const controller = useViewportControllerContext();
962
962
  const vp = controller?.viewport ?? null;
@@ -2008,7 +2008,9 @@ export function AnnotationsLayer() {
2008
2008
  setEditCaretPoint({ x: e.clientX, y: e.clientY });
2009
2009
  setEditingId(strokeId);
2010
2010
  if (annotSel) annotSel.replace(strokeId);
2011
- setTool('move');
2011
+ // DDR-223 — back to the MODE's resting tool (move in edit, browse in
2012
+ // preview): editing a text annotation shouldn't exit preview.
2013
+ resetTool();
2012
2014
  return true;
2013
2015
  }
2014
2016
  const editableTarget = findEditableElementAt(e.clientX, e.clientY);
@@ -2020,7 +2022,7 @@ export function AnnotationsLayer() {
2020
2022
  showCanvasToast(
2021
2023
  'This text is filled in from code (a variable) — edit it via chat or /design:edit.'
2022
2024
  );
2023
- setTool('move');
2025
+ resetTool();
2024
2026
  return true;
2025
2027
  }
2026
2028
  document.dispatchEvent(
@@ -2028,6 +2030,9 @@ export function AnnotationsLayer() {
2028
2030
  detail: { el: editableTarget, clientX: e.clientX, clientY: e.clientY },
2029
2031
  })
2030
2032
  );
2033
+ // Deliberately `move`, not `resetTool` — editing ARTBOARD content is
2034
+ // an edit-mode activity, so this one flips to edit even from preview
2035
+ // (DDR-223 mode⇄tool sync).
2031
2036
  setTool('move');
2032
2037
  return true;
2033
2038
  }
@@ -2037,7 +2042,7 @@ export function AnnotationsLayer() {
2037
2042
  setPendingText({ x: wx, y: wy });
2038
2043
  if (annotSel) annotSel.clear();
2039
2044
  const stickyOnText = sticky.locked && sticky.tool === 'text';
2040
- if (!stickyOnText) setTool('move');
2045
+ if (!stickyOnText) resetTool();
2041
2046
  return true;
2042
2047
  }
2043
2048
  return true;
@@ -2061,6 +2066,7 @@ export function AnnotationsLayer() {
2061
2066
  annotSel,
2062
2067
  sticky,
2063
2068
  setTool,
2069
+ resetTool,
2064
2070
  ]
2065
2071
  );
2066
2072
 
@@ -2209,21 +2215,24 @@ export function AnnotationsLayer() {
2209
2215
  showOnceHint('chain', '⌘Enter commits and creates the next one beside it.');
2210
2216
  }
2211
2217
  }
2212
- // T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
2213
- // mode is locked on this tool. Sticky lets the user draw many shapes in a
2214
- // row (canonical pattern: tldraw double-click to lock). Eraser stays
2215
- // armed by default — that tool is destructive, not constructive.
2216
- // Map a highlighter pen (a 'pen' stroke with the flag) back to the
2217
- // 'highlighter' tool id so its sticky-lock check matches the active tool.
2218
+ // T18 / T19 — flip the tool back to the MODE's resting tool after every
2219
+ // commit UNLESS sticky mode is locked on this tool (DDR-223: move in edit
2220
+ // — byte-identical to the old hardcode — browse in preview, so drawing an
2221
+ // annotation never silently exits the alive posture). Sticky lets the user
2222
+ // draw many shapes in a row (canonical pattern: tldraw double-click to
2223
+ // lock). Eraser stays armed by default — that tool is destructive, not
2224
+ // constructive. Map a highlighter pen (a 'pen' stroke with the flag) back
2225
+ // to the 'highlighter' tool id so its sticky-lock check matches the
2226
+ // active tool.
2218
2227
  const toolJustUsed = cur.tool === 'pen' && cur.highlighter ? 'highlighter' : cur.tool;
2219
2228
  if (toolJustUsed !== 'eraser') {
2220
2229
  const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
2221
- if (!stickyOnThis) setTool('move');
2230
+ if (!stickyOnThis) resetTool();
2222
2231
  }
2223
2232
  drawAnchorRef.current = null;
2224
2233
  lastDrawPointRef.current = null;
2225
2234
  setDrawing(null);
2226
- }, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
2235
+ }, [isActive, isErase, visible, commitStrokes, annotSel, resetTool, sticky]);
2227
2236
 
2228
2237
  // T21 — abort a mid-stroke draw without committing. Dispatched by the
2229
2238
  // canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
@@ -1499,11 +1499,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1499
1499
  // shared `.meta.json` document. It lives in `_canvas-state/<slug>.view.json`
1500
1500
  // ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from
1501
1501
  // the legacy `_canvas-state/<slug>.json` ({ sections, viewport:{x,y,scale} })
1502
- // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.1–4, so
1502
+ // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.02–4, so
1503
1503
  // overloading one file would let the two writers clobber each other's shape.
1504
1504
 
1505
- /** Validate a candidate viewport — finite x/y, zoom clamped [0.1, 4] (the
1506
- * Phase 4 rule). Returns the normalized viewport, or null when invalid. */
1505
+ /** Validate a candidate viewport — finite x/y, zoom clamped [0.02, 4] (the
1506
+ * Phase 4 rule). Mirrors `ZOOM_MIN` in canvas-lib.tsx (issue #91) — keep the
1507
+ * two floors in lockstep or a save/reload round-trip silently re-clamps a
1508
+ * zoom the client just let the user reach. Returns the normalized viewport,
1509
+ * or null when invalid. */
1507
1510
  function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null {
1508
1511
  if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
1509
1512
  const vv = v as { x?: unknown; y?: unknown; zoom?: unknown };
@@ -1512,7 +1515,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1512
1515
  Number.isFinite(vv.y as number) &&
1513
1516
  Number.isFinite(vv.zoom as number)
1514
1517
  ) {
1515
- const zoom = Math.min(4, Math.max(0.1, vv.zoom as number));
1518
+ const zoom = Math.min(4, Math.max(0.02, vv.zoom as number));
1516
1519
  return { x: vv.x as number, y: vv.y as number, zoom };
1517
1520
  }
1518
1521
  return null;
@@ -1571,6 +1574,41 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1571
1574
  return [...new Set(out)];
1572
1575
  }
1573
1576
 
1577
+ // Issue #91 security follow-up — `layout.artboards[]` (DDR-027 position-only
1578
+ // entries) is reachable from the untrusted canvas origin (DDR-054) via the
1579
+ // same PATCH lane as overlays/locked above, but previously had no count or
1580
+ // magnitude bound. `fit()`/`computeFit` (canvas-lib.tsx) now correctly frames
1581
+ // whatever bounding box this describes — before the zoom-floor fix, a hard
1582
+ // 0.1 zoom clamp accidentally masked an unbounded layout into a cropped,
1583
+ // mismatched view; after it, an oversized synced layout can legitimately be
1584
+ // framed whole, promoting every artboard in it to its own GPU layer at once
1585
+ // (canvas-lib.tsx `content-visibility` comment). Cap count + coordinate
1586
+ // magnitude the same way `normalizeOverlays`/`normalizeLocked` cap theirs.
1587
+ const MAX_LAYOUT_ARTBOARDS = 2000;
1588
+ const MAX_LAYOUT_COORD = 1_000_000;
1589
+
1590
+ function normalizeLayoutArtboards(
1591
+ v: unknown
1592
+ ): Array<{ id: string; x: number; y: number }> | null {
1593
+ if (!Array.isArray(v) || v.length > MAX_LAYOUT_ARTBOARDS) return null;
1594
+ const out: Array<{ id: string; x: number; y: number }> = [];
1595
+ for (const entry of v) {
1596
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
1597
+ const e = entry as { id?: unknown; x?: unknown; y?: unknown };
1598
+ if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 128) return null;
1599
+ if (
1600
+ !Number.isFinite(e.x as number) ||
1601
+ !Number.isFinite(e.y as number) ||
1602
+ Math.abs(e.x as number) > MAX_LAYOUT_COORD ||
1603
+ Math.abs(e.y as number) > MAX_LAYOUT_COORD
1604
+ ) {
1605
+ return null;
1606
+ }
1607
+ out.push({ id: e.id, x: e.x as number, y: e.y as number });
1608
+ }
1609
+ return out;
1610
+ }
1611
+
1574
1612
  /** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
1575
1613
  * read half of the read-modify-write both `saveCanvasView` and
1576
1614
  * `saveCanvasOverlays` need so writing one lane never clobbers the other. */
@@ -1819,7 +1857,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1819
1857
  if (patch.layout === null) {
1820
1858
  next.layout = undefined;
1821
1859
  } else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) {
1822
- next.layout = patch.layout;
1860
+ const rawLayout = patch.layout as Record<string, unknown>;
1861
+ if ('artboards' in rawLayout) {
1862
+ // Bounded — see normalizeLayoutArtboards. An oversized/malformed
1863
+ // `artboards` array is a silent no-op (leaves the prior persisted
1864
+ // layout in place), matching the overlays/locked lanes' convention.
1865
+ const artboards = normalizeLayoutArtboards(rawLayout.artboards);
1866
+ if (artboards !== null) {
1867
+ next.layout = { ...rawLayout, artboards };
1868
+ }
1869
+ } else {
1870
+ next.layout = patch.layout;
1871
+ }
1823
1872
  }
1824
1873
  // Defensive: a stale inline viewport must never persist in the versioned
1825
1874
  // file (the camera lane owns it now). JSON.stringify drops undefined keys.
@@ -353,6 +353,16 @@ export function writeCompileEntry(target: PlatformTarget): string {
353
353
  // Side-effect module: must be imported BEFORE any oxc-parser usage.
354
354
  import bindingPath from ${JSON.stringify(bindingSpec)} with { type: 'file' };
355
355
  process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindingPath;
356
+ // DDR-177 — a compiled binary must be self-contained: every runtime-spawned
357
+ // child (canvas-build worker, asset-sweep worker) resolves its JS runtime via
358
+ // resolveBunPath() = MAUDE_BUN_PATH || 'bun'. The desktop app launches this
359
+ // binary with a GUI PATH that has no 'bun', and NOTHING set MAUDE_BUN_PATH —
360
+ // so the asset sweep died with "Executable not found in $PATH" on machines
361
+ // without a user-installed bun. Default it to THIS binary: workerEnv() adds
362
+ // BUN_BE_BUN=1 when MAUDE_BUN_PATH === process.execPath, so children re-enter
363
+ // the sidecar as a plain JS runtime. An explicit launcher-set value (the
364
+ // cell's studio-child.mjs) still wins.
365
+ process.env.MAUDE_BUN_PATH ||= process.execPath;
356
366
  `;
357
367
  writeFileSync(initPath, initContent);
358
368
 
@@ -496,7 +496,13 @@ export interface MountCanvasOptions {
496
496
  function buildCanvasTree(Canvas: ComponentType, file: string | undefined): ReactNode {
497
497
  return createElement(
498
498
  MaybeToolProvider,
499
- null,
499
+ // DDR-223 — this layer's provider instance keeps the DDR-187 posture:
500
+ // bare DS specimens stay ALIVE with native cursors (there is no palette,
501
+ // no editing, nothing to boot into edit for). On a UI canvas the inner
502
+ // canvas-lib ToolProvider (a separate bundle, separate context) owns the
503
+ // real boot posture (edit/`move`); this ancestor instance only carries the
504
+ // comment tool + claim layer and converges on the first tool keydown.
505
+ { initial: 'browse' as const },
500
506
  createElement(
501
507
  MaybeSelectionSetProvider,
502
508
  null,
@@ -52,6 +52,31 @@ export function IconBrowse(props: IconProps) {
52
52
  );
53
53
  }
54
54
 
55
+ // DDR-223 addendum 2 — the Preview/Edit/Present toggle glyphs (owner steer
56
+ // 2026-08-15): lucide `eye` + `pencil-ruler` (ISC), redrawn in the house
57
+ // 24 × 24 / 1.75-stroke language. Mode segments deliberately do NOT reuse tool
58
+ // glyphs — a mode is a way of looking at the canvas, not a recalled tool.
59
+ export function IconEye(props: IconProps) {
60
+ return (
61
+ <Svg {...props}>
62
+ <path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
63
+ <circle cx="12" cy="12" r="3" />
64
+ </Svg>
65
+ );
66
+ }
67
+ export function IconPencilRuler(props: IconProps) {
68
+ return (
69
+ <Svg {...props}>
70
+ <path d="M13 7 8.7 2.7a2.41 2.41 0 0 0-3.4 0L2.7 5.3a2.41 2.41 0 0 0 0 3.4L7 13" />
71
+ <path d="m8 6 2-2" />
72
+ <path d="m18 16 2-2" />
73
+ <path d="m17 11 4.3 4.3c.94.94.94 2.46 0 3.4l-2.6 2.6c-.94.94-2.46.94-3.4 0L11 17" />
74
+ <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
75
+ <path d="m15 5 4 4" />
76
+ </Svg>
77
+ );
78
+ }
79
+
55
80
  export function IconHand(props: IconProps) {
56
81
  return (
57
82
  <Svg {...props}>
@@ -146,11 +171,14 @@ export function IconLink(props: IconProps) {
146
171
  );
147
172
  }
148
173
 
174
+ // DDR-223 addendum 2 — redrawn to the lucide `presentation` glyph so the three
175
+ // mode-segment icons (eye / pencil-ruler / presentation) read as one family.
149
176
  export function IconPresentation(props: IconProps) {
150
177
  return (
151
178
  <Svg {...props}>
152
- <rect x="3" y="4" width="18" height="12" rx="1.5" />
153
- <path d="M9 21l3-5 3 5" />
179
+ <path d="M2 3h20" />
180
+ <path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" />
181
+ <path d="m7 21 5-5 5 5" />
154
182
  </Svg>
155
183
  );
156
184
  }
@@ -214,7 +214,15 @@ export function AIPlaceholder({
214
214
  // ─────────────────────────────────────────────────────────────────────────────
215
215
  // Module constants
216
216
 
217
- const ZOOM_MIN = 0.1;
217
+ // Issue #91 — was 0.1. A board spanning more world units than ~10x the
218
+ // viewport could never be framed whole: `fit()` and manual zoom-out both
219
+ // funnel through `clampZoom` (below), so the old floor blocked "fit to
220
+ // screen" from ever reaching the zoom a wide board actually needs. 0.02
221
+ // matches the interactive floor other canvas tools (Figma, tldraw) ship.
222
+ // Keep in lockstep with the mirror clamp in `api.ts`'s `normalizeViewport`
223
+ // (the per-machine camera persisted to `_canvas-state/<slug>.view.json`,
224
+ // DDR-115) — that one re-clamps on save/reload independently of this file.
225
+ const ZOOM_MIN = 0.02;
218
226
  const ZOOM_MAX = 4.0;
219
227
  const ZOOM_STEP_IN = 1.2;
220
228
  const ZOOM_STEP_OUT = 1 / 1.2;
@@ -685,7 +693,9 @@ function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
685
693
  });
686
694
  }
687
695
 
688
- function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
696
+ // Exported for the same reason as `clampZoom` above — lets a unit test prove
697
+ // the raw fit computation for a wide board isn't what's clamping the zoom.
698
+ export function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
689
699
  if (rects.length === 0) return { x: 0, y: 0, zoom: 1 };
690
700
  let xMin = Number.POSITIVE_INFINITY;
691
701
  let yMin = Number.POSITIVE_INFINITY;
@@ -907,7 +917,25 @@ export interface ViewportControllerHandle {
907
917
  isInteracting: boolean;
908
918
  }
909
919
 
910
- function clampZoom(z: number): number {
920
+ // Issue #94 — wheel pan used raw WheelEvent deltas as CSS pixels, but the
921
+ // Wheel Events spec lets a browser report deltas in three different units
922
+ // (`deltaMode`): pixel (0, trackpads and most Chromium mice), line (1,
923
+ // Firefox's default for physical mice — one notch is a small integer like
924
+ // 3), or page (2). Consuming a line-mode `3` as 3px of pan per notch is why
925
+ // wheel-driven panning read as "very slow" while trackpad panning (already
926
+ // pixel-mode) felt fine. LINES_TO_PIXELS mirrors the pixels-per-line browsers
927
+ // themselves use for native line-scrolling at default font size.
928
+ const LINES_TO_PIXELS = 16;
929
+ export function wheelDeltaToPixels(delta: number, deltaMode: number, pageSize: number): number {
930
+ if (deltaMode === 1) return delta * LINES_TO_PIXELS; // DOM_DELTA_LINE
931
+ if (deltaMode === 2) return delta * pageSize; // DOM_DELTA_PAGE
932
+ return delta; // DOM_DELTA_PIXEL (0) — already pixels
933
+ }
934
+
935
+ // Exported (like `revealAxisDelta` in canvas-shell.tsx) so a pure-logic unit
936
+ // test can pin the zoom floor without booting the DOM/host-dependent
937
+ // viewport controller.
938
+ export function clampZoom(z: number): number {
911
939
  if (!Number.isFinite(z)) return 1;
912
940
  return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
913
941
  }
@@ -1399,6 +1427,13 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1399
1427
  const rect = host.getBoundingClientRect();
1400
1428
  const cx = e.clientX - rect.left;
1401
1429
  const cy = e.clientY - rect.top;
1430
+ // Normalize line/page-mode deltas to pixels before anything below
1431
+ // reads them — see `wheelDeltaToPixels` (issue #94). Zoom's own
1432
+ // [-50, 50] clamp already absorbed this for the pinch/ctrl+wheel path,
1433
+ // but pan had no such clamp so a line-mode mouse wheel panned at only
1434
+ // a few px per notch.
1435
+ const dx = wheelDeltaToPixels(e.deltaX, e.deltaMode, host.clientWidth);
1436
+ const dy = wheelDeltaToPixels(e.deltaY, e.deltaMode, host.clientHeight);
1402
1437
  // Mac trackpad pinch fires wheel with ctrlKey:true automatically, even
1403
1438
  // without a physical Ctrl press — so the same branch covers both
1404
1439
  // Ctrl+wheel (mouse) and pinch-zoom (trackpad).
@@ -1409,7 +1444,7 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1409
1444
  // notch = ±100) onto the same perceived-speed curve. Mouse-wheel
1410
1445
  // users still get smooth zoom (clamped notches accumulate at the
1411
1446
  // same exp rate), trackpad-pinch users no longer outpace them.
1412
- const clamped = Math.max(-50, Math.min(50, e.deltaY));
1447
+ const clamped = Math.max(-50, Math.min(50, dy));
1413
1448
  const factor = Math.exp(-clamped * WHEEL_ZOOM_K);
1414
1449
  zoomAt(factor, cx, cy);
1415
1450
  return;
@@ -1419,14 +1454,14 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1419
1454
  // doesn't, Safari sometimes does); some don't. Read whichever axis
1420
1455
  // actually carries energy so the gesture lands horizontally either way.
1421
1456
  if (e.shiftKey) {
1422
- const d = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
1457
+ const d = Math.abs(dy) >= Math.abs(dx) ? dy : dx;
1423
1458
  panBy(-d, 0);
1424
1459
  return;
1425
1460
  }
1426
1461
  // Default: trackpad two-finger scroll → 2D pan. The negation keeps the
1427
1462
  // "content follows your fingers" mapping (Mac natural scroll). Mouse
1428
1463
  // wheels with only deltaY pan vertically.
1429
- panBy(-e.deltaX, -e.deltaY);
1464
+ panBy(-dx, -dy);
1430
1465
  };
1431
1466
 
1432
1467
  // Safari/WKWebView-only trackpad pinch. WebKit exposes the native pinch via
@@ -67,15 +67,13 @@ import {
67
67
  import { ContextualToolbar } from './contextual-toolbar.tsx';
68
68
  import { CursorsOverlay } from './cursors-overlay.tsx';
69
69
  import {
70
+ buildComposeSelection,
70
71
  cssPath,
71
72
  deriveFile,
72
- domPath,
73
73
  hoverTargetToSelection,
74
+ openCommentComposer,
74
75
  realClasses,
75
76
  resolveSelectionEl,
76
- scopedCdSelector,
77
- selectorIndex,
78
- shortText,
79
77
  } from './dom-selection.ts';
80
78
  import { setElementDragActive } from './drag-state.ts';
81
79
  import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
@@ -1378,33 +1376,17 @@ function buildRegistry(deps: {
1378
1376
  });
1379
1377
  };
1380
1378
 
1379
+ // issue-90 — right-click "Add comment" (element / artboard-chrome / world)
1380
+ // used to only postMessage the parent shell, which merely mirrors the
1381
+ // target for the StatusBar/sidebar; it never dispatched `cm:open-composer`,
1382
+ // the event `comments-overlay.tsx` actually listens on to open the
1383
+ // composer. Selecting the menu item did nothing. `openCommentComposer`
1384
+ // sends both, matching the working comment-tool-click path
1385
+ // (canvas-comment-mount.tsx's `dropComment`).
1381
1386
  const postComposeForTarget = (target: ContextTarget): void => {
1382
1387
  if (typeof window === 'undefined') return;
1383
- const composeSelector = target.cdId
1384
- ? scopedCdSelector(target.cdId, target.artboardId)
1385
- : cssPath(target.el);
1386
- const sel: Selection | null = target.el
1387
- ? {
1388
- file: deriveFile(),
1389
- id: target.cdId ?? undefined,
1390
- selector: composeSelector,
1391
- artboardId: target.artboardId,
1392
- index: target.cdId ? selectorIndex(document, composeSelector, target.el) : 0,
1393
- tag: target.el.tagName.toLowerCase(),
1394
- classes: realClasses(target.el),
1395
- text: shortText(target.el, 240),
1396
- dom_path: domPath(target.el),
1397
- bounds: (target.el as HTMLElement).getBoundingClientRect
1398
- ? boundsOf(target.el as HTMLElement)
1399
- : null,
1400
- html: (target.el.outerHTML ?? '').slice(0, 4000),
1401
- }
1402
- : null;
1403
- try {
1404
- window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
1405
- } catch {
1406
- /* ignore */
1407
- }
1388
+ const sel = buildComposeSelection(target, target.clientX, target.clientY);
1389
+ openCommentComposer(sel, target.clientX, target.clientY);
1408
1390
  };
1409
1391
 
1410
1392
  const fitItem: MenuItem = {
@@ -2141,6 +2123,18 @@ function buildRegistry(deps: {
2141
2123
  ],
2142
2124
  ],
2143
2125
  'artboard-chrome': [
2126
+ [
2127
+ // issue-90 — right-click on an artboard's own label/border chrome
2128
+ // (outside its body, where `data-cd-id` never resolves) had no
2129
+ // "Add comment" entry at all; only a hit inside the body counted as
2130
+ // `element`. Anchors at the click point (no element under it).
2131
+ {
2132
+ id: 'add-comment',
2133
+ label: 'Add comment',
2134
+ shortcut: 'C',
2135
+ onSelect: postComposeForTarget,
2136
+ },
2137
+ ],
2144
2138
  [
2145
2139
  {
2146
2140
  id: 'fit-one',
@@ -2273,6 +2267,17 @@ function buildRegistry(deps: {
2273
2267
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
2274
2268
  ],
2275
2269
  world: [
2270
+ // issue-90 — right-click on empty canvas background (no artboard, no
2271
+ // element) had no "Add comment" entry at all. Anchors at the click
2272
+ // point, same as the comment tool's own floating-pin fallback.
2273
+ [
2274
+ {
2275
+ id: 'add-comment',
2276
+ label: 'Add comment',
2277
+ shortcut: 'C',
2278
+ onSelect: postComposeForTarget,
2279
+ },
2280
+ ],
2276
2281
  [fitItem, resetItem],
2277
2282
  [
2278
2283
  exportItem('export-canvas', 'Export canvas as separate…', 'canvas-as-separate'),
@@ -2320,16 +2325,6 @@ function filterRegistryForReadOnly(registry: ContextRegistry): ContextRegistry {
2320
2325
  return out;
2321
2326
  }
2322
2327
 
2323
- function boundsOf(el: HTMLElement) {
2324
- const r = el.getBoundingClientRect();
2325
- return {
2326
- x: Math.round(r.left),
2327
- y: Math.round(r.top),
2328
- w: Math.round(r.width),
2329
- h: Math.round(r.height),
2330
- };
2331
- }
2332
-
2333
2328
  // ─────────────────────────────────────────────────────────────────────────────
2334
2329
  // Task A1 (feature-element-editing-robustness) — camera-aware reveal.
2335
2330
  //
@@ -2438,7 +2433,7 @@ function CanvasRouter({
2438
2433
  distributeArtboards: (axis: 'x' | 'y') => void;
2439
2434
  alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
2440
2435
  }) {
2441
- const { tool, setTool, clearSticky } = useToolMode();
2436
+ const { tool, setTool, resetTool, clearSticky } = useToolMode();
2442
2437
  const selSet = useSelectionSet();
2443
2438
  const annotSel = useAnnotationSelection();
2444
2439
  const ctxMenu = useContextMenu();
@@ -3274,10 +3269,11 @@ function CanvasRouter({
3274
3269
  } catch {
3275
3270
  /* non-DOM env */
3276
3271
  }
3277
- // T19 — clear sticky-tool lock + flip back to Move. Esc is the
3278
- // canonical "back to default state" gesture across canvas apps.
3272
+ // T19 — clear sticky-tool lock + flip back to the mode's resting tool
3273
+ // (DDR-223: move in edit, browse in preview). Esc is the canonical
3274
+ // "back to default state" gesture across canvas apps.
3279
3275
  clearSticky();
3280
- if (tool !== 'move') setTool('move');
3276
+ resetTool();
3281
3277
  ctxMenu.close();
3282
3278
  selSet.clear();
3283
3279
  annotSel.clear();
@@ -370,16 +370,20 @@ function shellToast(message, ok = false, action) {
370
370
  );
371
371
  }
372
372
 
373
- // feature-4 (browse/move split, DDR-187) — one-time first-run teaching hint.
374
- // The boot default is `browse` (the mock is ALIVE — buttons click), so the
375
- // Figma muscle-memory gesture (plain click = select) does nothing until V is
376
- // pressed. Surface that ONCE, gated by a localStorage marker (same family as
377
- // `mdcc-whatsnew-seen`). Auto-dismisses; a V press (learned) clears it early.
378
- const BROWSE_HINT_SEEN = 'maude-browse-hint-seen';
373
+ // DDR-223 (issue #93, supersedes DDR-187's boot half) — one-time first-run
374
+ // teaching hint. Authoring canvases now boot into EDIT (`move`/V armed): click
375
+ // selects like Figma, and the mock is inert until the Preview toggle flips the
376
+ // alive posture back on. Teach exactly that ONCE, gated by a NEW localStorage
377
+ // marker (`maude-mode-hint-seen` — deliberately not the old
378
+ // `maude-browse-hint-seen`, which taught the opposite posture; every existing
379
+ // user should see the new hint once). Read-only viewers still boot preview
380
+ // (alive) and keep the DDR-187 wording. Auto-dismisses; the taught gesture
381
+ // (V in read-only) clears it early.
382
+ const MODE_HINT_SEEN = 'maude-mode-hint-seen';
379
383
  function browseFirstRunHint(readOnly = false) {
380
384
  if (typeof document === 'undefined' || typeof localStorage === 'undefined') return;
381
385
  try {
382
- if (localStorage.getItem(BROWSE_HINT_SEEN) === '1') return;
386
+ if (localStorage.getItem(MODE_HINT_SEEN) === '1') return;
383
387
  } catch {
384
388
  return;
385
389
  }
@@ -393,18 +397,21 @@ function browseFirstRunHint(readOnly = false) {
393
397
  'font:12px/1.5 var(--font-ui,system-ui,sans-serif);background:var(--surface-2,#1b1e24);' +
394
398
  'color:var(--text-1,#e7eaf0);border:1px solid var(--border-1,#333a45);' +
395
399
  'box-shadow:0 10px 34px rgba(0,0,0,.42);opacity:0;transition:opacity 160ms ease;';
400
+ const kbd =
401
+ '<kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
402
+ 'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd>';
396
403
  el.innerHTML =
397
- '<span>Your mock is <strong>live</strong> — click things to try it. ' +
398
- 'Press <kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
399
- 'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd> ' +
400
404
  // Cloud Phase 25 C2 — a viewer selects to inspect, never to edit; the
401
405
  // hint must not promise an editor the role doesn't have.
402
- (readOnly ? 'to select &amp; inspect.</span>' : 'to select &amp; edit like Figma.</span>') +
406
+ (readOnly
407
+ ? `<span>Your mock is <strong>live</strong> — click things to try it. Press ${kbd} to select &amp; inspect.</span>`
408
+ : '<span>You’re in <strong>Edit</strong> — click selects, like Figma. ' +
409
+ 'Switch to <strong>Preview</strong> in the toolbar to use the live mock.</span>') +
403
410
  '<button type="button" aria-label="Dismiss" style="background:none;border:none;color:inherit;' +
404
411
  'cursor:pointer;font-size:15px;line-height:1;opacity:.65;padding:2px">×</button>';
405
412
  const dismiss = () => {
406
413
  try {
407
- localStorage.setItem(BROWSE_HINT_SEEN, '1');
414
+ localStorage.setItem(MODE_HINT_SEEN, '1');
408
415
  } catch {
409
416
  /* private mode */
410
417
  }
@@ -416,7 +423,7 @@ function browseFirstRunHint(readOnly = false) {
416
423
  if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) dismiss();
417
424
  };
418
425
  el.querySelector('button')?.addEventListener('click', dismiss);
419
- document.addEventListener('keydown', onV, true);
426
+ if (readOnly) document.addEventListener('keydown', onV, true);
420
427
  document.body.appendChild(el);
421
428
  requestAnimationFrame(() => {
422
429
  el.style.opacity = '1';