@1agh/maude 0.22.2 → 0.24.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 (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -10,6 +10,9 @@
10
10
  # [--include-system 0|1] (default 1)
11
11
  # [--timeout <secs>] (default 8)
12
12
  # [--engine auto|agent-browser|playwright]
13
+ # [--changed-only] (default off — screenshot only canvases changed
14
+ # since the last smoke run; escalates to the full
15
+ # set when dev-server / canvas-lib / templates changed)
13
16
  #
14
17
  # Reads:
15
18
  # $DESIGN_ROOT/_server.json (must exist — caller runs server-up.sh first)
@@ -31,6 +34,7 @@ OUT_DIR=""
31
34
  INCLUDE_SYSTEM=1
32
35
  TIMEOUT=8
33
36
  ENGINE="auto"
37
+ CHANGED_ONLY=0
34
38
 
35
39
  while [ $# -gt 0 ]; do
36
40
  case "$1" in
@@ -39,8 +43,9 @@ while [ $# -gt 0 ]; do
39
43
  --include-system) INCLUDE_SYSTEM="$2"; shift 2 ;;
40
44
  --timeout) TIMEOUT="$2"; shift 2 ;;
41
45
  --engine) ENGINE="$2"; shift 2 ;;
46
+ --changed-only) CHANGED_ONLY=1; shift ;;
42
47
  --help|-h)
43
- sed -n '2,28p' "$0" | sed 's/^# \?//'
48
+ sed -n '2,30p' "$0" | sed 's/^# \?//'
44
49
  exit 0
45
50
  ;;
46
51
  *)
@@ -73,6 +78,10 @@ if [ -z "$OUT_DIR" ]; then
73
78
  OUT_DIR="$DESIGN_ROOT/_history/_smoke/$TS"
74
79
  fi
75
80
  mkdir -p "$OUT_DIR"
81
+ # agent-browser ignores RELATIVE screenshot paths (writes to ~/.agent-browser/tmp
82
+ # instead), which silently strands every PNG. Canonicalize to absolute so the
83
+ # captured evidence actually lands in OUT_DIR. See DDR-021.
84
+ OUT_DIR="$(cd "$OUT_DIR" && pwd)"
76
85
 
77
86
  # ---------- engine resolution ----------
78
87
  if [ "$ENGINE" = "auto" ]; then
@@ -107,6 +116,57 @@ COUNT=$(printf '%s' "$CANVASES" | grep -c .)
107
116
  [ "$COUNT" -gt 0 ] || { echo "smoke.sh: no canvases found (ui/*.tsx or system/*/preview/*.tsx)" >&2; exit 1; }
108
117
  echo "→ found $COUNT canvases" >&2
109
118
 
119
+ # ---------- --changed-only incremental filter (Phase C / DDR-061) ----------
120
+ # Default smoke screenshots every canvas. --changed-only narrows to the canvases
121
+ # touched since the last recorded smoke run, UNLESS the diff touches an
122
+ # "everything could break" shape (dev-server, canvas-lib, canvas templates) — then
123
+ # it escalates back to the full set. Correctness > speed: no baseline, no git, or
124
+ # an escalation trigger all fall back to the full set.
125
+ MARKER="$DESIGN_ROOT/_history/_smoke/.last-smoke.json"
126
+ if [ "$CHANGED_ONLY" = "1" ]; then
127
+ LAST_SHA=""
128
+ if [ -f "$MARKER" ] && command -v jq >/dev/null 2>&1; then
129
+ LAST_SHA=$(jq -r '.sha // empty' "$MARKER" 2>/dev/null)
130
+ fi
131
+ if ! command -v git >/dev/null 2>&1 || ! git -C "$REPO" rev-parse HEAD >/dev/null 2>&1; then
132
+ echo "→ --changed-only: no git in $REPO — running full set" >&2
133
+ elif [ -z "$LAST_SHA" ]; then
134
+ echo "→ --changed-only: no prior smoke baseline ($MARKER) — running full set" >&2
135
+ else
136
+ # All paths changed between the last smoke and the current working tree
137
+ # (committed + uncommitted), plus untracked canvases.
138
+ CHANGED=$(
139
+ { git -C "$REPO" diff --name-only "$LAST_SHA" -- 2>/dev/null
140
+ git -C "$REPO" ls-files --others --exclude-standard -- .design/ui .design/system 2>/dev/null
141
+ } | sort -u
142
+ )
143
+ if printf '%s\n' "$CHANGED" | grep -qE 'dev-server/|canvas-lib\.tsx|canvas[^/]*\.tsx\.template'; then
144
+ echo "→ --changed-only: dev-server / canvas-lib / template changed — escalating to FULL set" >&2
145
+ else
146
+ # Keep only canvases whose repo-relative path is in the changed set.
147
+ FILTERED=""
148
+ while IFS= read -r CANVAS; do
149
+ [ -z "$CANVAS" ] && continue
150
+ REL_REPO="${CANVAS#$REPO/}"
151
+ if printf '%s\n' "$CHANGED" | grep -qxF "$REL_REPO"; then
152
+ FILTERED="$FILTERED$CANVAS"$'\n'
153
+ fi
154
+ done <<< "$CANVASES"
155
+ CANVASES=$(printf '%s' "$FILTERED" | sed '/^$/d')
156
+ COUNT=$(printf '%s' "$CANVASES" | grep -c .)
157
+ if [ "$COUNT" -eq 0 ]; then
158
+ echo "→ --changed-only: no canvas .tsx changed since last smoke ($LAST_SHA) — nothing to screenshot" >&2
159
+ # Refresh the marker so the next run diffs from here, then exit clean.
160
+ mkdir -p "$(dirname "$MARKER")"
161
+ printf '{"sha":"%s","ts":"%s","mode":"changed-only-noop"}' \
162
+ "$(git -C "$REPO" rev-parse HEAD 2>/dev/null)" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$MARKER"
163
+ exit 0
164
+ fi
165
+ echo "→ --changed-only: $COUNT changed canvas(es) since $LAST_SHA" >&2
166
+ fi
167
+ fi
168
+ fi
169
+
110
170
  # ---------- helpers ----------
111
171
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
112
172
  SLUG_HELPER="$SCRIPT_DIR/slug.sh"
@@ -160,6 +220,8 @@ probe_agent_browser() {
160
220
  for (const s of sel) { const el = document.querySelector(s); if (el && el.innerText) return el.innerText.slice(0, 120); }
161
221
  const body = document.body && document.body.innerText || '';
162
222
  if (body.startsWith('Error:') || body.startsWith('SyntaxError:') || body.startsWith('ReferenceError:')) return body.slice(0, 120);
223
+ const t = body.trim();
224
+ if (t === 'Not found' || t.startsWith('Forbidden (canvas origin)')) return 'route-error: ' + t.slice(0, 80);
163
225
  return '';
164
226
  })()" 2>/dev/null)
165
227
  # Strip wrapping quotes that agent-browser eval adds for strings.
@@ -172,16 +234,24 @@ probe_agent_browser() {
172
234
  return
173
235
  fi
174
236
 
237
+ # The PNG IS the evidence (DDR-021). A missing/empty file means agent-browser
238
+ # never wrote it (relative path, capture crash, …) — that is a FAILURE, not an
239
+ # OK. The old code fell straight through to "OK" here, which is exactly how a
240
+ # broken canvas route ("Not found" 404 + no PNG) masqueraded as 43/43 green.
241
+ if [ ! -s "$out_png" ]; then
242
+ echo "ERROR"
243
+ echo "screenshot-not-written"
244
+ echo "$out_png"
245
+ return
246
+ fi
175
247
  # PNG size sanity — < 2 KB usually means blank background only.
176
- if [ -s "$out_png" ]; then
177
- local size
178
- size=$(wc -c < "$out_png" 2>/dev/null | tr -d ' ')
179
- if [ -n "$size" ] && [ "$size" -lt 2048 ]; then
180
- echo "BLANK"
181
- echo "png-${size}B"
182
- echo "$out_png"
183
- return
184
- fi
248
+ local size
249
+ size=$(wc -c < "$out_png" 2>/dev/null | tr -d ' ')
250
+ if [ -n "$size" ] && [ "$size" -lt 2048 ]; then
251
+ echo "BLANK"
252
+ echo "png-${size}B"
253
+ echo "$out_png"
254
+ return
185
255
  fi
186
256
 
187
257
  echo "OK"
@@ -211,7 +281,11 @@ while IFS= read -r CANVAS; do
211
281
  N=$((N + 1))
212
282
  REL="${CANVAS#$DESIGN_ROOT/}"
213
283
  REL_ENC=$(urlencode_path "$REL")
214
- URL="http://localhost:$PORT/$REL_ENC"
284
+ # Canvases render through the canvas shell, not the bare path. The bare
285
+ # `/<rel>` route 404s when the canvas-origin sandbox is on (default since
286
+ # phase-9.1) — only `/_canvas-shell.html?canvas=<rel>` mounts the canvas
287
+ # (works in both split-on and legacy same-origin modes).
288
+ URL="http://localhost:$PORT/_canvas-shell.html?canvas=$REL_ENC"
215
289
  SLUG=$(bash "$SLUG_HELPER" "$REL" 2>/dev/null || printf '%s' "$REL" | tr '/ ' '__' | tr '[:upper:]' '[:lower:]')
216
290
  OUT_PNG="$OUT_DIR/$SLUG.png"
217
291
 
@@ -264,6 +338,15 @@ done <<< "$CANVASES"
264
338
  echo "→ report: $MD" >&2
265
339
  echo "→ tsv: $TSV" >&2
266
340
 
341
+ # Record this run as the baseline for the next --changed-only diff (Phase C / DDR-061).
342
+ if command -v git >/dev/null 2>&1 && git -C "$REPO" rev-parse HEAD >/dev/null 2>&1; then
343
+ mkdir -p "$DESIGN_ROOT/_history/_smoke"
344
+ printf '{"sha":"%s","ts":"%s","mode":"%s","count":%d,"failed":%d}' \
345
+ "$(git -C "$REPO" rev-parse HEAD 2>/dev/null)" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
346
+ "$([ "$CHANGED_ONLY" = "1" ] && echo changed-only || echo full)" "$COUNT" "$FAILED" \
347
+ > "$DESIGN_ROOT/_history/_smoke/.last-smoke.json"
348
+ fi
349
+
267
350
  if [ $FAILED -gt 0 ]; then
268
351
  echo "✗ smoke: $FAILED / $COUNT canvases failed" >&2
269
352
  exit 3
@@ -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,10 +449,16 @@ 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
@@ -415,7 +474,7 @@ async function main() {
415
474
  // on-disk artifacts against .min-sizes.json after the build.
416
475
  if (process.env.MAUDE_SKIP_RUNTIME_BUILD === '1') {
417
476
  console.log(
418
- `[build] dist/runtime/*.js SKIPPED (MAUDE_SKIP_RUNTIME_BUILD=1 — using committed pre-built)`
477
+ '[build] dist/runtime/*.js SKIPPED (MAUDE_SKIP_RUNTIME_BUILD=1 — using committed pre-built)'
419
478
  );
420
479
  } else {
421
480
  const runtime = await buildRuntimeBundles();
@@ -444,4 +503,11 @@ if (import.meta.main) {
444
503
  await main();
445
504
  }
446
505
 
447
- 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
+ }