@1agh/maude 0.23.0 → 0.25.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 (127) 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 +32 -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 +126 -2
  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 +74 -10
  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/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -4,12 +4,23 @@
4
4
  # per-canvas hooks in /design:edit step 7 / /design:new step 9 — typically
5
5
  # dev-server infra changes or bulk multi-canvas migrations. See DDR-021.
6
6
  #
7
+ # Beyond "does it render", preview specimens are also gated on "does it render
8
+ # STYLED" (DDR-068): a static import-graph lint (every specimen reaches
9
+ # _layout.css — its single CSS entry, which @imports the tokens + _components.css
10
+ # — and every shared preview/_*.css has >=1 importer) plus a runtime
11
+ # computed-style check (the DS token contract `--bg-0` must resolve on the body,
12
+ # else the canvas's import graph lost the token CSS and every var()-driven rule
13
+ # is dead — the specimen mounts with content but renders unstyled).
14
+ #
7
15
  # Usage:
8
16
  # smoke.sh [--root <repo>]
9
17
  # [--out-dir <dir>]
10
18
  # [--include-system 0|1] (default 1)
11
19
  # [--timeout <secs>] (default 8)
12
20
  # [--engine auto|agent-browser|playwright]
21
+ # [--changed-only] (default off — screenshot only canvases changed
22
+ # since the last smoke run; escalates to the full
23
+ # set when dev-server / canvas-lib / templates changed)
13
24
  #
14
25
  # Reads:
15
26
  # $DESIGN_ROOT/_server.json (must exist — caller runs server-up.sh first)
@@ -24,13 +35,14 @@
24
35
  # Stdout: one line per canvas, tab-separated:
25
36
  # STATUS \t FILE \t SCREENSHOT \t DETAIL
26
37
  # Stderr: diagnostic / progress.
27
- # Exit: 0 = all green / 3 = at least one blank/error / 1 = missing deps / 2 = bad args.
38
+ # Exit: 0 = all green / 3 = at least one blank/error/unstyled/lint-fail / 1 = missing deps / 2 = bad args.
28
39
 
29
40
  REPO=""
30
41
  OUT_DIR=""
31
42
  INCLUDE_SYSTEM=1
32
43
  TIMEOUT=8
33
44
  ENGINE="auto"
45
+ CHANGED_ONLY=0
34
46
 
35
47
  while [ $# -gt 0 ]; do
36
48
  case "$1" in
@@ -39,8 +51,9 @@ while [ $# -gt 0 ]; do
39
51
  --include-system) INCLUDE_SYSTEM="$2"; shift 2 ;;
40
52
  --timeout) TIMEOUT="$2"; shift 2 ;;
41
53
  --engine) ENGINE="$2"; shift 2 ;;
54
+ --changed-only) CHANGED_ONLY=1; shift ;;
42
55
  --help|-h)
43
- sed -n '2,28p' "$0" | sed 's/^# \?//'
56
+ sed -n '2,30p' "$0" | sed 's/^# \?//'
44
57
  exit 0
45
58
  ;;
46
59
  *)
@@ -73,6 +86,10 @@ if [ -z "$OUT_DIR" ]; then
73
86
  OUT_DIR="$DESIGN_ROOT/_history/_smoke/$TS"
74
87
  fi
75
88
  mkdir -p "$OUT_DIR"
89
+ # agent-browser ignores RELATIVE screenshot paths (writes to ~/.agent-browser/tmp
90
+ # instead), which silently strands every PNG. Canonicalize to absolute so the
91
+ # captured evidence actually lands in OUT_DIR. See DDR-021.
92
+ OUT_DIR="$(cd "$OUT_DIR" && pwd)"
76
93
 
77
94
  # ---------- engine resolution ----------
78
95
  if [ "$ENGINE" = "auto" ]; then
@@ -107,6 +124,57 @@ COUNT=$(printf '%s' "$CANVASES" | grep -c .)
107
124
  [ "$COUNT" -gt 0 ] || { echo "smoke.sh: no canvases found (ui/*.tsx or system/*/preview/*.tsx)" >&2; exit 1; }
108
125
  echo "→ found $COUNT canvases" >&2
109
126
 
127
+ # ---------- --changed-only incremental filter (Phase C / DDR-061) ----------
128
+ # Default smoke screenshots every canvas. --changed-only narrows to the canvases
129
+ # touched since the last recorded smoke run, UNLESS the diff touches an
130
+ # "everything could break" shape (dev-server, canvas-lib, canvas templates) — then
131
+ # it escalates back to the full set. Correctness > speed: no baseline, no git, or
132
+ # an escalation trigger all fall back to the full set.
133
+ MARKER="$DESIGN_ROOT/_history/_smoke/.last-smoke.json"
134
+ if [ "$CHANGED_ONLY" = "1" ]; then
135
+ LAST_SHA=""
136
+ if [ -f "$MARKER" ] && command -v jq >/dev/null 2>&1; then
137
+ LAST_SHA=$(jq -r '.sha // empty' "$MARKER" 2>/dev/null)
138
+ fi
139
+ if ! command -v git >/dev/null 2>&1 || ! git -C "$REPO" rev-parse HEAD >/dev/null 2>&1; then
140
+ echo "→ --changed-only: no git in $REPO — running full set" >&2
141
+ elif [ -z "$LAST_SHA" ]; then
142
+ echo "→ --changed-only: no prior smoke baseline ($MARKER) — running full set" >&2
143
+ else
144
+ # All paths changed between the last smoke and the current working tree
145
+ # (committed + uncommitted), plus untracked canvases.
146
+ CHANGED=$(
147
+ { git -C "$REPO" diff --name-only "$LAST_SHA" -- 2>/dev/null
148
+ git -C "$REPO" ls-files --others --exclude-standard -- .design/ui .design/system 2>/dev/null
149
+ } | sort -u
150
+ )
151
+ if printf '%s\n' "$CHANGED" | grep -qE 'dev-server/|canvas-lib\.tsx|canvas[^/]*\.tsx\.template'; then
152
+ echo "→ --changed-only: dev-server / canvas-lib / template changed — escalating to FULL set" >&2
153
+ else
154
+ # Keep only canvases whose repo-relative path is in the changed set.
155
+ FILTERED=""
156
+ while IFS= read -r CANVAS; do
157
+ [ -z "$CANVAS" ] && continue
158
+ REL_REPO="${CANVAS#$REPO/}"
159
+ if printf '%s\n' "$CHANGED" | grep -qxF "$REL_REPO"; then
160
+ FILTERED="$FILTERED$CANVAS"$'\n'
161
+ fi
162
+ done <<< "$CANVASES"
163
+ CANVASES=$(printf '%s' "$FILTERED" | sed '/^$/d')
164
+ COUNT=$(printf '%s' "$CANVASES" | grep -c .)
165
+ if [ "$COUNT" -eq 0 ]; then
166
+ echo "→ --changed-only: no canvas .tsx changed since last smoke ($LAST_SHA) — nothing to screenshot" >&2
167
+ # Refresh the marker so the next run diffs from here, then exit clean.
168
+ mkdir -p "$(dirname "$MARKER")"
169
+ printf '{"sha":"%s","ts":"%s","mode":"changed-only-noop"}' \
170
+ "$(git -C "$REPO" rev-parse HEAD 2>/dev/null)" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$MARKER"
171
+ exit 0
172
+ fi
173
+ echo "→ --changed-only: $COUNT changed canvas(es) since $LAST_SHA" >&2
174
+ fi
175
+ fi
176
+ fi
177
+
110
178
  # ---------- helpers ----------
111
179
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
112
180
  SLUG_HELPER="$SCRIPT_DIR/slug.sh"
@@ -116,6 +184,59 @@ urlencode_path() {
116
184
  printf '%s' "$1" | sed 's/ /%20/g'
117
185
  }
118
186
 
187
+ # Static import-graph lint (DDR-068). Runs once before render. The dev-server
188
+ # inlines ONLY the CSS a canvas's import graph produces (canvas-build.ts), so a
189
+ # forgotten import is a silently unstyled specimen that still "has content" and
190
+ # sails past the per-canvas render checks below. Asserts, per DS preview dir:
191
+ # (1) every specimen reaches `_layout.css` (direct `import`, or its own css
192
+ # `@import`s it) — _layout.css is the single CSS entry that pulls tokens
193
+ # (colors_and_type.css) + controls (_components.css);
194
+ # (2) every shared `preview/_*.css` has >=1 importer (no orphan partial — the
195
+ # class of bug where `.btn`/`.input` in _components.css loaded NOWHERE).
196
+ # Appends violations to $MD; sets LINT_FAILED. Anchored to real `^import` lines
197
+ # so a specimen that DISPLAYS a css filename in its content isn't a false match.
198
+ LINT_FAILED=0
199
+ lint_specimen_imports() {
200
+ local SQ Q preview tsx base css imp impfile reached n=0
201
+ SQ=$(printf '\047'); Q="[\"$SQ]" # bracket class matching either quote style
202
+ for preview in "$DESIGN_ROOT"/system/*/preview; do
203
+ [ -d "$preview" ] || continue
204
+ # (1) every specimen reaches _layout.css
205
+ for tsx in "$preview"/*.tsx; do
206
+ [ -e "$tsx" ] || continue
207
+ base=$(basename "$tsx"); case "$base" in _*) continue ;; esac
208
+ grep -qE "^[[:space:]]*import[^;]*${Q}[^\"$SQ]*_layout\.css" "$tsx" && continue
209
+ reached=0
210
+ while IFS= read -r imp; do
211
+ impfile="$preview/$(basename "$imp")"
212
+ [ -f "$impfile" ] || continue
213
+ if grep -qE "@import[^;]*_layout\.css" "$impfile"; then reached=1; break; fi
214
+ done < <(grep -oE "import[[:space:]]+${Q}[^\"$SQ]*\.css" "$tsx" 2>/dev/null | grep -oE "[^\"$SQ]*\.css$")
215
+ if [ "$reached" -eq 0 ]; then
216
+ printf 'LINT-FAIL %s — never reaches _layout.css (no DS tokens/layout/components)\n' "${tsx#$DESIGN_ROOT/}" >&2
217
+ printf '| ✗ LINT | `%s` | — | never reaches _layout.css |\n' "${tsx#$DESIGN_ROOT/}" >> "$MD"
218
+ n=$((n + 1))
219
+ fi
220
+ done
221
+ # (2) every shared _*.css partial has an importer
222
+ for css in "$preview"/_*.css; do
223
+ [ -e "$css" ] || continue
224
+ base=$(basename "$css")
225
+ grep -qsE "^[[:space:]]*import[^;]*${Q}[^\"$SQ]*${base}${Q}" "$preview"/*.tsx && continue
226
+ grep -qsE "@import[^;]*${base}" "$preview"/*.css "$preview"/../*.css && continue
227
+ printf 'LINT-FAIL %s — orphan shared partial (no specimen or css imports it)\n' "${css#$DESIGN_ROOT/}" >&2
228
+ printf '| ✗ LINT | `%s` | — | orphan shared partial |\n' "${css#$DESIGN_ROOT/}" >> "$MD"
229
+ n=$((n + 1))
230
+ done
231
+ done
232
+ LINT_FAILED=$n
233
+ if [ "$n" -eq 0 ]; then
234
+ echo "→ import-graph lint: clean" >&2
235
+ else
236
+ echo "✗ import-graph lint: $n violation(s) — see report" >&2
237
+ fi
238
+ }
239
+
119
240
  # Probe a canvas via agent-browser. Returns three lines on stdout:
120
241
  # <status> one of OK / BLANK / ERROR
121
242
  # <detail> short summary string
@@ -123,6 +244,7 @@ urlencode_path() {
123
244
  probe_agent_browser() {
124
245
  local url="$1"
125
246
  local out_png="$2"
247
+ local is_specimen="$3" # 1 → apply the DDR-068 computed-style gate
126
248
 
127
249
  agent-browser open "$url" >/dev/null 2>&1 || { echo "ERROR"; echo "open-failed"; echo ""; return; }
128
250
 
@@ -160,6 +282,8 @@ probe_agent_browser() {
160
282
  for (const s of sel) { const el = document.querySelector(s); if (el && el.innerText) return el.innerText.slice(0, 120); }
161
283
  const body = document.body && document.body.innerText || '';
162
284
  if (body.startsWith('Error:') || body.startsWith('SyntaxError:') || body.startsWith('ReferenceError:')) return body.slice(0, 120);
285
+ const t = body.trim();
286
+ if (t === 'Not found' || t.startsWith('Forbidden (canvas origin)')) return 'route-error: ' + t.slice(0, 80);
163
287
  return '';
164
288
  })()" 2>/dev/null)
165
289
  # Strip wrapping quotes that agent-browser eval adds for strings.
@@ -172,16 +296,53 @@ probe_agent_browser() {
172
296
  return
173
297
  fi
174
298
 
299
+ # The PNG IS the evidence (DDR-021). A missing/empty file means agent-browser
300
+ # never wrote it (relative path, capture crash, …) — that is a FAILURE, not an
301
+ # OK. The old code fell straight through to "OK" here, which is exactly how a
302
+ # broken canvas route ("Not found" 404 + no PNG) masqueraded as 43/43 green.
303
+ if [ ! -s "$out_png" ]; then
304
+ echo "ERROR"
305
+ echo "screenshot-not-written"
306
+ echo "$out_png"
307
+ return
308
+ fi
175
309
  # 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
310
+ local size
311
+ size=$(wc -c < "$out_png" 2>/dev/null | tr -d ' ')
312
+ if [ -n "$size" ] && [ "$size" -lt 2048 ]; then
313
+ echo "BLANK"
314
+ echo "png-${size}B"
315
+ echo "$out_png"
316
+ return
317
+ fi
318
+
319
+ # Computed-style gate (DDR-068) — "mounts with content" ≠ "rendered styled". A
320
+ # specimen whose import graph dropped the token CSS passes every check above
321
+ # (it has text, no error, a >2 KB screenshot) yet every var()-driven rule is
322
+ # dead. Confirm the DS token contract actually resolved at runtime: --bg-0 is
323
+ # defined on :root by colors_and_type.css (DDR-043 name contract) and inherits
324
+ # to <body>, so an empty value means the token CSS never loaded. Scoped to DS
325
+ # preview specimens — ui/ app canvases may legitimately opt out of the tokens.
326
+ if [ "$is_specimen" = "1" ]; then
327
+ local styled
328
+ styled=$(agent-browser eval "(() => {
329
+ const b = document.body; if (!b) return 'unstyled:no-body';
330
+ const cs = getComputedStyle(b);
331
+ const tok = (cs.getPropertyValue('--bg-0').trim() || cs.getPropertyValue('--fg-0').trim() || cs.getPropertyValue('--accent').trim());
332
+ if (tok) return 'styled';
333
+ return 'unstyled:no-tokens(ff=' + ((cs.fontFamily||'').replace(/[\"\\n]/g,'').slice(0,18)) + ')';
334
+ })()" 2>/dev/null)
335
+ styled=$(printf '%s' "$styled" | sed 's/^"//; s/"$//')
336
+ # Only an EXPLICIT unstyled* verdict fails — a blank/garbled eval (browser
337
+ # hiccup) falls through to OK, since the DOM already mounted without error.
338
+ case "$styled" in
339
+ unstyled*)
340
+ echo "UNSTYLED"
341
+ echo "$styled"
342
+ echo "$out_png"
343
+ return
344
+ ;;
345
+ esac
185
346
  fi
186
347
 
187
348
  echo "OK"
@@ -204,6 +365,13 @@ printf 'status\tfile\tscreenshot\tdetail\n' > "$TSV"
204
365
  echo "|---|---|---|---|"
205
366
  } > "$MD"
206
367
 
368
+ # Static import-graph lint before rendering (DDR-068) — only meaningful when
369
+ # system specimens are in scope (they own the shared _layout.css / _components.css
370
+ # chain). Violations are folded into the final exit code alongside render fails.
371
+ if [ "$INCLUDE_SYSTEM" = "1" ] && [ -d "$DESIGN_ROOT/system" ]; then
372
+ lint_specimen_imports
373
+ fi
374
+
207
375
  FAILED=0
208
376
  N=0
209
377
  while IFS= read -r CANVAS; do
@@ -211,14 +379,20 @@ while IFS= read -r CANVAS; do
211
379
  N=$((N + 1))
212
380
  REL="${CANVAS#$DESIGN_ROOT/}"
213
381
  REL_ENC=$(urlencode_path "$REL")
214
- URL="http://localhost:$PORT/$REL_ENC"
382
+ # Canvases render through the canvas shell, not the bare path. The bare
383
+ # `/<rel>` route 404s when the canvas-origin sandbox is on (default since
384
+ # phase-9.1) — only `/_canvas-shell.html?canvas=<rel>` mounts the canvas
385
+ # (works in both split-on and legacy same-origin modes).
386
+ URL="http://localhost:$PORT/_canvas-shell.html?canvas=$REL_ENC"
215
387
  SLUG=$(bash "$SLUG_HELPER" "$REL" 2>/dev/null || printf '%s' "$REL" | tr '/ ' '__' | tr '[:upper:]' '[:lower:]')
216
388
  OUT_PNG="$OUT_DIR/$SLUG.png"
389
+ # DS preview specimens get the computed-style gate; ui/ app canvases don't.
390
+ case "$REL" in system/*/preview/*) IS_SPECIMEN=1 ;; *) IS_SPECIMEN=0 ;; esac
217
391
 
218
392
  printf ' [%d/%d] %s … ' "$N" "$COUNT" "$REL" >&2
219
393
 
220
394
  if [ "$ENGINE" = "agent-browser" ]; then
221
- RESULT=$(probe_agent_browser "$URL" "$OUT_PNG")
395
+ RESULT=$(probe_agent_browser "$URL" "$OUT_PNG" "$IS_SPECIMEN")
222
396
  else
223
397
  # Playwright fallback — coarser: just screenshot, accept any PNG > 2 KB as OK.
224
398
  # See _screenshot-playwright.mjs for the underlying tool.
@@ -240,10 +414,11 @@ while IFS= read -r CANVAS; do
240
414
  SHOT=$(printf '%s\n' "$RESULT" | sed -n '3p')
241
415
 
242
416
  case "$STATUS" in
243
- OK) SYM="✓"; echo "$SYM" >&2 ;;
244
- BLANK) SYM="✗"; FAILED=$((FAILED + 1)); echo "$SYM blank ($DETAIL)" >&2 ;;
245
- ERROR) SYM="⚠"; FAILED=$((FAILED + 1)); echo "$SYM error ($DETAIL)" >&2 ;;
246
- *) SYM="?"; FAILED=$((FAILED + 1)); echo "? unknown ($STATUS)" >&2 ;;
417
+ OK) SYM="✓"; echo "$SYM" >&2 ;;
418
+ BLANK) SYM="✗"; FAILED=$((FAILED + 1)); echo "$SYM blank ($DETAIL)" >&2 ;;
419
+ ERROR) SYM="⚠"; FAILED=$((FAILED + 1)); echo "$SYM error ($DETAIL)" >&2 ;;
420
+ UNSTYLED) SYM=""; FAILED=$((FAILED + 1)); echo "$SYM unstyled ($DETAIL)" >&2 ;;
421
+ *) SYM="?"; FAILED=$((FAILED + 1)); echo "? unknown ($STATUS)" >&2 ;;
247
422
  esac
248
423
 
249
424
  printf '%s\t%s\t%s\t%s\n' "$STATUS" "$REL" "$SHOT" "$DETAIL" >> "$TSV"
@@ -252,21 +427,31 @@ while IFS= read -r CANVAS; do
252
427
  printf '%s\t%s\t%s\t%s\n' "$STATUS" "$REL" "$SHOT" "$DETAIL"
253
428
  done <<< "$CANVASES"
254
429
 
430
+ TOTAL_FAIL=$((FAILED + LINT_FAILED))
255
431
  {
256
432
  echo
257
- if [ $FAILED -eq 0 ]; then
258
- echo "**Result:** ✓ all $COUNT canvases rendered."
433
+ if [ $TOTAL_FAIL -eq 0 ]; then
434
+ echo "**Result:** ✓ all $COUNT canvases rendered styled; import-graph lint clean."
259
435
  else
260
- echo "**Result:** ✗ $FAILED / $COUNT canvases failed."
436
+ echo "**Result:** ✗ $FAILED / $COUNT canvases failed render/style; $LINT_FAILED import-graph lint violation(s)."
261
437
  fi
262
438
  } >> "$MD"
263
439
 
264
440
  echo "→ report: $MD" >&2
265
441
  echo "→ tsv: $TSV" >&2
266
442
 
267
- if [ $FAILED -gt 0 ]; then
268
- echo "✗ smoke: $FAILED / $COUNT canvases failed" >&2
443
+ # Record this run as the baseline for the next --changed-only diff (Phase C / DDR-061).
444
+ if command -v git >/dev/null 2>&1 && git -C "$REPO" rev-parse HEAD >/dev/null 2>&1; then
445
+ mkdir -p "$DESIGN_ROOT/_history/_smoke"
446
+ printf '{"sha":"%s","ts":"%s","mode":"%s","count":%d,"failed":%d}' \
447
+ "$(git -C "$REPO" rev-parse HEAD 2>/dev/null)" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
448
+ "$([ "$CHANGED_ONLY" = "1" ] && echo changed-only || echo full)" "$COUNT" "$FAILED" \
449
+ > "$DESIGN_ROOT/_history/_smoke/.last-smoke.json"
450
+ fi
451
+
452
+ if [ $TOTAL_FAIL -gt 0 ]; then
453
+ echo "✗ smoke: $FAILED / $COUNT canvases failed render/style; $LINT_FAILED lint violation(s)" >&2
269
454
  exit 3
270
455
  fi
271
- echo "✓ smoke: all $COUNT canvases rendered" >&2
456
+ echo "✓ smoke: all $COUNT canvases rendered styled; import-graph lint clean" >&2
272
457
  exit 0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @file canvas-arrowheads.ts — Phase 24 arrow shaft + head geometry
3
+ * @scope plugins/design/dev-server/canvas-arrowheads.ts
4
+ * @purpose Single source of truth for arrow rendering. An ArrowStroke is
5
+ * reduced to an ordered list of `SvgPrimitive`s (the shaft, then
6
+ * the start head, then the end head). The serializer formats each
7
+ * primitive into the persisted `.annotations.svg` string; the live
8
+ * `StrokeNode` maps the SAME primitives to JSX — so the on-disk and
9
+ * on-canvas forms can never drift.
10
+ *
11
+ * Back-compat invariant (DDR-067): a default arrow (lineType
12
+ * 'straight', startHead 'none', endHead 'triangle', solid) MUST
13
+ * reduce to exactly `[<line>, <polyline fill=color>]` — the
14
+ * byte-identical Phase-5.1 form the canary fixtures freeze.
15
+ * `arrowHeadPoints` lives here (moved from annotations-layer, which
16
+ * re-exports it). This module owns the arrow style enums + a
17
+ * structural geometry input and imports NOTHING from
18
+ * annotations-layer — keeping the dependency one-way
19
+ * (annotations-layer → canvas-arrowheads) so there is no module
20
+ * cycle. (A `.ts` root in a re-export cycle with a react `.tsx`
21
+ * breaks @types/react's global `JSX` namespace project-wide under
22
+ * this tsconfig's `types: ["bun-types"]` — see DDR-067.)
23
+ */
24
+
25
+ /**
26
+ * Phase 24 — full FigJam arrowhead vocabulary, selectable per end. `none` =
27
+ * bare end; `line` = open chevron; `triangle` = the legacy filled head
28
+ * (default end); `triangle-outline` = unfilled triangle; `circle` / `diamond`
29
+ * = filled markers. annotations-layer re-exports this type.
30
+ */
31
+ export type ArrowHead = 'none' | 'line' | 'triangle' | 'triangle-outline' | 'circle' | 'diamond';
32
+ /** The valid `ArrowHead` vocabulary as a runtime set — `svgToStrokes` clamps a
33
+ * parsed `data-*-head` against this so a poisoned/out-of-vocab value can't be
34
+ * cast through unchecked (Phase 24 security review, DDR-067). */
35
+ export const ARROW_HEADS: ReadonlySet<string> = new Set<ArrowHead>([
36
+ 'none',
37
+ 'line',
38
+ 'triangle',
39
+ 'triangle-outline',
40
+ 'circle',
41
+ 'diamond',
42
+ ]);
43
+ /** Phase 24 — arrow shaft routing. `straight` is the default + legacy form. */
44
+ export type ArrowLineType = 'straight' | 'curved' | 'elbow';
45
+
46
+ /**
47
+ * Structural geometry input for {@link arrowPrimitives}. `ArrowStroke` (defined
48
+ * in annotations-layer) is assignable to this, so we depend on the shape, not
49
+ * the named type — no import back, no cycle.
50
+ */
51
+ export interface ArrowGeom {
52
+ x1: number;
53
+ y1: number;
54
+ x2: number;
55
+ y2: number;
56
+ width: number;
57
+ color: string;
58
+ startHead?: ArrowHead;
59
+ endHead?: ArrowHead;
60
+ dashed?: boolean;
61
+ lineType?: ArrowLineType;
62
+ }
63
+
64
+ /**
65
+ * The filled-triangle head outline as an SVG `points` string. Three points —
66
+ * `wingA, tip, wingB` — drawn as a <polyline> (filled = solid triangle, the
67
+ * implicit close; unfilled = open chevron) or a <polygon> (closed outline).
68
+ * Parameterized by the tip + the point the shaft arrives FROM, exactly as in
69
+ * Phase 5.1, so the legacy serialization is byte-identical.
70
+ */
71
+ export function arrowHeadPoints(
72
+ x1: number,
73
+ y1: number,
74
+ x2: number,
75
+ y2: number,
76
+ width: number
77
+ ): string {
78
+ const angle = Math.atan2(y2 - y1, x2 - x1);
79
+ const len = 12 + width * 2;
80
+ const wing = Math.PI / 7;
81
+ const ax = x2 - Math.cos(angle - wing) * len;
82
+ const ay = y2 - Math.sin(angle - wing) * len;
83
+ const bx = x2 - Math.cos(angle + wing) * len;
84
+ const by = y2 - Math.sin(angle + wing) * len;
85
+ return `${ax},${ay} ${x2},${y2} ${bx},${by}`;
86
+ }
87
+
88
+ /** A small diamond head centered on the tip, one axis along the shaft. */
89
+ function diamondHeadPoints(
90
+ fromX: number,
91
+ fromY: number,
92
+ tipX: number,
93
+ tipY: number,
94
+ width: number
95
+ ): string {
96
+ const angle = Math.atan2(tipY - fromY, tipX - fromX);
97
+ const half = 5 + width;
98
+ const ax = Math.cos(angle);
99
+ const ay = Math.sin(angle);
100
+ const px = -ay;
101
+ const py = ax;
102
+ const p = (dx: number, dy: number) => `${tipX + dx},${tipY + dy}`;
103
+ return `${p(ax * half, ay * half)} ${p(px * half, py * half)} ${p(-ax * half, -ay * half)} ${p(
104
+ -px * half,
105
+ -py * half
106
+ )}`;
107
+ }
108
+
109
+ /** Ordered SVG primitives — the shaft + heads. The renderer/serializer format these. */
110
+ export type SvgPrimitive =
111
+ | { el: 'line'; x1: number; y1: number; x2: number; y2: number; dash: boolean }
112
+ | { el: 'path'; d: string; dash: boolean }
113
+ | { el: 'polyline'; points: string; fill: string }
114
+ | { el: 'polygon'; points: string; fill: string }
115
+ | { el: 'circle'; cx: number; cy: number; r: number; fill: string };
116
+
117
+ /**
118
+ * Shaft path for a non-straight arrow + the control point each head orients
119
+ * toward (its tangent source). Straight arrows never call this — they use a
120
+ * bare <line> so the legacy bytes are preserved.
121
+ */
122
+ function shaftPath(
123
+ x1: number,
124
+ y1: number,
125
+ x2: number,
126
+ y2: number,
127
+ lineType: Exclude<ArrowLineType, 'straight'>
128
+ ): { d: string; startFrom: [number, number]; endFrom: [number, number] } {
129
+ if (lineType === 'curved') {
130
+ const mx = (x1 + x2) / 2;
131
+ const my = (y1 + y2) / 2;
132
+ const dx = x2 - x1;
133
+ const dy = y2 - y1;
134
+ const len = Math.hypot(dx, dy) || 1;
135
+ // Perpendicular bow — 22% of the chord, consistent side so curve is stable.
136
+ const off = len * 0.22;
137
+ const cx = mx + (-dy / len) * off;
138
+ const cy = my + (dx / len) * off;
139
+ return { d: `M${x1} ${y1} Q${cx} ${cy} ${x2} ${y2}`, startFrom: [cx, cy], endFrom: [cx, cy] };
140
+ }
141
+ // elbow — one orthogonal bend, along the dominant axis first.
142
+ const corner: [number, number] = Math.abs(x2 - x1) >= Math.abs(y2 - y1) ? [x2, y1] : [x1, y2];
143
+ return {
144
+ d: `M${x1} ${y1} L${corner[0]} ${corner[1]} L${x2} ${y2}`,
145
+ startFrom: corner,
146
+ endFrom: corner,
147
+ };
148
+ }
149
+
150
+ function headPrimitives(
151
+ head: ArrowHead,
152
+ tipX: number,
153
+ tipY: number,
154
+ fromX: number,
155
+ fromY: number,
156
+ width: number,
157
+ color: string
158
+ ): SvgPrimitive[] {
159
+ switch (head) {
160
+ case 'none':
161
+ return [];
162
+ case 'triangle':
163
+ // Legacy filled head — byte-identical to Phase 5.1.
164
+ return [
165
+ { el: 'polyline', points: arrowHeadPoints(fromX, fromY, tipX, tipY, width), fill: color },
166
+ ];
167
+ case 'line':
168
+ // Open chevron — same 3 points, unfilled (no implicit close).
169
+ return [
170
+ { el: 'polyline', points: arrowHeadPoints(fromX, fromY, tipX, tipY, width), fill: 'none' },
171
+ ];
172
+ case 'triangle-outline':
173
+ // Closed triangle outline — polygon auto-closes the third edge.
174
+ return [
175
+ { el: 'polygon', points: arrowHeadPoints(fromX, fromY, tipX, tipY, width), fill: 'none' },
176
+ ];
177
+ case 'diamond':
178
+ return [
179
+ { el: 'polygon', points: diamondHeadPoints(fromX, fromY, tipX, tipY, width), fill: color },
180
+ ];
181
+ case 'circle':
182
+ return [{ el: 'circle', cx: tipX, cy: tipY, r: 4 + width, fill: color }];
183
+ default:
184
+ // Defensive: an out-of-vocabulary head (e.g. a poisoned hub-pushed value
185
+ // that slipped past the parse-clamp) renders NOTHING rather than throwing
186
+ // a TypeError mid-render — no per-stroke render DoS (Phase 24 security
187
+ // review, DDR-067). Parse-clamp + serialize-esc are the primary controls.
188
+ return [];
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Reduce an arrow to its ordered SVG primitives: shaft, then start head, then
194
+ * end head (matching the legacy serialization order). Defaults: start 'none',
195
+ * end 'triangle', lineType 'straight', solid.
196
+ */
197
+ export function arrowPrimitives(s: ArrowGeom): SvgPrimitive[] {
198
+ const startHead = s.startHead ?? 'none';
199
+ const endHead = s.endHead ?? 'triangle';
200
+ const dashed = s.dashed ?? false;
201
+ const lineType = s.lineType ?? 'straight';
202
+ const out: SvgPrimitive[] = [];
203
+
204
+ // Default tangent sources (straight): each head points away from the far end.
205
+ let startFrom: [number, number] = [s.x2, s.y2];
206
+ let endFrom: [number, number] = [s.x1, s.y1];
207
+
208
+ if (lineType === 'straight') {
209
+ out.push({ el: 'line', x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2, dash: dashed });
210
+ } else {
211
+ const shaft = shaftPath(s.x1, s.y1, s.x2, s.y2, lineType);
212
+ out.push({ el: 'path', d: shaft.d, dash: dashed });
213
+ startFrom = shaft.startFrom;
214
+ endFrom = shaft.endFrom;
215
+ }
216
+
217
+ out.push(...headPrimitives(startHead, s.x1, s.y1, startFrom[0], startFrom[1], s.width, s.color));
218
+ out.push(...headPrimitives(endHead, s.x2, s.y2, endFrom[0], endFrom[1], s.width, s.color));
219
+ return out;
220
+ }
@@ -48,29 +48,14 @@ import {
48
48
  import { MaybeToolProvider, useToolMode } from './use-tool-mode.tsx';
49
49
 
50
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
- }
51
+ // Phase 24 the comment-mode cursor (and every other tool cursor) is owned
52
+ // SOLELY by use-tool-mode.tsx, whose `* { cursor: <Kenney glyph> !important }`
53
+ // rule is injected by the ToolProvider this layer mounts (MaybeToolProvider)
54
+ // so it covers bare DS specimens too. The old `[data-mc-host]…`/`body[…]`
55
+ // comment-cursor rule used to live here, but its higher specificity shadowed
56
+ // the unified Kenney cursor on UI canvases (comment-mount stamps
57
+ // `data-active-tool` on <body>, so `body[data-active-tool="comment"] *` matched
58
+ // there as well). Removed so the single source of truth wins. See DDR-067.
74
59
 
75
60
  // ─────────────────────────────────────────────────────────────────────────────
76
61
  // CommentHost — owns the lite comment subsystem: the input router (comment-
@@ -116,7 +101,6 @@ function pickSpecimenEl(clientX: number, clientY: number): HTMLElement | null {
116
101
  }
117
102
 
118
103
  function CommentHost({ children, file }: { children: ReactNode; file: string | undefined }) {
119
- ensureMountCursorStyles();
120
104
  const { tool, setTool } = useToolMode();
121
105
  const selSet = useSelectionSet();
122
106
  const hostRef = useRef<HTMLDivElement | null>(null);