@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
@@ -0,0 +1,519 @@
1
+ // annotations round-trip — Phase 21. The PARSE side of the schema (svgToStrokes)
2
+ // needs a DOMParser, which bun:test does not expose natively. We register
3
+ // happy-dom for THIS file only (beforeAll/afterAll) so the rest of the suite
4
+ // keeps running DOM-free; the existing annotations-layer.test.ts covers the
5
+ // write side (strokesToSvg) without a DOM.
6
+ //
7
+ // The load-bearing test here is the back-compat canary (Task 10): a hand-frozen
8
+ // pre-Phase-21 fixture must survive parse → re-serialize BYTE-IDENTICAL. Any
9
+ // "phantom default" (e.g. always emitting data-end-head="triangle") would bloat
10
+ // every legacy SVG on the first load→save cycle and this test would catch it.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
13
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
14
+
15
+ import {
16
+ type ArrowStroke,
17
+ type RectStroke,
18
+ type StickyStroke,
19
+ type Stroke,
20
+ type TextStroke,
21
+ strokesToSvg,
22
+ svgToStrokes,
23
+ } from '../annotations-layer.tsx';
24
+ import { sanitizeAnnotationSvg } from '../api.ts';
25
+
26
+ beforeAll(() => {
27
+ GlobalRegistrator.register();
28
+ });
29
+ afterAll(async () => {
30
+ await GlobalRegistrator.unregister();
31
+ });
32
+
33
+ /** Re-serialize a freshly-parsed SVG. Round-trip helper. */
34
+ function reparse(svg: string): string {
35
+ return strokesToSvg(svgToStrokes(svg));
36
+ }
37
+
38
+ describe('annotations round-trip / back-compat canary (Task 10)', () => {
39
+ test('a frozen pre-Phase-21 SVG round-trips BYTE-IDENTICAL', async () => {
40
+ const fixture = await Bun.file(
41
+ new URL('./fixtures/phase-20-annotations.svg', import.meta.url)
42
+ ).text();
43
+ // No trailing newline in the fixture; svgToStrokes trims its input but the
44
+ // serializer never emits trailing whitespace, so raw === raw must hold.
45
+ expect(fixture.endsWith('</svg>')).toBe(true);
46
+ const roundTripped = reparse(fixture);
47
+ expect(roundTripped).toBe(fixture);
48
+ });
49
+
50
+ test('parsing the fixture yields exactly the five legacy strokes', async () => {
51
+ const fixture = await Bun.file(
52
+ new URL('./fixtures/phase-20-annotations.svg', import.meta.url)
53
+ ).text();
54
+ const strokes = svgToStrokes(fixture);
55
+ expect(strokes.map((s) => s.tool)).toEqual(['pen', 'rect', 'ellipse', 'arrow', 'text']);
56
+ // The legacy rect must NOT pick up a phantom non-zero radius.
57
+ const rect = strokes.find((s) => s.tool === 'rect') as RectStroke;
58
+ expect(rect.cornerRadius ?? 0).toBe(0);
59
+ // The legacy arrow must NOT pick up phantom head/dash overrides.
60
+ const arrow = strokes.find((s) => s.tool === 'arrow') as ArrowStroke;
61
+ expect(arrow.startHead).toBeUndefined();
62
+ expect(arrow.endHead).toBeUndefined();
63
+ expect(arrow.dashed).toBeUndefined();
64
+ // The legacy text keeps its anchor.
65
+ const text = strokes.find((s) => s.tool === 'text') as TextStroke;
66
+ expect(text.anchorId).toBe('r1');
67
+ });
68
+ });
69
+
70
+ describe('annotations round-trip / Phase-21 back-compat canary (Phase 24, Task 2)', () => {
71
+ // A frozen Phase-21-era canvas — sticky + rounded rect + arrow-both-heads-
72
+ // dashed. Phase 24 must round-trip it BYTE-IDENTICAL (the new polygon /
73
+ // 6-head / line-type / bold-strike-align fields all serialize only for
74
+ // non-default values, so a Phase-21 scene gains zero bytes).
75
+ test('a frozen Phase-21 SVG round-trips BYTE-IDENTICAL', async () => {
76
+ const fixture = await Bun.file(
77
+ new URL('./fixtures/phase-21-annotations.svg', import.meta.url)
78
+ ).text();
79
+ expect(fixture.endsWith('</svg>')).toBe(true);
80
+ expect(reparse(fixture)).toBe(fixture);
81
+ });
82
+
83
+ test('parsing the Phase-21 fixture keeps each shape free of phantom Phase-24 fields', async () => {
84
+ const fixture = await Bun.file(
85
+ new URL('./fixtures/phase-21-annotations.svg', import.meta.url)
86
+ ).text();
87
+ const strokes = svgToStrokes(fixture);
88
+ expect(strokes.map((s) => s.tool)).toEqual(['sticky', 'rect', 'arrow']);
89
+ const sticky = strokes.find((s) => s.tool === 'sticky') as StickyStroke;
90
+ // No phantom bold/strike/align on a plain sticky.
91
+ expect(sticky.bold).toBeUndefined();
92
+ expect(sticky.strike).toBeUndefined();
93
+ expect(sticky.align).toBeUndefined();
94
+ expect(sticky.cornerRadius).toBe(8);
95
+ const arrow = strokes.find((s) => s.tool === 'arrow') as ArrowStroke;
96
+ expect(arrow.startHead).toBe('triangle');
97
+ expect(arrow.endHead).toBeUndefined(); // triangle is the default → unset
98
+ expect(arrow.dashed).toBe(true);
99
+ expect(arrow.lineType).toBeUndefined(); // straight default → no phantom
100
+ });
101
+ });
102
+
103
+ describe('annotations round-trip / sticky', () => {
104
+ const sticky: StickyStroke = {
105
+ id: 'st1',
106
+ tool: 'sticky',
107
+ color: '#ffe27a',
108
+ x: 40,
109
+ y: 50,
110
+ w: 200,
111
+ h: 160,
112
+ text: 'approve copy?',
113
+ fontSize: 14,
114
+ cornerRadius: 8,
115
+ };
116
+
117
+ test('sticky survives serialize → parse with all fields intact', () => {
118
+ const [parsed] = svgToStrokes(strokesToSvg([sticky])) as StickyStroke[];
119
+ expect(parsed).toEqual(sticky);
120
+ });
121
+
122
+ test('sticky with multi-line text round-trips the newline', () => {
123
+ const multi: StickyStroke = { ...sticky, text: 'line one\nline two' };
124
+ const [parsed] = svgToStrokes(strokesToSvg([multi])) as StickyStroke[];
125
+ expect(parsed?.text).toBe('line one\nline two');
126
+ });
127
+
128
+ test('sticky serialize→parse is idempotent', () => {
129
+ const once = strokesToSvg([sticky]);
130
+ expect(reparse(once)).toBe(once);
131
+ });
132
+
133
+ test('sticky never persists a foreignObject (sanitizer would strip it)', () => {
134
+ expect(strokesToSvg([sticky])).not.toContain('foreignObject');
135
+ });
136
+
137
+ test('sticky SVG survives sanitizeAnnotationSvg byte-intact (PUT-path)', () => {
138
+ // The persisted form passes through the DDR-060 F1 sanitizer on every PUT.
139
+ // It uses only allowlisted elements (g/rect/text) and no denied attrs, so
140
+ // it must come back unchanged — otherwise sticky text wouldn't persist.
141
+ const svg = strokesToSvg([sticky]);
142
+ expect(sanitizeAnnotationSvg(svg)).toBe(svg);
143
+ });
144
+
145
+ test('sticky body text still parses after a sanitize pass', () => {
146
+ const svg = strokesToSvg([{ ...sticky, text: 'survive me' }]);
147
+ const cleaned = sanitizeAnnotationSvg(svg);
148
+ const [parsed] = svgToStrokes(cleaned) as StickyStroke[];
149
+ expect(parsed?.text).toBe('survive me');
150
+ });
151
+ });
152
+
153
+ describe('annotations round-trip / rect corner radius', () => {
154
+ const base: RectStroke = {
155
+ id: 'r',
156
+ tool: 'rect',
157
+ color: '#1d6cf0',
158
+ width: 2,
159
+ x: 0,
160
+ y: 0,
161
+ w: 50,
162
+ h: 50,
163
+ fill: null,
164
+ };
165
+
166
+ for (const r of [0, 8, 999]) {
167
+ test(`cornerRadius ${r} round-trips`, () => {
168
+ const [parsed] = svgToStrokes(strokesToSvg([{ ...base, cornerRadius: r }])) as RectStroke[];
169
+ expect(parsed?.cornerRadius ?? 0).toBe(r);
170
+ });
171
+ }
172
+ });
173
+
174
+ describe('annotations round-trip / arrow heads + dash (4 dirs × 2 dash)', () => {
175
+ const base: ArrowStroke = {
176
+ id: 'a',
177
+ tool: 'arrow',
178
+ color: '#1a8f3e',
179
+ width: 2,
180
+ x1: 0,
181
+ y1: 0,
182
+ x2: 100,
183
+ y2: 0,
184
+ };
185
+
186
+ // The four FigJam direction presets, expressed as (startHead, endHead) pairs.
187
+ const dirs: Array<{
188
+ name: string;
189
+ startHead: 'none' | 'triangle';
190
+ endHead: 'none' | 'triangle';
191
+ }> = [
192
+ { name: 'none (line)', startHead: 'none', endHead: 'none' },
193
+ { name: 'start', startHead: 'triangle', endHead: 'none' },
194
+ { name: 'end (default)', startHead: 'none', endHead: 'triangle' },
195
+ { name: 'both', startHead: 'triangle', endHead: 'triangle' },
196
+ ];
197
+
198
+ for (const d of dirs) {
199
+ for (const dashed of [false, true]) {
200
+ test(`${d.name} + ${dashed ? 'dashed' : 'solid'} round-trips`, () => {
201
+ const arrow: ArrowStroke = {
202
+ ...base,
203
+ startHead: d.startHead,
204
+ endHead: d.endHead,
205
+ dashed,
206
+ };
207
+ const svg = strokesToSvg([arrow]);
208
+ const [parsed] = svgToStrokes(svg) as ArrowStroke[];
209
+ // Normalize defaults: the parser leaves default values undefined.
210
+ expect(parsed?.startHead ?? 'none').toBe(d.startHead);
211
+ expect(parsed?.endHead ?? 'triangle').toBe(d.endHead);
212
+ expect(parsed?.dashed ?? false).toBe(dashed);
213
+ // And it is idempotent at the byte level.
214
+ expect(reparse(svg)).toBe(svg);
215
+ });
216
+ }
217
+ }
218
+ });
219
+
220
+ describe('annotations round-trip / standalone vs anchored text', () => {
221
+ test('standalone text round-trips its world (x, y) and stays unanchored', () => {
222
+ const t: TextStroke = {
223
+ id: 't-std',
224
+ tool: 'text',
225
+ color: '#1a1a1a',
226
+ fontSize: 20,
227
+ text: 'side note',
228
+ x: 120,
229
+ y: 80,
230
+ };
231
+ const [parsed] = svgToStrokes(strokesToSvg([t])) as TextStroke[];
232
+ expect(parsed?.anchorId).toBeUndefined();
233
+ expect(parsed?.x).toBe(120);
234
+ expect(parsed?.y).toBe(80);
235
+ expect(parsed?.text).toBe('side note');
236
+ expect(parsed?.fontSize).toBe(20);
237
+ });
238
+
239
+ test('anchored text round-trips its host id (back-compat)', () => {
240
+ const t: TextStroke = {
241
+ id: 't-anc',
242
+ tool: 'text',
243
+ color: '#1a1a1a',
244
+ fontSize: 14,
245
+ text: 'label',
246
+ anchorId: 'r-host',
247
+ };
248
+ const [parsed] = svgToStrokes(strokesToSvg([t])) as TextStroke[];
249
+ expect(parsed?.anchorId).toBe('r-host');
250
+ expect(parsed?.x).toBeUndefined();
251
+ });
252
+ });
253
+
254
+ describe('annotations round-trip / mixed scene idempotency', () => {
255
+ test('a scene mixing every Phase 21 shape re-serializes identically', () => {
256
+ const scene: Stroke[] = [
257
+ {
258
+ id: 'p',
259
+ tool: 'pen',
260
+ color: '#000',
261
+ width: 3,
262
+ points: [
263
+ [0, 0],
264
+ [5, 5],
265
+ ],
266
+ },
267
+ {
268
+ id: 'r',
269
+ tool: 'rect',
270
+ color: '#222',
271
+ width: 2,
272
+ x: 0,
273
+ y: 0,
274
+ w: 40,
275
+ h: 40,
276
+ fill: null,
277
+ cornerRadius: 999,
278
+ },
279
+ {
280
+ id: 'a',
281
+ tool: 'arrow',
282
+ color: '#111',
283
+ width: 2,
284
+ x1: 0,
285
+ y1: 0,
286
+ x2: 50,
287
+ y2: 50,
288
+ startHead: 'triangle',
289
+ endHead: 'triangle',
290
+ dashed: true,
291
+ },
292
+ {
293
+ id: 'st',
294
+ tool: 'sticky',
295
+ color: '#ffd6e7',
296
+ x: 10,
297
+ y: 10,
298
+ w: 200,
299
+ h: 160,
300
+ text: 'hi',
301
+ fontSize: 14,
302
+ cornerRadius: 8,
303
+ },
304
+ { id: 'tx', tool: 'text', color: '#000', fontSize: 14, text: 'free', x: 5, y: 6 },
305
+ ];
306
+ const svg = strokesToSvg(scene);
307
+ expect(reparse(svg)).toBe(svg);
308
+ });
309
+ });
310
+
311
+ // ─────────────────────────────────────────────────────────────────────────────
312
+ // Phase 24 — polygon, full arrowhead set + line-type, text/sticky bold/strike/
313
+ // align. Parse needs a DOMParser (registered via happy-dom above).
314
+
315
+ describe('annotations round-trip / Phase 24 polygon', () => {
316
+ for (const shape of ['diamond', 'triangle', 'triangle-down'] as const) {
317
+ test(`${shape} round-trips its bbox + shape + fill`, () => {
318
+ const poly = {
319
+ id: `pg-${shape}`,
320
+ tool: 'polygon' as const,
321
+ shape,
322
+ color: '#e5484d',
323
+ width: 3,
324
+ x: 10,
325
+ y: 20,
326
+ w: 80,
327
+ h: 60,
328
+ fill: '#fbe0e1',
329
+ };
330
+ const svg = strokesToSvg([poly]);
331
+ expect(svg).toContain(`data-shape="${shape}"`);
332
+ const [parsed] = svgToStrokes(svg) as Array<typeof poly>;
333
+ expect(parsed?.tool).toBe('polygon');
334
+ expect(parsed?.shape).toBe(shape);
335
+ expect(parsed?.x).toBeCloseTo(10, 4);
336
+ expect(parsed?.y).toBeCloseTo(20, 4);
337
+ expect(parsed?.w).toBeCloseTo(80, 4);
338
+ expect(parsed?.h).toBeCloseTo(60, 4);
339
+ expect(parsed?.fill).toBe('#fbe0e1');
340
+ expect(reparse(svg)).toBe(svg); // idempotent
341
+ });
342
+ }
343
+
344
+ test('dashed polygon round-trips the dash flag', () => {
345
+ const svg = strokesToSvg([
346
+ {
347
+ id: 'pg',
348
+ tool: 'polygon',
349
+ shape: 'diamond',
350
+ color: '#222',
351
+ width: 2,
352
+ x: 0,
353
+ y: 0,
354
+ w: 40,
355
+ h: 40,
356
+ dashed: true,
357
+ },
358
+ ]);
359
+ expect(svg).toContain('data-dash="1"');
360
+ const [parsed] = svgToStrokes(svg) as Array<{ dashed?: boolean }>;
361
+ expect(parsed?.dashed).toBe(true);
362
+ });
363
+ });
364
+
365
+ describe('annotations round-trip / Phase 24 full arrowhead set + line-type', () => {
366
+ const heads: ArrowStroke['startHead'][] = [
367
+ 'none',
368
+ 'line',
369
+ 'triangle',
370
+ 'triangle-outline',
371
+ 'circle',
372
+ 'diamond',
373
+ ];
374
+ for (const start of heads) {
375
+ for (const end of heads) {
376
+ test(`heads start=${start} end=${end} round-trip`, () => {
377
+ const arrow: ArrowStroke = {
378
+ id: 'a',
379
+ tool: 'arrow',
380
+ color: '#1a8f3e',
381
+ width: 3,
382
+ x1: 0,
383
+ y1: 0,
384
+ x2: 100,
385
+ y2: 20,
386
+ startHead: start,
387
+ endHead: end,
388
+ };
389
+ const svg = strokesToSvg([arrow]);
390
+ const [parsed] = svgToStrokes(svg) as ArrowStroke[];
391
+ expect(parsed?.startHead ?? 'none').toBe(start);
392
+ expect(parsed?.endHead ?? 'triangle').toBe(end);
393
+ expect(reparse(svg)).toBe(svg);
394
+ });
395
+ }
396
+ }
397
+
398
+ for (const lineType of ['straight', 'curved', 'elbow'] as const) {
399
+ test(`lineType=${lineType} round-trips + endpoints recover from the shaft`, () => {
400
+ const arrow: ArrowStroke = {
401
+ id: 'a',
402
+ tool: 'arrow',
403
+ color: '#3b82f6',
404
+ width: 2,
405
+ x1: 12,
406
+ y1: 8,
407
+ x2: 90,
408
+ y2: 64,
409
+ lineType,
410
+ };
411
+ const svg = strokesToSvg([arrow]);
412
+ const [parsed] = svgToStrokes(svg) as ArrowStroke[];
413
+ expect(parsed?.lineType ?? 'straight').toBe(lineType);
414
+ // Endpoints survive whether the shaft is a <line> or a <path>.
415
+ expect(parsed?.x1).toBeCloseTo(12, 4);
416
+ expect(parsed?.y1).toBeCloseTo(8, 4);
417
+ expect(parsed?.x2).toBeCloseTo(90, 4);
418
+ expect(parsed?.y2).toBeCloseTo(64, 4);
419
+ expect(reparse(svg)).toBe(svg);
420
+ });
421
+ }
422
+ });
423
+
424
+ describe('annotations round-trip / Phase 24 text + sticky bold/strike/align', () => {
425
+ for (const align of ['left', 'center', 'right'] as const) {
426
+ test(`standalone text align=${align} + bold + strike round-trips`, () => {
427
+ const t: TextStroke = {
428
+ id: 't',
429
+ tool: 'text',
430
+ color: '#1a1a1a',
431
+ fontSize: 36,
432
+ text: 'huge',
433
+ x: 10,
434
+ y: 20,
435
+ bold: true,
436
+ strike: true,
437
+ align,
438
+ };
439
+ const [parsed] = svgToStrokes(strokesToSvg([t])) as TextStroke[];
440
+ expect(parsed?.bold).toBe(true);
441
+ expect(parsed?.strike).toBe(true);
442
+ // 'left' is the standalone default → serializer omits it → parser leaves undefined.
443
+ expect(parsed?.align ?? 'left').toBe(align);
444
+ });
445
+ }
446
+
447
+ test('anchored text default (centre, no bold/strike) stays byte-identical', () => {
448
+ const t: TextStroke = {
449
+ id: 't',
450
+ tool: 'text',
451
+ color: '#1a1a1a',
452
+ fontSize: 14,
453
+ text: 'x',
454
+ anchorId: 'h',
455
+ };
456
+ const svg = strokesToSvg([t]);
457
+ expect(svg).not.toContain('data-align');
458
+ expect(svg).not.toContain('font-weight');
459
+ expect(svg).not.toContain('text-decoration');
460
+ expect(reparse(svg)).toBe(svg);
461
+ });
462
+
463
+ test('sticky bold/strike/align round-trips; a plain sticky gains no attrs', () => {
464
+ const plain: StickyStroke = {
465
+ id: 'st',
466
+ tool: 'sticky',
467
+ color: '#fce8a6',
468
+ x: 0,
469
+ y: 0,
470
+ w: 200,
471
+ h: 200,
472
+ text: 'note',
473
+ fontSize: 16,
474
+ };
475
+ const plainSvg = strokesToSvg([plain]);
476
+ expect(plainSvg).not.toContain('data-bold');
477
+ expect(plainSvg).not.toContain('data-align');
478
+ const styled: StickyStroke = { ...plain, bold: true, strike: true, align: 'center' };
479
+ const svg = strokesToSvg([styled]);
480
+ expect(svg).toContain('data-bold="1"');
481
+ expect(svg).toContain('data-strike="1"');
482
+ expect(svg).toContain('data-align="center"');
483
+ const [parsed] = svgToStrokes(svg) as StickyStroke[];
484
+ expect(parsed?.bold).toBe(true);
485
+ expect(parsed?.strike).toBe(true);
486
+ expect(parsed?.align).toBe('center');
487
+ });
488
+ });
489
+
490
+ describe('annotations round-trip / Phase 24 arrowhead parse-clamp (DDR-067 security)', () => {
491
+ // A hub-pushed SVG with an out-of-vocabulary / poisoned data-*-head must be
492
+ // REJECTED on parse (not cast through unchecked) so it can never reach the
493
+ // serializer to attempt a quote-breakout.
494
+ test('an out-of-vocab data-start-head is dropped, not cast through', () => {
495
+ const dirty =
496
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
497
+ '<g data-id="a" data-tool="arrow" stroke="#111" stroke-width="2" fill="none" ' +
498
+ 'data-start-head="t&quot;onload=&quot;alert(1)" data-end-head="evil">' +
499
+ '<line x1="0" y1="0" x2="50" y2="0"/></g></svg>';
500
+ const [parsed] = svgToStrokes(dirty) as ArrowStroke[];
501
+ expect(parsed?.tool).toBe('arrow');
502
+ expect(parsed?.startHead).toBeUndefined(); // poisoned → rejected
503
+ expect(parsed?.endHead).toBeUndefined(); // 'evil' → rejected
504
+ // Re-serializing the clamped stroke emits NO poisoned attribute.
505
+ const reserialized = strokesToSvg(svgToStrokes(dirty));
506
+ expect(reserialized).not.toContain('onload');
507
+ expect(reserialized).not.toContain('evil');
508
+ });
509
+
510
+ test('a valid expanded head (circle/diamond) still round-trips', () => {
511
+ const ok =
512
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
513
+ '<g data-id="a" data-tool="arrow" stroke="#111" stroke-width="2" fill="none" data-start-head="circle" data-end-head="diamond">' +
514
+ '<line x1="0" y1="0" x2="50" y2="0"/><polygon points="0,0 0,0 0,0" fill="#111"/><polygon points="0,0 0,0 0,0 0,0" fill="#111"/></g></svg>';
515
+ const [parsed] = svgToStrokes(ok) as ArrowStroke[];
516
+ expect(parsed?.startHead).toBe('circle');
517
+ expect(parsed?.endHead).toBe('diamond');
518
+ });
519
+ });
@@ -0,0 +1,162 @@
1
+ // canvas-cursors — Phase 21 custom tool cursors. Validates the data-URI CSS
2
+ // cursor shape so a malformed string (which silently disables the cursor in the
3
+ // browser) can't ship undetected.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import { TOOL_CURSORS, resolveToolCursor } from '../canvas-cursors.ts';
8
+
9
+ const ALL_TOOLS = [
10
+ 'move',
11
+ 'hand',
12
+ 'comment',
13
+ 'pen',
14
+ 'shape',
15
+ 'rect',
16
+ 'ellipse',
17
+ 'sticky',
18
+ 'arrow',
19
+ 'text',
20
+ 'eraser',
21
+ ] as const;
22
+
23
+ describe('canvas-cursors / TOOL_CURSORS', () => {
24
+ test('covers every tool', () => {
25
+ for (const t of ALL_TOOLS) {
26
+ expect(typeof TOOL_CURSORS[t]).toBe('string');
27
+ expect(TOOL_CURSORS[t].length).toBeGreaterThan(0);
28
+ }
29
+ });
30
+
31
+ test('Phase 24 — every tool ships a Kenney cursor (move is no longer the bare system arrow)', () => {
32
+ // The brief: ONE unified cursor library for ALL tools. `move` now ships a
33
+ // Kenney arrow glyph with a `default` native fallback (not bare 'default').
34
+ expect(TOOL_CURSORS.move).toContain('url("data:image/svg+xml,');
35
+ expect(TOOL_CURSORS.move).toMatch(/, default$/);
36
+ });
37
+
38
+ test('custom cursors are well-formed data-URI CSS values with hotspot + fallback', () => {
39
+ const fallback: Record<string, string> = {
40
+ move: 'default',
41
+ hand: 'grab',
42
+ comment: 'crosshair',
43
+ pen: 'crosshair',
44
+ shape: 'crosshair',
45
+ rect: 'crosshair',
46
+ ellipse: 'crosshair',
47
+ sticky: 'crosshair',
48
+ arrow: 'crosshair',
49
+ text: 'text',
50
+ eraser: 'cell',
51
+ };
52
+ for (const [tool, fb] of Object.entries(fallback)) {
53
+ const v = TOOL_CURSORS[tool as keyof typeof TOOL_CURSORS];
54
+ // url("data:image/svg+xml,<encoded>") <hx> <hy>, <fallback>
55
+ expect(v).toMatch(
56
+ new RegExp(`^url\\("data:image/svg\\+xml,%3Csvg[^"]+"\\) \\d+ \\d+, ${fb}$`)
57
+ );
58
+ // The SVG renders at 24px (Phase 24 — smaller than the 32-unit viewBox)
59
+ // and is valid encoded markup.
60
+ const decoded = decodeURIComponent(v.slice(v.indexOf(',') + 1, v.indexOf('")')));
61
+ expect(decoded).toContain("width='24'");
62
+ expect(decoded).toContain("height='24'");
63
+ expect(decoded).toContain("viewBox='0 0 32 32'");
64
+ expect(decoded).toContain('</svg>');
65
+ }
66
+ });
67
+
68
+ test('hotspots are within the 0..31 image bounds', () => {
69
+ for (const t of ALL_TOOLS) {
70
+ if (TOOL_CURSORS[t] === 'default') continue;
71
+ const m = TOOL_CURSORS[t].match(/"\) (\d+) (\d+),/);
72
+ expect(m).not.toBeNull();
73
+ const hx = Number(m?.[1]);
74
+ const hy = Number(m?.[2]);
75
+ expect(hx).toBeGreaterThanOrEqual(0);
76
+ expect(hx).toBeLessThanOrEqual(31);
77
+ expect(hy).toBeGreaterThanOrEqual(0);
78
+ expect(hy).toBeLessThanOrEqual(31);
79
+ }
80
+ });
81
+ });
82
+
83
+ // Phase 24 (DDR-067) — the canvas→shell `tool-cursor` bridge echoes a tool
84
+ // TOKEN and the shell resolves it HERE to a trusted cursor. This is the control
85
+ // that closes the phase-24 ethical-hacker Finding 2 (an untrusted synced canvas
86
+ // posting an invisible/displaced SVG cursor as a clickjacking aid over the
87
+ // un-CSP'd shell). These tests assert the confinement so it can't silently
88
+ // regress: only known tool ids resolve, and they always resolve to a trusted,
89
+ // visible data-URI cursor — never to attacker-supplied bytes.
90
+ describe('canvas-cursors / resolveToolCursor (untrusted token → trusted cursor)', () => {
91
+ test('every known tool resolves to its trusted TOOL_CURSORS string', () => {
92
+ for (const t of ALL_TOOLS) {
93
+ expect(resolveToolCursor(t)).toBe(TOOL_CURSORS[t]);
94
+ }
95
+ });
96
+
97
+ test('resolved value is always a trusted data-URI cursor (or native fallback) — never raw bytes', () => {
98
+ for (const t of ALL_TOOLS) {
99
+ const v = resolveToolCursor(t);
100
+ expect(typeof v).toBe('string');
101
+ // Each is one of our own frozen entries: a `url("data:image/svg+xml,…")`
102
+ // glyph with a hotspot + native fallback. The shell interpolates THIS,
103
+ // not the message, so there is no attacker-controlled region.
104
+ expect(v).toMatch(/^url\("data:image\/svg\+xml,%3Csvg[^"]+"\) \d+ \d+, [a-z-]+$/);
105
+ }
106
+ });
107
+
108
+ test('rejects unknown / prototype-polluting / malformed tokens', () => {
109
+ const hostile = [
110
+ // not a known tool
111
+ 'pwn',
112
+ 'banana',
113
+ // prototype chain — `in` would match these; `hasOwnProperty` must not
114
+ 'constructor',
115
+ 'toString',
116
+ 'hasOwnProperty',
117
+ '__proto__',
118
+ // shape-gate violations (uppercase / digits / punctuation / whitespace)
119
+ 'Move',
120
+ 'pen1',
121
+ 'pen ',
122
+ ' pen',
123
+ 'pen;',
124
+ 'pe.n',
125
+ '',
126
+ // the OLD attack vector: a full cursor STRING is no longer accepted —
127
+ // only a bare token is, so an invisible/displaced SVG cursor can't pass
128
+ 'url("data:image/svg+xml,%3Csvg/%3E") 0 0, none',
129
+ ];
130
+ for (const tok of hostile) {
131
+ expect(resolveToolCursor(tok)).toBeNull();
132
+ }
133
+ });
134
+
135
+ test('rejects non-string tokens (typeof short-circuits BEFORE coercion)', () => {
136
+ // The `{ toString }` / `[ 'pen' ]` cases prove the guard checks `typeof`
137
+ // first and never coerces — a malicious `toString`/`Symbol.toPrimitive`
138
+ // that returns a valid tool id must NOT smuggle a cursor through.
139
+ let coerced = false;
140
+ const hostileCoercible = {
141
+ toString() {
142
+ coerced = true;
143
+ return 'pen';
144
+ },
145
+ };
146
+ for (const tok of [
147
+ null,
148
+ undefined,
149
+ 42,
150
+ {},
151
+ [],
152
+ ['pen'],
153
+ true,
154
+ Symbol('pen'),
155
+ hostileCoercible,
156
+ ]) {
157
+ // biome-ignore lint/suspicious/noExplicitAny: deliberate hostile-input test
158
+ expect(resolveToolCursor(tok as any)).toBeNull();
159
+ }
160
+ expect(coerced).toBe(false);
161
+ });
162
+ });