@1agh/maude 0.24.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 (50) hide show
  1. package/package.json +8 -8
  2. package/plugins/design/dependencies.json +30 -2
  3. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  4. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  5. package/plugins/design/dev-server/api.ts +15 -1
  6. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  7. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  8. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  9. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  10. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  12. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  13. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  14. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  15. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  16. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  17. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  18. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  19. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  20. package/plugins/design/dev-server/client/app.jsx +1084 -417
  21. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  22. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  23. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  24. package/plugins/design/dev-server/exporters/html.ts +4 -1
  25. package/plugins/design/dev-server/exporters/index.ts +4 -1
  26. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  27. package/plugins/design/dev-server/exporters/png.ts +21 -2
  28. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  29. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  30. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  31. package/plugins/design/dev-server/http.ts +40 -9
  32. package/plugins/design/dev-server/input-router.tsx +9 -2
  33. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  34. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  35. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  36. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  37. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  38. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  39. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  40. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  41. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  42. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  43. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  44. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  45. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  46. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  47. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  48. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  49. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  50. package/plugins/design/templates/_shell.html +36 -9
@@ -4,6 +4,14 @@
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>]
@@ -27,7 +35,7 @@
27
35
  # Stdout: one line per canvas, tab-separated:
28
36
  # STATUS \t FILE \t SCREENSHOT \t DETAIL
29
37
  # Stderr: diagnostic / progress.
30
- # 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.
31
39
 
32
40
  REPO=""
33
41
  OUT_DIR=""
@@ -176,6 +184,59 @@ urlencode_path() {
176
184
  printf '%s' "$1" | sed 's/ /%20/g'
177
185
  }
178
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
+
179
240
  # Probe a canvas via agent-browser. Returns three lines on stdout:
180
241
  # <status> one of OK / BLANK / ERROR
181
242
  # <detail> short summary string
@@ -183,6 +244,7 @@ urlencode_path() {
183
244
  probe_agent_browser() {
184
245
  local url="$1"
185
246
  local out_png="$2"
247
+ local is_specimen="$3" # 1 → apply the DDR-068 computed-style gate
186
248
 
187
249
  agent-browser open "$url" >/dev/null 2>&1 || { echo "ERROR"; echo "open-failed"; echo ""; return; }
188
250
 
@@ -254,6 +316,35 @@ probe_agent_browser() {
254
316
  return
255
317
  fi
256
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
346
+ fi
347
+
257
348
  echo "OK"
258
349
  echo "ok"
259
350
  echo "$out_png"
@@ -274,6 +365,13 @@ printf 'status\tfile\tscreenshot\tdetail\n' > "$TSV"
274
365
  echo "|---|---|---|---|"
275
366
  } > "$MD"
276
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
+
277
375
  FAILED=0
278
376
  N=0
279
377
  while IFS= read -r CANVAS; do
@@ -288,11 +386,13 @@ while IFS= read -r CANVAS; do
288
386
  URL="http://localhost:$PORT/_canvas-shell.html?canvas=$REL_ENC"
289
387
  SLUG=$(bash "$SLUG_HELPER" "$REL" 2>/dev/null || printf '%s' "$REL" | tr '/ ' '__' | tr '[:upper:]' '[:lower:]')
290
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
291
391
 
292
392
  printf ' [%d/%d] %s … ' "$N" "$COUNT" "$REL" >&2
293
393
 
294
394
  if [ "$ENGINE" = "agent-browser" ]; then
295
- RESULT=$(probe_agent_browser "$URL" "$OUT_PNG")
395
+ RESULT=$(probe_agent_browser "$URL" "$OUT_PNG" "$IS_SPECIMEN")
296
396
  else
297
397
  # Playwright fallback — coarser: just screenshot, accept any PNG > 2 KB as OK.
298
398
  # See _screenshot-playwright.mjs for the underlying tool.
@@ -314,10 +414,11 @@ while IFS= read -r CANVAS; do
314
414
  SHOT=$(printf '%s\n' "$RESULT" | sed -n '3p')
315
415
 
316
416
  case "$STATUS" in
317
- OK) SYM="✓"; echo "$SYM" >&2 ;;
318
- BLANK) SYM="✗"; FAILED=$((FAILED + 1)); echo "$SYM blank ($DETAIL)" >&2 ;;
319
- ERROR) SYM="⚠"; FAILED=$((FAILED + 1)); echo "$SYM error ($DETAIL)" >&2 ;;
320
- *) 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 ;;
321
422
  esac
322
423
 
323
424
  printf '%s\t%s\t%s\t%s\n' "$STATUS" "$REL" "$SHOT" "$DETAIL" >> "$TSV"
@@ -326,12 +427,13 @@ while IFS= read -r CANVAS; do
326
427
  printf '%s\t%s\t%s\t%s\n' "$STATUS" "$REL" "$SHOT" "$DETAIL"
327
428
  done <<< "$CANVASES"
328
429
 
430
+ TOTAL_FAIL=$((FAILED + LINT_FAILED))
329
431
  {
330
432
  echo
331
- if [ $FAILED -eq 0 ]; then
332
- 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."
333
435
  else
334
- echo "**Result:** ✗ $FAILED / $COUNT canvases failed."
436
+ echo "**Result:** ✗ $FAILED / $COUNT canvases failed render/style; $LINT_FAILED import-graph lint violation(s)."
335
437
  fi
336
438
  } >> "$MD"
337
439
 
@@ -347,9 +449,9 @@ if command -v git >/dev/null 2>&1 && git -C "$REPO" rev-parse HEAD >/dev/null 2>
347
449
  > "$DESIGN_ROOT/_history/_smoke/.last-smoke.json"
348
450
  fi
349
451
 
350
- if [ $FAILED -gt 0 ]; then
351
- echo "✗ smoke: $FAILED / $COUNT canvases failed" >&2
452
+ if [ $TOTAL_FAIL -gt 0 ]; then
453
+ echo "✗ smoke: $FAILED / $COUNT canvases failed render/style; $LINT_FAILED lint violation(s)" >&2
352
454
  exit 3
353
455
  fi
354
- echo "✓ smoke: all $COUNT canvases rendered" >&2
456
+ echo "✓ smoke: all $COUNT canvases rendered styled; import-graph lint clean" >&2
355
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);