@1agh/maude 0.40.0 → 0.41.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 (73) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -3
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/annotate.mjs +576 -34
  9. package/apps/studio/bin/canvas-rects.sh +152 -0
  10. package/apps/studio/bin/fetch-asset.sh +34 -0
  11. package/apps/studio/bin/read-annotations.mjs +138 -7
  12. package/apps/studio/bin/smoke.sh +42 -6
  13. package/apps/studio/build.ts +21 -0
  14. package/apps/studio/canvas-comment-mount.tsx +138 -4
  15. package/apps/studio/canvas-edit.ts +744 -11
  16. package/apps/studio/canvas-lib.tsx +219 -2
  17. package/apps/studio/canvas-shell.tsx +487 -20
  18. package/apps/studio/client/app.jsx +1451 -22
  19. package/apps/studio/client/comments-overlay.css +130 -126
  20. package/apps/studio/client/github.js +8 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +48 -0
  22. package/apps/studio/comments-overlay.tsx +148 -41
  23. package/apps/studio/context-menu.tsx +15 -5
  24. package/apps/studio/contextual-toolbar.tsx +262 -4
  25. package/apps/studio/cursors-overlay.tsx +4 -4
  26. package/apps/studio/dist/client.bundle.js +20 -20
  27. package/apps/studio/dist/comment-mount.js +59 -1
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/dom-selection.ts +127 -1
  30. package/apps/studio/drag-state.ts +24 -0
  31. package/apps/studio/equal-spacing-detector.ts +205 -0
  32. package/apps/studio/export-dialog.tsx +1 -1
  33. package/apps/studio/history.ts +47 -1
  34. package/apps/studio/http.ts +223 -0
  35. package/apps/studio/input-router.tsx +12 -0
  36. package/apps/studio/marquee-overlay.tsx +1 -1
  37. package/apps/studio/measure-overlay.tsx +241 -0
  38. package/apps/studio/participants-chrome.tsx +3 -3
  39. package/apps/studio/sizing-mode.ts +117 -0
  40. package/apps/studio/spacing-handles.ts +166 -0
  41. package/apps/studio/test/annotate-write.test.ts +890 -0
  42. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  43. package/apps/studio/test/canvas-edit.test.ts +50 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
  45. package/apps/studio/test/canvas-rects.test.ts +198 -0
  46. package/apps/studio/test/comments-overlay.test.ts +117 -0
  47. package/apps/studio/test/dom-selection.test.ts +130 -0
  48. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  50. package/apps/studio/test/element-resize.test.ts +136 -0
  51. package/apps/studio/test/element-structural-api.test.ts +360 -0
  52. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  53. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  54. package/apps/studio/test/history-rollback.test.ts +26 -0
  55. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  56. package/apps/studio/test/read-annotations.test.ts +154 -0
  57. package/apps/studio/test/sizing-mode.test.ts +102 -0
  58. package/apps/studio/test/spacing-handles.test.ts +138 -0
  59. package/apps/studio/test/specimen-select.test.ts +88 -0
  60. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  61. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  62. package/apps/studio/tool-palette.tsx +122 -2
  63. package/apps/studio/undo-hud.tsx +2 -2
  64. package/apps/studio/use-element-resize.tsx +732 -0
  65. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  66. package/apps/studio/use-selection-set.tsx +14 -0
  67. package/apps/studio/use-spacing-handles.tsx +388 -0
  68. package/apps/studio/whats-new.json +28 -0
  69. package/cli/commands/design.mjs +6 -1
  70. package/cli/commands/design.test.mjs +49 -1
  71. package/cli/lib/fetch-asset.test.mjs +213 -0
  72. package/package.json +8 -8
  73. package/plugins/design/dependencies.json +10 -2
@@ -0,0 +1,173 @@
1
+ // camera-reveal — Task A3 (feature-element-editing-robustness), Stage A.
2
+ //
3
+ // Regression guard for Bugs A + B: a `select-by-id` reveal must pan the camera
4
+ // (the `.dc-world` transform, via the viewport controller) and MUST NOT scroll
5
+ // the overflow:hidden `.dc-canvas` / `.dc-artboard-body` clip layers — host
6
+ // scroll is what desynced the camera model (phantom right strip + pan reset
7
+ // after an absolute-element move).
8
+ //
9
+ // canvas-shell.tsx pulls in a React/DOM dependency graph, so we register
10
+ // happy-dom for this file (like canvas-hmr-runtime.test.tsx) before importing.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
13
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
14
+
15
+ beforeAll(() => {
16
+ GlobalRegistrator.register();
17
+ });
18
+ afterAll(() => {
19
+ GlobalRegistrator.unregister();
20
+ });
21
+
22
+ import type { ViewportControllerHandle } from '../canvas-lib.tsx';
23
+ import { REVEAL_MARGIN_PX, revealAxisDelta, revealElementViaCamera } from '../canvas-shell.tsx';
24
+
25
+ const M = REVEAL_MARGIN_PX;
26
+
27
+ describe('revealAxisDelta — minimal 1-axis camera pan', () => {
28
+ // Viewport spans [0, 1000]; usable band is [M, 1000 - M].
29
+ const V0 = 0;
30
+ const V1 = 1000;
31
+
32
+ test('fully visible → no pan', () => {
33
+ expect(revealAxisDelta(200, 400, V0, V1, M)).toBe(0);
34
+ });
35
+
36
+ test('flush at the inset band edges → no pan', () => {
37
+ expect(revealAxisDelta(M, V1 - M, V0, V1, M)).toBe(0);
38
+ });
39
+
40
+ test('off the start (left/top) edge → positive delta brings the start in', () => {
41
+ // element [-50, 100]; start (-50) is left of the inset band start (M=40).
42
+ const d = revealAxisDelta(-50, 100, V0, V1, M);
43
+ expect(d).toBe(M - -50); // vs - elStart = 40 - (-50) = 90
44
+ expect(d).toBeGreaterThan(0);
45
+ });
46
+
47
+ test('off the end (right/bottom) edge → negative delta brings the end in', () => {
48
+ // element [900, 1100]; end (1100) is past the inset band end (960).
49
+ const d = revealAxisDelta(900, 1100, V0, V1, M);
50
+ expect(d).toBe(V1 - M - 1100); // ve - elEnd = 960 - 1100 = -140
51
+ expect(d).toBeLessThan(0);
52
+ });
53
+
54
+ test('element wider than the viewport → no pan (matches scrollIntoView nearest)', () => {
55
+ expect(revealAxisDelta(-200, 1200, V0, V1, M)).toBe(0);
56
+ });
57
+ });
58
+
59
+ // Minimal fake controller — only `panBy` is exercised; the rest satisfy the type.
60
+ function fakeController(): {
61
+ handle: ViewportControllerHandle;
62
+ calls: Array<[number, number]>;
63
+ } {
64
+ const calls: Array<[number, number]> = [];
65
+ const noop = () => {};
66
+ const handle: ViewportControllerHandle = {
67
+ viewport: { x: 0, y: 0, zoom: 1 },
68
+ setViewport: noop,
69
+ panBy: (dx, dy) => calls.push([dx, dy]),
70
+ zoomAt: noop,
71
+ fit: noop,
72
+ reset: noop,
73
+ zoomIn: noop,
74
+ zoomOut: noop,
75
+ jumpTo: noop,
76
+ animateTo: noop,
77
+ isInteracting: false,
78
+ };
79
+ return { handle, calls };
80
+ }
81
+
82
+ function elWithRect(rect: Partial<DOMRect>): HTMLElement {
83
+ const el = document.createElement('div');
84
+ const r = {
85
+ left: 0,
86
+ top: 0,
87
+ right: 0,
88
+ bottom: 0,
89
+ width: 0,
90
+ height: 0,
91
+ x: 0,
92
+ y: 0,
93
+ ...rect,
94
+ } as DOMRect;
95
+ el.getBoundingClientRect = () => r;
96
+ return el;
97
+ }
98
+
99
+ describe('revealElementViaCamera — pans the controller, never host scroll', () => {
100
+ test('off-screen element pans via panBy and leaves host scroll at 0', () => {
101
+ const host = elWithRect({
102
+ left: 0,
103
+ top: 0,
104
+ right: 1000,
105
+ bottom: 800,
106
+ width: 1000,
107
+ height: 800,
108
+ });
109
+ // Target sits past the right edge — the exact Bug B repro (overflowing element).
110
+ const target = elWithRect({
111
+ left: 1100,
112
+ top: 100,
113
+ right: 1300,
114
+ bottom: 200,
115
+ width: 200,
116
+ height: 100,
117
+ });
118
+ const { handle, calls } = fakeController();
119
+
120
+ revealElementViaCamera(host, target, handle);
121
+
122
+ expect(calls.length).toBe(1);
123
+ const [dx, dy] = calls[0];
124
+ expect(dx).toBeLessThan(0); // pan world left to reveal the right-side element
125
+ expect(dy).toBe(0); // vertically already in view
126
+ // The invariant: no host scroll was touched.
127
+ expect(host.scrollLeft).toBe(0);
128
+ expect(host.scrollTop).toBe(0);
129
+ });
130
+
131
+ test('already-visible element → no pan at all', () => {
132
+ const host = elWithRect({
133
+ left: 0,
134
+ top: 0,
135
+ right: 1000,
136
+ bottom: 800,
137
+ width: 1000,
138
+ height: 800,
139
+ });
140
+ const target = elWithRect({
141
+ left: 300,
142
+ top: 300,
143
+ right: 500,
144
+ bottom: 400,
145
+ width: 200,
146
+ height: 100,
147
+ });
148
+ const { handle, calls } = fakeController();
149
+
150
+ revealElementViaCamera(host, target, handle);
151
+
152
+ expect(calls.length).toBe(0);
153
+ expect(host.scrollLeft).toBe(0);
154
+ expect(host.scrollTop).toBe(0);
155
+ });
156
+
157
+ test('detached element (zero box) → no pan', () => {
158
+ const host = elWithRect({
159
+ left: 0,
160
+ top: 0,
161
+ right: 1000,
162
+ bottom: 800,
163
+ width: 1000,
164
+ height: 800,
165
+ });
166
+ const target = elWithRect({});
167
+ const { handle, calls } = fakeController();
168
+
169
+ revealElementViaCamera(host, target, handle);
170
+
171
+ expect(calls.length).toBe(0);
172
+ });
173
+ });
@@ -214,6 +214,56 @@ describe('canvas-edit / applyEdit', () => {
214
214
  });
215
215
  });
216
216
 
217
+ describe('canvas-edit / applyEdit — Stage F2 media src-replace', () => {
218
+ test('replaces a literal <img src="…"> — the ordinary "Replace image…" case', () => {
219
+ const src = `function Demo() { return <img src="assets/old.png" alt="x" />; }`;
220
+ const ids = idsOf(src);
221
+ const id = ids.img as string;
222
+ const out = applyEdit(CANVAS, src, id, 'src', 'assets/12345678.png');
223
+ expect(out.source).toBe(
224
+ `function Demo() { return <img src="assets/12345678.png" alt="x" />; }`
225
+ );
226
+ });
227
+
228
+ test('replaces a trivial {"literal"} src expression', () => {
229
+ const src = `function Demo() { return <img src={"assets/old.png"} alt="x" />; }`;
230
+ const ids = idsOf(src);
231
+ const id = ids.img as string;
232
+ const out = applyEdit(CANVAS, src, id, 'src', 'assets/new.png');
233
+ expect(out.source).toContain('src="assets/new.png"');
234
+ });
235
+
236
+ test('refuses (does not corrupt) a src bound to a JS expression', () => {
237
+ // src={imageUrl} is a BINDING, not a static value — silently overwriting it
238
+ // would discard the binding. This is the exact gotcha the plan's Task F2
239
+ // flags for "Replace image…"; the context-menu comment already assumed
240
+ // this refusal existed (canvas-shell.tsx) — it didn't, until this fix.
241
+ const src = `function Demo({ imageUrl }) { return <img src={imageUrl} alt="x" />; }`;
242
+ const ids = idsOf(src);
243
+ const id = ids.img as string;
244
+ expect(() => applyEdit(CANVAS, src, id, 'src', 'assets/new.png')).toThrow(
245
+ /bound to a JS expression/
246
+ );
247
+ // Also refuses a member-expression / template-literal binding, not just a
248
+ // bare identifier.
249
+ const src2 = `function Demo({ item }) { return <img src={item.url} alt="x" />; }`;
250
+ const ids2 = idsOf(src2);
251
+ expect(() => applyEdit(CANVAS, src2, ids2.img as string, 'src', 'assets/new.png')).toThrow(
252
+ /bound to a JS expression/
253
+ );
254
+ });
255
+
256
+ test('a non-src attribute expression binding is unaffected (existing overwrite behavior)', () => {
257
+ // The refusal is scoped to `src` (the plan's stated gotcha) — an
258
+ // expression-bound `title` or similar still overwrites as before; widening
259
+ // the refusal to every attribute is a bigger, separate change.
260
+ const src = `function Demo({ label }) { return <button title={label}>x</button>; }`;
261
+ const ids = idsOf(src);
262
+ const out = applyEdit(CANVAS, src, ids.button as string, 'title', 'Fixed label');
263
+ expect(out.source).toContain('title="Fixed label"');
264
+ });
265
+ });
266
+
217
267
  describe('canvas-edit / applyRemove (reset)', () => {
218
268
  test('removes one style key, leaving the siblings + object intact', () => {
219
269
  const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
@@ -93,6 +93,24 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
93
93
  // revert route (whole-file swap — strictly more powerful than a move).
94
94
  '/_api/reorder',
95
95
  '/_api/reorder-revert',
96
+ // Stage I (feature-element-editing-robustness) — general element
97
+ // structural edits (delete / insert / new-artboard) + free-hand artboard
98
+ // resize (D4) are ALL source-writes, MAIN-ORIGIN ONLY. The untrusted
99
+ // canvas iframe requests them over the dgn:* bus; the shell performs the
100
+ // write. A GET from the canvas origin must 403 at the gate (route
101
+ // unreachable on this origin), never 405 from a reached handler.
102
+ '/_api/delete-element',
103
+ '/_api/insert-element',
104
+ '/_api/duplicate-element',
105
+ '/_api/insert-artboard',
106
+ '/_api/resize-artboard',
107
+ '/_api/delete-artboard',
108
+ // Stage F1 — the AssetPicker's asset-listing GET is a shell (main-origin)
109
+ // concern; the untrusted canvas iframe must not enumerate project media.
110
+ '/_api/assets',
111
+ // Stage H — the edit-scope (local-vs-shared) verdict is a shell badge
112
+ // concern; the untrusted canvas origin must not reach it (dual-allowlist).
113
+ '/_api/edit-scope',
96
114
  // Phase 27 (E2) — every /_api/git/* route is MAIN-ORIGIN ONLY: absent from
97
115
  // CANVAS_SAFE_API + startCanvasServer's `routes` map. A GET from the
98
116
  // canvas origin must 403 at the gate (not 405 from a reached handler),
@@ -0,0 +1,198 @@
1
+ // canvas-rects.test.ts — feature-whiteboard-ai-toolkit. Unit + fixture
2
+ // coverage for the STATIC (no-browser) fallback shim behind
3
+ // `maude design canvas-rects`. The LIVE lane (window.__maudeCanvasRects,
4
+ // canvas-lib.tsx) is verified via a real render (agent-browser), not here —
5
+ // this file covers the deterministic offline path: JSX seed harvesting, the
6
+ // default-grid layout, and the meta.json position merge, which together must
7
+ // mirror DesignCanvasInner's `initialArtboards()` (canvas-lib.tsx) exactly so
8
+ // the static fallback never disagrees with what the browser would compute.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ import { harvestSeeds, resolveArtboards, synthDefaultGrid } from '../bin/_canvas-rects-static.mjs';
16
+
17
+ describe('harvestSeeds', () => {
18
+ test('extracts id/width/height in document order', () => {
19
+ const source = `
20
+ <DCArtboard id="hero" label="Hero" width={1200} height={800}>x</DCArtboard>
21
+ <DCArtboard id="footer" label="Footer" width={1200} height={400}>y</DCArtboard>
22
+ `;
23
+ expect(harvestSeeds(source)).toEqual([
24
+ { id: 'hero', w: 1200, h: 800 },
25
+ { id: 'footer', w: 1200, h: 400 },
26
+ ]);
27
+ });
28
+
29
+ test('defaults width/height to VP_GRID (1280x820) when absent', () => {
30
+ const source = `<DCArtboard id="bare" label="Bare">x</DCArtboard>`;
31
+ expect(harvestSeeds(source)).toEqual([{ id: 'bare', w: 1280, h: 820 }]);
32
+ });
33
+
34
+ test('mints __ab_N when id is missing', () => {
35
+ const source = `<DCArtboard label="No id" width={100} height={100}>x</DCArtboard>`;
36
+ expect(harvestSeeds(source)).toEqual([{ id: '__ab_0', w: 100, h: 100 }]);
37
+ });
38
+
39
+ test('is robust to attribute order', () => {
40
+ const source = `<DCArtboard height={500} width={900} id="reordered">x</DCArtboard>`;
41
+ expect(harvestSeeds(source)).toEqual([{ id: 'reordered', w: 900, h: 500 }]);
42
+ });
43
+
44
+ test('empty source yields no seeds', () => {
45
+ expect(harvestSeeds('')).toEqual([]);
46
+ });
47
+ });
48
+
49
+ describe('synthDefaultGrid', () => {
50
+ test('mirrors canvas-lib.tsx VP_GRID (3 cols, 80px gutter, cell = max dims)', () => {
51
+ const seeds = [
52
+ { id: 'a', w: 1200, h: 800 },
53
+ { id: 'b', w: 1440, h: 900 }, // largest — sets the cell size
54
+ { id: 'c', w: 1200, h: 800 },
55
+ { id: 'd', w: 1200, h: 800 }, // wraps to row 1 (col 0)
56
+ ];
57
+ const rects = synthDefaultGrid(seeds);
58
+ expect(rects).toEqual([
59
+ { id: 'a', x: 0, y: 0, w: 1200, h: 800 },
60
+ { id: 'b', x: 1440 + 80, y: 0, w: 1440, h: 900 },
61
+ { id: 'c', x: (1440 + 80) * 2, y: 0, w: 1200, h: 800 },
62
+ { id: 'd', x: 0, y: 900 + 80, w: 1200, h: 800 },
63
+ ]);
64
+ });
65
+
66
+ test('empty seeds yields no rects', () => {
67
+ expect(synthDefaultGrid([])).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe('resolveArtboards', () => {
72
+ const source = `
73
+ <DCArtboard id="a" label="A" width={1200} height={800}>x</DCArtboard>
74
+ <DCArtboard id="b" label="B" width={1200} height={800}>y</DCArtboard>
75
+ `;
76
+
77
+ test('no meta layout — falls back to the default grid entirely', () => {
78
+ expect(resolveArtboards(source, [])).toEqual([
79
+ { id: 'a', x: 0, y: 0, w: 1200, h: 800 },
80
+ { id: 'b', x: 1280, w: 1200, h: 800, y: 0 },
81
+ ]);
82
+ });
83
+
84
+ test('meta layout overrides x/y but JSX width/height stays authoritative (DDR-027)', () => {
85
+ const layout = [{ id: 'a', x: 5000, y: 3000 }];
86
+ const out = resolveArtboards(source, layout);
87
+ expect(out[0]).toEqual({ id: 'a', x: 5000, y: 3000, w: 1200, h: 800 });
88
+ // 'b' has no matching layout entry — stays on the default grid.
89
+ expect(out[1]).toEqual({ id: 'b', x: 1280, y: 0, w: 1200, h: 800 });
90
+ });
91
+
92
+ test('a legacy meta w/h wins over the JSX default (back-compat tolerance)', () => {
93
+ const layout = [{ id: 'a', x: 10, y: 20, w: 999, h: 555 }];
94
+ expect(resolveArtboards(source, layout)[0]).toEqual({ id: 'a', x: 10, y: 20, w: 999, h: 555 });
95
+ });
96
+
97
+ test('non-finite / zero meta values fall back to the default', () => {
98
+ const layout = [{ id: 'a', x: Number.NaN, y: 20, w: 0, h: -5 }];
99
+ expect(resolveArtboards(source, layout)[0]).toEqual({ id: 'a', x: 0, y: 20, w: 1200, h: 800 });
100
+ });
101
+ });
102
+
103
+ describe('canvas-rects.sh static fallback (fixture, no browser)', () => {
104
+ test('emits a manifest matching the canvas fixture, elements always empty', () => {
105
+ const root = mkdtempSync(join(tmpdir(), 'maude-canvas-rects-fixture-'));
106
+ try {
107
+ mkdirSync(join(root, '.design', 'ui'), { recursive: true });
108
+ writeFileSync(
109
+ join(root, '.design', 'ui', 'Fixture.tsx'),
110
+ `export default function Fixture() {
111
+ return (
112
+ <DesignCanvas>
113
+ <DCArtboard id="one" label="One" width={1000} height={600} />
114
+ </DesignCanvas>
115
+ );
116
+ }
117
+ `
118
+ );
119
+ writeFileSync(
120
+ join(root, '.design', 'ui', 'Fixture.meta.json'),
121
+ JSON.stringify({ layout: { artboards: [{ id: 'one', x: 42, y: 7 }] } })
122
+ );
123
+
124
+ const proc = Bun.spawnSync([
125
+ 'node',
126
+ new URL('../bin/_canvas-rects-static.mjs', import.meta.url).pathname,
127
+ '--rel',
128
+ 'ui/Fixture.tsx',
129
+ '--root',
130
+ root,
131
+ ]);
132
+ const manifest = JSON.parse(proc.stdout.toString().trim());
133
+ expect(manifest).toEqual({
134
+ artboards: [{ id: 'one', x: 42, y: 7, w: 1000, h: 600 }],
135
+ elements: [],
136
+ elementsTruncated: false,
137
+ });
138
+ expect(proc.stderr.toString()).toMatch(/no live dev server/);
139
+ } finally {
140
+ rmSync(root, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test('a missing canvas file emits an empty manifest, not an error', () => {
145
+ const root = mkdtempSync(join(tmpdir(), 'maude-canvas-rects-fixture-'));
146
+ try {
147
+ mkdirSync(join(root, '.design'), { recursive: true });
148
+ const proc = Bun.spawnSync([
149
+ 'node',
150
+ new URL('../bin/_canvas-rects-static.mjs', import.meta.url).pathname,
151
+ '--rel',
152
+ 'ui/Nope.tsx',
153
+ '--root',
154
+ root,
155
+ ]);
156
+ expect(JSON.parse(proc.stdout.toString().trim())).toEqual({
157
+ artboards: [],
158
+ elements: [],
159
+ elementsTruncated: false,
160
+ });
161
+ } finally {
162
+ rmSync(root, { recursive: true, force: true });
163
+ }
164
+ });
165
+
166
+ // Security regression (feature-whiteboard-ai-toolkit review, F-path-traversal):
167
+ // --rel used to be joined straight into designRoot with no containment
168
+ // check — a `../` value could read anything readable by the process,
169
+ // unlike the sibling read-annotations.mjs/annotate.mjs lookups (which
170
+ // route the same rel-path shape through fileSlug() flattening). --rel here
171
+ // must stay a real nested path (it locates the actual .tsx on disk), so
172
+ // the fix is a resolve()+startsWith() containment assert instead.
173
+ test('--rel that resolves outside designRoot is rejected, not read', () => {
174
+ const root = mkdtempSync(join(tmpdir(), 'maude-canvas-rects-traversal-'));
175
+ try {
176
+ mkdirSync(join(root, '.design', 'ui'), { recursive: true });
177
+ // A secret OUTSIDE .design that a traversal would try to reach.
178
+ writeFileSync(join(root, 'secret.tsx'), 'export default function Secret(){return null}');
179
+
180
+ const proc = Bun.spawnSync([
181
+ 'node',
182
+ new URL('../bin/_canvas-rects-static.mjs', import.meta.url).pathname,
183
+ '--rel',
184
+ '../secret.tsx',
185
+ '--root',
186
+ root,
187
+ ]);
188
+ expect(JSON.parse(proc.stdout.toString().trim())).toEqual({
189
+ artboards: [],
190
+ elements: [],
191
+ elementsTruncated: false,
192
+ });
193
+ expect(proc.stderr.toString()).toMatch(/escapes designRoot/);
194
+ } finally {
195
+ rmSync(root, { recursive: true, force: true });
196
+ }
197
+ });
198
+ });
@@ -0,0 +1,117 @@
1
+ // comments-overlay — pure helpers covering the two comment-anchoring bugs in
2
+ // .ai/logs/rca/issue-comment-anchor-drift-and-popup-edge-clamp.md:
3
+ //
4
+ // 1. `resolveCommentTarget` — a stale `data-cd-id` selector (post-rewrite
5
+ // renumbering, DDR-019) must not be trusted when it resolves to an
6
+ // element of the wrong tag; it must fall back to the structural matcher,
7
+ // and return null (not a wrong element) when the target is truly gone.
8
+ // 2. `placeNearPoint` — the composer/thread popup must flip to whichever
9
+ // side of the anchor actually fits the viewport, instead of always
10
+ // growing down-right and clipping off-screen near a canvas edge.
11
+ //
12
+ // Needs a live DOM for (1) — register happy-dom for THIS file only, like
13
+ // annotations-roundtrip.test.ts.
14
+
15
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
16
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
17
+
18
+ beforeAll(() => {
19
+ GlobalRegistrator.register();
20
+ });
21
+ afterAll(() => {
22
+ GlobalRegistrator.unregister();
23
+ });
24
+
25
+ import { placeNearPoint, resolveCommentTarget } from '../comments-overlay.tsx';
26
+
27
+ describe('resolveCommentTarget', () => {
28
+ test('trusts a direct data-cd-id hit when the tag matches', () => {
29
+ document.body.innerHTML = `
30
+ <div data-dc-screen="art1">
31
+ <button class="cta" data-cd-id="aaa11111">Buy now</button>
32
+ </div>
33
+ `;
34
+ const el = resolveCommentTarget({
35
+ selector: '[data-dc-screen="art1"] [data-cd-id="aaa11111"]',
36
+ tag: 'button',
37
+ classes: 'cta',
38
+ dom_path: ['html', 'body', 'div[data-dc-screen="art1"]', 'button.cta'],
39
+ });
40
+ expect(el?.getAttribute('data-cd-id')).toBe('aaa11111');
41
+ });
42
+
43
+ test('falls back to the structural match when the id now belongs to a different tag', () => {
44
+ // Renumbering: "aaa11111" now sits on an unrelated <div>; the button the
45
+ // comment was anchored to kept its structure but got a fresh id.
46
+ document.body.innerHTML = `
47
+ <div data-dc-screen="art1">
48
+ <div data-cd-id="aaa11111" class="decoy">Wrong element now has the old id</div>
49
+ <section class="hero">
50
+ <button class="cta" data-cd-id="zzz99999">Buy now</button>
51
+ </section>
52
+ </div>
53
+ `;
54
+ const el = resolveCommentTarget({
55
+ selector: '[data-dc-screen="art1"] [data-cd-id="aaa11111"]',
56
+ tag: 'button',
57
+ classes: 'cta',
58
+ dom_path: ['html', 'body', 'div[data-dc-screen="art1"]', 'section.hero', 'button.cta'],
59
+ });
60
+ expect(el?.getAttribute('data-cd-id')).toBe('zzz99999');
61
+ });
62
+
63
+ test('returns null (not a wrong element) when the target has genuinely been removed', () => {
64
+ document.body.innerHTML = `
65
+ <div data-dc-screen="art1">
66
+ <p>The button that used to be here is gone.</p>
67
+ </div>
68
+ `;
69
+ const el = resolveCommentTarget({
70
+ selector: '[data-dc-screen="art1"] [data-cd-id="aaa11111"]',
71
+ tag: 'button',
72
+ classes: 'cta',
73
+ dom_path: ['html', 'body', 'div[data-dc-screen="art1"]', 'button.cta'],
74
+ });
75
+ expect(el).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe('placeNearPoint', () => {
80
+ beforeAll(() => {
81
+ window.innerWidth = 1000;
82
+ window.innerHeight = 800;
83
+ });
84
+
85
+ test('keeps the default down-right placement when there is room', () => {
86
+ const placed = placeNearPoint({ x: 100, y: 100 }, { w: 300, h: 200 });
87
+ expect(placed).toEqual({ x: 100, y: 100 });
88
+ });
89
+
90
+ test('flips to the left of the anchor when the card would overflow the right edge', () => {
91
+ const placed = placeNearPoint({ x: 900, y: 100 }, { w: 300, h: 200 });
92
+ // 900 + 300 = 1200 > 1000 → flip: 900 - 300 = 600, which has room (>= margin).
93
+ expect(placed.x).toBe(600);
94
+ });
95
+
96
+ test('flips above the anchor when the card would overflow the bottom edge', () => {
97
+ const placed = placeNearPoint({ x: 100, y: 700 }, { w: 300, h: 200 });
98
+ // 700 + 200 = 900 > 800 → flip: 700 - 200 = 500, which has room.
99
+ expect(placed.y).toBe(500);
100
+ });
101
+
102
+ test('flips both axes when the anchor sits in a corner (near right AND bottom edge)', () => {
103
+ const placed = placeNearPoint({ x: 950, y: 750 }, { w: 300, h: 200 });
104
+ // Flipped x (950-300=650) has room, so it flips rather than clamps.
105
+ expect(placed.x).toBe(650);
106
+ // Flipped y (750-200=550) has room too.
107
+ expect(placed.y).toBe(550);
108
+ });
109
+
110
+ test('clamps inward when even the flipped position would go negative', () => {
111
+ const placed = placeNearPoint({ x: 50, y: 50 }, { w: 2000, h: 2000 });
112
+ // Card wider/taller than the viewport in both directions — neither the
113
+ // default nor the flip fits, so it clamps to the margin-in-from-edge spot.
114
+ expect(placed.x).toBe(8);
115
+ expect(placed.y).toBe(8);
116
+ });
117
+ });