@1agh/maude 0.22.0 → 0.23.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.
Files changed (60) hide show
  1. package/cli/commands/design-link.test.mjs +53 -1
  2. package/cli/commands/hub.test.mjs +10 -9
  3. package/cli/lib/design-link.mjs +154 -7
  4. package/cli/lib/hubs-config.mjs +42 -4
  5. package/package.json +9 -9
  6. package/plugins/design/dev-server/bin/check-runtime-bundles.sh +125 -0
  7. package/plugins/design/dev-server/bin/runtime-health.sh +47 -6
  8. package/plugins/design/dev-server/build.ts +87 -7
  9. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  10. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  11. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  12. package/plugins/design/dev-server/client/app.jsx +37 -15
  13. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  14. package/plugins/design/dev-server/collab/registry.ts +51 -0
  15. package/plugins/design/dev-server/config.schema.json +20 -0
  16. package/plugins/design/dev-server/context.ts +7 -0
  17. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  18. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  19. package/plugins/design/dev-server/dist/runtime/.min-sizes.json +16 -0
  20. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +2 -6
  21. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +2 -6
  22. package/plugins/design/dev-server/dist/runtime/motion.js +4787 -8
  23. package/plugins/design/dev-server/dist/runtime/motion_react.js +9654 -7
  24. package/plugins/design/dev-server/dist/runtime/pixi-js.js +5865 -5469
  25. package/plugins/design/dev-server/dist/runtime/react-dom.js +4 -22
  26. package/plugins/design/dev-server/dist/runtime/react-dom_client.js +5 -23
  27. package/plugins/design/dev-server/dist/runtime/react.js +4 -22
  28. package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +4 -22
  29. package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +4 -22
  30. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +2 -6
  31. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +2 -6
  32. package/plugins/design/dev-server/dist/runtime/yjs.js +2 -6
  33. package/plugins/design/dev-server/dom-selection.ts +156 -0
  34. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  35. package/plugins/design/dev-server/input-router.tsx +99 -61
  36. package/plugins/design/dev-server/server.ts +18 -0
  37. package/plugins/design/dev-server/sync/agent.ts +323 -0
  38. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  39. package/plugins/design/dev-server/sync/codec.ts +169 -0
  40. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  41. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  42. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  43. package/plugins/design/dev-server/sync/index.ts +474 -0
  44. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  45. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  46. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  47. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  48. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  49. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  50. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  51. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  52. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  53. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  54. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  55. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  56. package/plugins/design/dev-server/use-collab.tsx +157 -13
  57. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  58. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  59. package/plugins/design/templates/_shell.html +15 -5
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
@@ -114,6 +114,58 @@ async function buildClient(): Promise<{ outBytes: number; outPath: string }> {
114
114
  return { outBytes: out.size, outPath };
115
115
  }
116
116
 
117
+ // ---------- (a.5) Comment mount layer (shell-owned comments) ----------
118
+ //
119
+ // canvas-comment-mount.tsx → dist/comment-mount.js. Loaded by _shell.html via
120
+ // a `<script type="module">`; its `mountCanvas(...)` wraps any canvas default
121
+ // export in the lite comment provider tree. React + the other canvas-runtime
122
+ // packages are EXTERNAL (bare specifiers resolved by _shell.html's importmap)
123
+ // so this bundle shares the single React/yjs singletons with the canvas
124
+ // module — inlining a second React would break hooks ("invalid hook call").
125
+
126
+ const COMMENT_MOUNT_EXTERNALS = [
127
+ 'react',
128
+ 'react-dom',
129
+ 'react-dom/client',
130
+ 'react/jsx-runtime',
131
+ 'react/jsx-dev-runtime',
132
+ 'yjs',
133
+ 'y-protocols/sync',
134
+ 'y-protocols/awareness',
135
+ 'lib0/decoding',
136
+ 'lib0/encoding',
137
+ ];
138
+
139
+ async function buildCommentMount(): Promise<{ outBytes: number; outPath: string }> {
140
+ ensureDist();
141
+ const outPath = join(DIST, 'comment-mount.js');
142
+ const result = await Bun.build({
143
+ entrypoints: [join(ROOT, 'canvas-comment-mount.tsx')],
144
+ outdir: DIST,
145
+ target: 'browser',
146
+ format: 'esm',
147
+ naming: 'comment-mount.js',
148
+ external: COMMENT_MOUNT_EXTERNALS,
149
+ minify: MODE === 'release',
150
+ sourcemap: MODE === 'dev' ? 'inline' : 'none',
151
+ define: {
152
+ // ALWAYS production React — like canvas-build.ts. React is external and
153
+ // resolves through the importmap to the dev-server's PRODUCTION runtime
154
+ // bundles, where `react/jsx-dev-runtime`'s `jsxDEV` is a no-op (undefined
155
+ // at call time). Emitting dev jsx (`jsxDEV`) against a production runtime
156
+ // throws "jsxDEV is not a function". Both halves must agree on the JSX
157
+ // flavour — production jsx (`react/jsx-runtime`) is the contract.
158
+ 'process.env.NODE_ENV': '"production"',
159
+ },
160
+ });
161
+ if (!result.success) {
162
+ const messages = result.logs.map((l) => l.message ?? String(l)).join('\n');
163
+ throw new Error(`Comment-mount build failed:\n${messages}`);
164
+ }
165
+ const out = Bun.file(outPath);
166
+ return { outBytes: out.size, outPath };
167
+ }
168
+
117
169
  // ---------- (b) CSS bundle (Lightning CSS) ----------
118
170
 
119
171
  async function buildCss(): Promise<{ outBytes: number; outPath: string }> {
@@ -325,6 +377,7 @@ async function buildServerBinary(target: PlatformTarget): Promise<{ outPath: str
325
377
 
326
378
  async function watch() {
327
379
  await buildClient();
380
+ await buildCommentMount();
328
381
  await buildCss();
329
382
  console.log('[build:watch] initial build complete; watching client/ + server source...');
330
383
 
@@ -396,20 +449,40 @@ async function main() {
396
449
  `[build] client.bundle.js ${client.outBytes.toLocaleString()} B (${(t1 - t0).toFixed(0)} ms)`
397
450
  );
398
451
 
452
+ const commentMount = await buildCommentMount();
453
+ const t1b = performance.now();
454
+ console.log(
455
+ `[build] comment-mount.js ${commentMount.outBytes.toLocaleString()} B (${(t1b - t1).toFixed(0)} ms)`
456
+ );
457
+
399
458
  const css = await buildCss();
400
459
  const t2 = performance.now();
401
460
  console.log(
402
- `[build] styles.css ${css.outBytes.toLocaleString()} B (${(t2 - t1).toFixed(0)} ms)`
461
+ `[build] styles.css ${css.outBytes.toLocaleString()} B (${(t2 - t1b).toFixed(0)} ms)`
403
462
  );
404
463
 
405
464
  // Pre-built runtime bundles — ship to disk so /_canvas-runtime/* never
406
465
  // needs a runtime Bun.build (which would need disk node_modules/react,
407
466
  // absent in compiled binaries + npm installs). Phase 19.1 / v0.18.1.
408
- const runtime = await buildRuntimeBundles();
409
- const t2b = performance.now();
410
- console.log(
411
- `[build] dist/runtime/*.js ${runtime.bytes.toLocaleString()} B in ${runtime.count} files (${(t2b - t2).toFixed(0)} ms)`
412
- );
467
+ //
468
+ // MAUDE_SKIP_RUNTIME_BUILD=1 — skip the regen and trust the committed
469
+ // dist/runtime/*.js. Set in CI publish-main so the npm tarball ships
470
+ // exactly what was committed in git (platform-agnostic). v0.22.0 shipped
471
+ // a broken Ubuntu-CI motion_react.js because the on-disk authoritative
472
+ // bundle got overwritten by `pnpm build`; this flag prevents that class
473
+ // of regression. The check-runtime-bundles.sh step still validates the
474
+ // on-disk artifacts against .min-sizes.json after the build.
475
+ if (process.env.MAUDE_SKIP_RUNTIME_BUILD === '1') {
476
+ console.log(
477
+ '[build] dist/runtime/*.js SKIPPED (MAUDE_SKIP_RUNTIME_BUILD=1 — using committed pre-built)'
478
+ );
479
+ } else {
480
+ const runtime = await buildRuntimeBundles();
481
+ const t2b = performance.now();
482
+ console.log(
483
+ `[build] dist/runtime/*.js ${runtime.bytes.toLocaleString()} B in ${runtime.count} files (${(t2b - t2).toFixed(0)} ms)`
484
+ );
485
+ }
413
486
 
414
487
  if (MODE === 'release') {
415
488
  const targets: PlatformTarget[] = FLAG_TARGET
@@ -430,4 +503,11 @@ if (import.meta.main) {
430
503
  await main();
431
504
  }
432
505
 
433
- export { buildClient, buildCss, buildServerBinary, PLATFORM_MATRIX, type PlatformTarget };
506
+ export {
507
+ buildClient,
508
+ buildCommentMount,
509
+ buildCss,
510
+ buildServerBinary,
511
+ PLATFORM_MATRIX,
512
+ type PlatformTarget,
513
+ };
@@ -0,0 +1,384 @@
1
+ /**
2
+ * @file canvas-comment-mount.tsx — shell-owned comment layer + mountCanvas
3
+ * @scope plugins/design/dev-server/canvas-comment-mount.tsx
4
+ * @purpose The canvas mount harness (`_shell.html`) calls `mountCanvas`
5
+ * instead of rendering the canvas default-export raw. mountCanvas
6
+ * wraps ANY default export — a `DesignCanvas` UI canvas OR a bare
7
+ * DS specimen — in a LITE comment provider tree so the in-place
8
+ * comment tool works on every mounted surface.
9
+ *
10
+ * Why a single shell-owned layer (DDR — see plan §"Key decision"):
11
+ * - Comments used to be mounted only by `DesignCanvas` (ToolProvider +
12
+ * SelectionSetProvider + CommentsOverlay + the onDropComment router
13
+ * branch). Bare specimens (`system/<ds>/preview/*.tsx`) never render
14
+ * DesignCanvas, so they had no comment tool at all.
15
+ * - Hoisting the comment subsystem here makes it universal. `DesignCanvas`
16
+ * becomes a CONSUMER of the shell-provided ToolProvider / SelectionSet /
17
+ * CommentsOverlay (via MaybeToolProvider / MaybeSelectionSetProvider and
18
+ * by dropping its own <CommentsOverlay/>).
19
+ *
20
+ * Coexistence with the UI-canvas router: this layer's input router is an
21
+ * ANCESTOR capture-listener over the canvas. On a UI canvas, `CanvasShell`
22
+ * still runs its OWN router (hover / select / context-menu / undo). To avoid
23
+ * swallowing those gestures, this router passes a narrow `claimableActions`
24
+ * allowlist — `drop-comment` / `tool` / `escape` / `hover`. `hover` never
25
+ * preventDefaults so the inner router's halo is unaffected; everything else
26
+ * (select / context-menu / undo) propagates untouched to the inner router.
27
+ */
28
+
29
+ import {
30
+ type ComponentType,
31
+ type ReactNode,
32
+ createElement,
33
+ useEffect,
34
+ useMemo,
35
+ useRef,
36
+ useState,
37
+ } from 'react';
38
+ import { createRoot } from 'react-dom/client';
39
+
40
+ import { CommentsOverlay } from './comments-overlay.tsx';
41
+ import { deriveFile, hoverTargetToSelection } from './dom-selection.ts';
42
+ import { type RouterAction, resolveHoverTarget, useInputRouter } from './input-router.tsx';
43
+ import {
44
+ MaybeSelectionSetProvider,
45
+ type Selection,
46
+ useSelectionSet,
47
+ } from './use-selection-set.tsx';
48
+ import { MaybeToolProvider, useToolMode } from './use-tool-mode.tsx';
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // Cursor CSS for the generic mount host. canvas-lib's HALO_CSS keys comment-
52
+ // mode cursors on `.dc-canvas[data-active-tool]`, which is absent in a bare
53
+ // specimen. We mirror the comment-tool cursor keyed on the mount host's
54
+ // `data-active-tool` attribute so the affordance ships even with no canvas-lib
55
+ // CSS present. The host is `display: contents` (so specimen layout is byte-
56
+ // identical) — a `display:contents` box can't paint a cursor, so the rule
57
+ // targets descendants of the host, not the host box itself.
58
+
59
+ const MC_CURSOR_CSS = `
60
+ [data-mc-host][data-active-tool="comment"] *,
61
+ body[data-active-tool="comment"] * {
62
+ cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M3 3 L14 3 L14 11 L8 11 L4 14 L4 11 L3 11 Z' fill='white' stroke='%23111' stroke-width='1' stroke-linejoin='round'/></svg>") 4 4, crosshair !important;
63
+ }
64
+ `.trim();
65
+
66
+ function ensureMountCursorStyles(): void {
67
+ if (typeof document === 'undefined') return;
68
+ if (document.getElementById('mc-cursor-css')) return;
69
+ const s = document.createElement('style');
70
+ s.id = 'mc-cursor-css';
71
+ s.textContent = MC_CURSOR_CSS;
72
+ document.head.appendChild(s);
73
+ }
74
+
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+ // CommentHost — owns the lite comment subsystem: the input router (comment-
77
+ // scoped), the single CommentsOverlay, the comment-mode cursor attribute, and
78
+ // the parent `dgn:'tool-set'` listener (so the outer menubar comment toggle
79
+ // reaches the iframe).
80
+
81
+ // Only these action kinds are claimed by the mount-layer router; the rest
82
+ // propagate to a UI canvas's own router. `tool` + `escape` are also handled by
83
+ // the inner router on UI canvases — idempotent (same shared provider). `hover`
84
+ // is dispatched too (it never preventDefaults, so the inner router's own hover
85
+ // halo on a UI canvas is unaffected) — we only PAINT the mount-layer preview
86
+ // halo on a bare specimen, where there is no inner CanvasShell halo.
87
+ const COMMENT_CLAIMS: ReadonlySet<RouterAction['kind']> = new Set<RouterAction['kind']>([
88
+ 'drop-comment',
89
+ 'tool',
90
+ 'escape',
91
+ 'hover',
92
+ ]);
93
+
94
+ // True when no DesignCanvas/CanvasShell is mounted on this surface — i.e. a
95
+ // bare DS specimen. On a UI canvas (`.dc-canvas` present) the inner shell owns
96
+ // hover-halo painting + `resolveHoverTarget` element anchoring, so the lite
97
+ // layer defers to it.
98
+ function isBareSpecimen(): boolean {
99
+ return typeof document !== 'undefined' && !document.querySelector('.dc-canvas');
100
+ }
101
+
102
+ // Deepest non-chrome element under a point — the comment anchor for a bare
103
+ // specimen (specimens aren't stamped with `data-cd-id` and have no
104
+ // `.dc-artboard-body`, so `resolveHoverTarget` returns null for them).
105
+ function pickSpecimenEl(clientX: number, clientY: number): HTMLElement | null {
106
+ if (typeof document === 'undefined') return null;
107
+ const hit = document.elementFromPoint(clientX, clientY) as HTMLElement | null;
108
+ if (!hit) return null;
109
+ // Never anchor to comment chrome or the document root.
110
+ if (hit.closest('.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, [data-mc-hover-halo]')) {
111
+ return null;
112
+ }
113
+ const tag = hit.tagName;
114
+ if (tag === 'HTML' || tag === 'BODY') return null;
115
+ return hit;
116
+ }
117
+
118
+ function CommentHost({ children, file }: { children: ReactNode; file: string | undefined }) {
119
+ ensureMountCursorStyles();
120
+ const { tool, setTool } = useToolMode();
121
+ const selSet = useSelectionSet();
122
+ const hostRef = useRef<HTMLDivElement | null>(null);
123
+ // Hover-preview halo target (bare specimens only — see isBareSpecimen).
124
+ const [hoverEl, setHoverEl] = useState<HTMLElement | null>(null);
125
+
126
+ // Latest tool for the router (read at event time, not captured).
127
+ const toolRef = useRef(tool);
128
+ toolRef.current = tool;
129
+ const getActiveTool = useMemo(() => () => toolRef.current, []);
130
+
131
+ // Drop the preview halo whenever we leave comment mode.
132
+ useEffect(() => {
133
+ if (tool !== 'comment') setHoverEl(null);
134
+ }, [tool]);
135
+
136
+ // Reflect the active tool onto the host (and body, since the host is
137
+ // display:contents and can't carry a paintable cursor). Comment-mode CSS
138
+ // keys off `[data-active-tool="comment"]`.
139
+ useEffect(() => {
140
+ const host = hostRef.current;
141
+ if (host) host.setAttribute('data-active-tool', tool);
142
+ if (typeof document !== 'undefined' && document.body) {
143
+ document.body.setAttribute('data-active-tool', tool);
144
+ }
145
+ return () => {
146
+ host?.removeAttribute('data-active-tool');
147
+ };
148
+ }, [tool]);
149
+
150
+ // Parent `dgn:'tool-set'` — the outer dev-server menubar posts this when the
151
+ // user toggles the comment tool. Mirrors canvas-shell's listener so the
152
+ // toggle reaches bare specimens too (which have no inner shell listener).
153
+ useEffect(() => {
154
+ if (typeof window === 'undefined') return;
155
+ const onMessage = (e: MessageEvent) => {
156
+ const m = e.data as { dgn?: string; tool?: string } | null;
157
+ if (!m || typeof m !== 'object' || m.dgn !== 'tool-set') return;
158
+ if (typeof m.tool === 'string') setTool(m.tool as never);
159
+ };
160
+ window.addEventListener('message', onMessage);
161
+ return () => window.removeEventListener('message', onMessage);
162
+ }, [setTool]);
163
+
164
+ useInputRouter({
165
+ hostRef,
166
+ getActiveTool,
167
+ claimableActions: COMMENT_CLAIMS,
168
+ callbacks: {
169
+ onHover: ({ clientX, clientY }) => {
170
+ // Paint a preview halo only on bare specimens; a UI canvas's own
171
+ // CanvasShell HoverHalo owns the comment-mode preview there.
172
+ if (toolRef.current !== 'comment' || !isBareSpecimen()) {
173
+ setHoverEl(null);
174
+ return;
175
+ }
176
+ const el = pickSpecimenEl(clientX, clientY);
177
+ setHoverEl((prev) => (prev === el ? prev : el));
178
+ },
179
+ onTool: ({ tool: t }) => setTool(t),
180
+ onEscape: () => {
181
+ if (toolRef.current !== 'move') setTool('move');
182
+ setHoverEl(null);
183
+ selSet.clear();
184
+ if (typeof window !== 'undefined') {
185
+ try {
186
+ window.parent.postMessage({ dgn: 'force-clear' }, '*');
187
+ } catch {
188
+ /* parent detached */
189
+ }
190
+ }
191
+ },
192
+ onDropComment: ({ clientX, clientY }) => dropComment(clientX, clientY, selSet, file),
193
+ },
194
+ });
195
+
196
+ // `display: contents` keeps the specimen's own flex/grid layout byte-
197
+ // identical — the host box contributes nothing to layout. The fixed-position
198
+ // CommentsOverlay renders fine as a child regardless.
199
+ return (
200
+ <div data-mc-host ref={hostRef} style={{ display: 'contents' }}>
201
+ {children}
202
+ {hoverEl ? <MountHoverHalo el={hoverEl} /> : null}
203
+ <CommentsOverlay />
204
+ </div>
205
+ );
206
+ }
207
+
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+ // MountHoverHalo — fixed-position outline tracking the hovered element's screen
210
+ // bounds via rAF. Inline-styled (no dependency on canvas-lib's HALO_CSS, which
211
+ // a bare specimen doesn't load). Mirrors canvas-shell's HoverHalo visually.
212
+
213
+ function MountHoverHalo({ el }: { el: HTMLElement }) {
214
+ const ref = useRef<HTMLDivElement | null>(null);
215
+ const targetRef = useRef<HTMLElement>(el);
216
+ targetRef.current = el;
217
+ const rafRef = useRef<number | null>(null);
218
+
219
+ useEffect(() => {
220
+ const tick = () => {
221
+ rafRef.current = null;
222
+ const div = ref.current;
223
+ const t = targetRef.current;
224
+ if (div && t?.isConnected) {
225
+ const r = t.getBoundingClientRect();
226
+ if (r.width === 0 && r.height === 0) {
227
+ div.style.display = 'none';
228
+ } else {
229
+ div.style.display = 'block';
230
+ div.style.left = `${Math.round(r.left)}px`;
231
+ div.style.top = `${Math.round(r.top)}px`;
232
+ div.style.width = `${Math.round(r.width)}px`;
233
+ div.style.height = `${Math.round(r.height)}px`;
234
+ }
235
+ } else if (div) {
236
+ div.style.display = 'none';
237
+ }
238
+ rafRef.current = requestAnimationFrame(tick);
239
+ };
240
+ rafRef.current = requestAnimationFrame(tick);
241
+ return () => {
242
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
243
+ };
244
+ }, []);
245
+
246
+ return (
247
+ <div
248
+ ref={ref}
249
+ aria-hidden="true"
250
+ data-mc-hover-halo=""
251
+ style={{
252
+ position: 'fixed',
253
+ display: 'none',
254
+ pointerEvents: 'none',
255
+ zIndex: 2147483646,
256
+ border: '2px solid var(--maude-hud-accent, #d63b1f)',
257
+ borderRadius: '3px',
258
+ boxSizing: 'border-box',
259
+ }}
260
+ />
261
+ );
262
+ }
263
+
264
+ // ─────────────────────────────────────────────────────────────────────────────
265
+ // Comment drop — generic path lifted from canvas-shell's onDropComment. Works
266
+ // with or without artboards: deep/shallow resolveHoverTarget → elementsFromPoint
267
+ // data-cd-id climb → floating fallback. Dispatches `cm:open-composer` for the
268
+ // in-iframe overlay + posts `comment-compose` to the parent for legacy mocks.
269
+
270
+ function dropComment(
271
+ clientX: number,
272
+ clientY: number,
273
+ selSet: { replace: (s: Selection) => void },
274
+ file: string | undefined
275
+ ): void {
276
+ if (typeof document === 'undefined') return;
277
+ let target = resolveHoverTarget(document, clientX, clientY, { deep: true });
278
+ if (!target) target = resolveHoverTarget(document, clientX, clientY, { deep: false });
279
+ // UI-canvas recovery — when both passes bail on a `pointer-events: none`
280
+ // decoration, enumerate the stack and climb the first `data-cd-id` ancestor
281
+ // inside an artboard body. Skipped on bare specimens, which instead anchor to
282
+ // the exact hovered element below (so the pin matches the hover preview halo).
283
+ if (!target && !isBareSpecimen() && typeof document.elementsFromPoint === 'function') {
284
+ const stack = document.elementsFromPoint(clientX, clientY);
285
+ for (const candidate of stack) {
286
+ const stamped = (candidate as Element).closest?.('[data-cd-id]') as HTMLElement | null;
287
+ if (!stamped) continue;
288
+ if (!stamped.closest('.dc-artboard-body')) continue;
289
+ const artboardEl = stamped.closest('[data-dc-screen]');
290
+ target = {
291
+ el: stamped,
292
+ cdId: stamped.getAttribute('data-cd-id'),
293
+ artboardId: artboardEl?.getAttribute('data-dc-screen') ?? null,
294
+ };
295
+ break;
296
+ }
297
+ }
298
+
299
+ // Bare-specimen anchor — specimens have no `.dc-artboard-body`, so
300
+ // resolveHoverTarget bails. Anchor to the EXACT element under the cursor (the
301
+ // same one pickSpecimenEl highlights for the hover preview), via its own
302
+ // data-cd-id when stamped, else a cssPath selector — so the dropped pin lands
303
+ // on the previewed element instead of floating.
304
+ if (!target && isBareSpecimen()) {
305
+ const el = pickSpecimenEl(clientX, clientY);
306
+ if (el) target = { el, cdId: el.getAttribute('data-cd-id'), artboardId: null };
307
+ }
308
+
309
+ if (!target) {
310
+ // Floating comment — no element anchor, just the click point (e.g. a click
311
+ // on empty canvas/specimen dead space). The overlay renders a pin at the
312
+ // stored bounds.
313
+ const floatingSel: Selection = {
314
+ file,
315
+ id: undefined,
316
+ selector: '',
317
+ artboardId: null,
318
+ tag: '',
319
+ classes: '',
320
+ text: '',
321
+ dom_path: [],
322
+ bounds: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
323
+ html: '',
324
+ };
325
+ openComposer(floatingSel, clientX, clientY);
326
+ return;
327
+ }
328
+
329
+ const sel = hoverTargetToSelection(target, file);
330
+ selSet.replace(sel);
331
+ openComposer(sel, clientX, clientY);
332
+ }
333
+
334
+ function openComposer(selection: Selection, clientX: number, clientY: number): void {
335
+ if (typeof document !== 'undefined') {
336
+ try {
337
+ document.dispatchEvent(
338
+ new CustomEvent('cm:open-composer', { detail: { selection, clientX, clientY } })
339
+ );
340
+ } catch {
341
+ /* CustomEvent absent — fall through to parent path */
342
+ }
343
+ }
344
+ if (typeof window !== 'undefined') {
345
+ try {
346
+ window.parent.postMessage({ dgn: 'comment-compose', selection }, '*');
347
+ } catch {
348
+ /* parent detached */
349
+ }
350
+ }
351
+ }
352
+
353
+ // ─────────────────────────────────────────────────────────────────────────────
354
+ // mountCanvas — the shell harness entry. Renders the canvas wrapped in the lite
355
+ // comment tree, or bare when comments are disabled (gallery thumbnails pass
356
+ // `commentsEnabled: false` via `?comments=0`).
357
+
358
+ export interface MountCanvasOptions {
359
+ rootEl: HTMLElement;
360
+ /** Canvas file key (designRel-prefixed). Defaults to `deriveFile()`. */
361
+ file?: string;
362
+ /** When false, the comment layer is skipped — the canvas renders raw. */
363
+ commentsEnabled: boolean;
364
+ }
365
+
366
+ export function mountCanvas(Canvas: ComponentType, opts: MountCanvasOptions): void {
367
+ const root = createRoot(opts.rootEl);
368
+ if (!opts.commentsEnabled) {
369
+ root.render(createElement(Canvas));
370
+ return;
371
+ }
372
+ const file = opts.file ?? deriveFile();
373
+ root.render(
374
+ createElement(
375
+ MaybeToolProvider,
376
+ null,
377
+ createElement(
378
+ MaybeSelectionSetProvider,
379
+ null,
380
+ createElement(CommentHost, { file }, createElement(Canvas))
381
+ )
382
+ )
383
+ );
384
+ }
@@ -95,7 +95,7 @@ import {
95
95
  import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
96
96
  import { CollabProvider, canvasSlugFromPath } from './use-collab.tsx';
97
97
  import { useSelectionSetOptional } from './use-selection-set.tsx';
98
- import { ToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
98
+ import { MaybeToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
99
99
  import { UndoStackProvider, useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
100
100
 
101
101
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1161,7 +1161,10 @@ interface DesignCanvasProps {
1161
1161
  *
1162
1162
  * `ToolProvider` lives above `DesignCanvasInner` so the viewport
1163
1163
  * controller's `isPanDragActive` predicate can read the live tool state
1164
- * via `useToolModeOptional` (hand-mode bare-drag pan).
1164
+ * via `useToolModeOptional` (hand-mode bare-drag pan). It's wrapped in
1165
+ * `MaybeToolProvider` so when the shell-owned comment mount layer
1166
+ * (canvas-comment-mount.tsx) already provides a ToolProvider, DesignCanvas
1167
+ * consumes that single instance instead of double-mounting.
1165
1168
  */
1166
1169
  export function DesignCanvas(props: DesignCanvasProps) {
1167
1170
  // Phase 20 — per-canvas undo/redo stack (DDR-050 rev 2). The provider
@@ -1178,9 +1181,9 @@ export function DesignCanvas(props: DesignCanvasProps) {
1178
1181
  // CollabProvider is omitted; useCollab() falls back gracefully to null.
1179
1182
  const collabSlug = canvasSlugFromPath(canvasFile);
1180
1183
  const inner = (
1181
- <ToolProvider>
1184
+ <MaybeToolProvider>
1182
1185
  <DesignCanvasInner {...props} />
1183
- </ToolProvider>
1186
+ </MaybeToolProvider>
1184
1187
  );
1185
1188
  return (
1186
1189
  <UndoStackProvider canvasFile={canvasFile}>
@@ -2300,11 +2303,18 @@ export const MOTION_ROLE_DEFAULTS: Record<MotionRole, RoleConfig> = {
2300
2303
  spring: {
2301
2304
  durationToken: '--dur-panel',
2302
2305
  easingToken: 'spring',
2303
- keyframes: { y: [0, -16, 0] },
2306
+ // Spring physics animate toward a single target; a 3-point array
2307
+ // (`[0,-16,0]`) makes motion/react's spring no-op (no movement). Use a
2308
+ // 2-point target and let `repeatType: 'reverse'` carry the return leg.
2309
+ keyframes: { y: [0, -16] },
2304
2310
  fallbackMs: 320,
2305
2311
  },
2306
2312
  scroll: {
2307
- durationToken: '--dur-route',
2313
+ // `--dur-route` is intentionally ~instant (route changes are snap, often
2314
+ // 1ms), which makes a scroll-linked drift imperceptible. Bind to the
2315
+ // longer `--dur-soft` so the demo (and any time-driven scroll fallback)
2316
+ // is actually visible.
2317
+ durationToken: '--dur-soft',
2308
2318
  easingToken: '--ease-in-out',
2309
2319
  keyframes: { x: [0, 24, 0] },
2310
2320
  fallbackMs: 480,
@@ -2425,6 +2435,11 @@ export function MotionDemo({
2425
2435
  const repeat = reduced || loop === 'once' ? 0 : Number.POSITIVE_INFINITY;
2426
2436
  const repeatType: 'reverse' | 'loop' = loop === 'always' ? 'reverse' : 'loop';
2427
2437
  const animate = reduced ? undefined : cfg.keyframes;
2438
+ // DS durations are micro-interaction speeds (often <200ms). Looping them with
2439
+ // no gap strobes ~10×/s. Insert a rest between cycles so each loop replays the
2440
+ // REAL token speed, then pauses — readable cadence, not a flicker. Spring's
2441
+ // own settle is the pause, so it skips the extra delay.
2442
+ const repeatDelay = loop === 'always' && !isSpring ? 0.9 : 0;
2428
2443
 
2429
2444
  return (
2430
2445
  <div
@@ -2441,6 +2456,7 @@ export function MotionDemo({
2441
2456
  type: isSpring ? 'spring' : 'tween',
2442
2457
  repeat,
2443
2458
  repeatType,
2459
+ repeatDelay,
2444
2460
  }}
2445
2461
  className="motion-demo__target"
2446
2462
  aria-label={label}