@1agh/maude 0.46.0 → 0.47.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 (56) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +113 -5
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/api.ts +417 -2
  5. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  6. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  7. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  8. package/apps/studio/bin/_curl-local.mjs +349 -0
  9. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  10. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  11. package/apps/studio/bin/curl-local.sh +28 -0
  12. package/apps/studio/canvas-cursors.ts +6 -0
  13. package/apps/studio/canvas-edit.ts +678 -11
  14. package/apps/studio/canvas-icons.tsx +13 -0
  15. package/apps/studio/canvas-lib.tsx +3 -0
  16. package/apps/studio/canvas-shell.tsx +646 -26
  17. package/apps/studio/client/app.jsx +898 -52
  18. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  19. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  20. package/apps/studio/contextual-toolbar.tsx +5 -3
  21. package/apps/studio/dist/client.bundle.js +1103 -1103
  22. package/apps/studio/dist/comment-mount.js +2 -2
  23. package/apps/studio/dist/styles.css +1 -1
  24. package/apps/studio/grid-track-handles.ts +179 -0
  25. package/apps/studio/handoff.ts +35 -0
  26. package/apps/studio/http.ts +118 -0
  27. package/apps/studio/input-router.tsx +73 -17
  28. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  29. package/apps/studio/test/browse-posture.test.tsx +107 -0
  30. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  31. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  32. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  33. package/apps/studio/test/comment-mount.test.ts +2 -1
  34. package/apps/studio/test/component-map.test.ts +48 -0
  35. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  36. package/apps/studio/test/detach-component.test.ts +94 -0
  37. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  38. package/apps/studio/test/element-structural-api.test.ts +74 -0
  39. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  40. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  41. package/apps/studio/test/handoff.test.ts +48 -0
  42. package/apps/studio/test/input-router.test.ts +82 -8
  43. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  44. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  45. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  46. package/apps/studio/tool-palette.tsx +3 -1
  47. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  48. package/apps/studio/use-element-resize.tsx +3 -1
  49. package/apps/studio/use-grid-track-handles.tsx +364 -0
  50. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  51. package/apps/studio/use-tool-mode.tsx +30 -3
  52. package/apps/studio/web-overlay-content.tsx +52 -0
  53. package/apps/studio/whats-new.json +27 -0
  54. package/cli/commands/design.mjs +16 -0
  55. package/package.json +8 -8
  56. package/plugins/design/templates/_shell.html +4 -0
@@ -0,0 +1,160 @@
1
+ // grid-track-handles.ts — feature-3-web-artboards T5 (absorbed
2
+ // feature-grid-track-editor stub). Pure geometry for the on-canvas grid
3
+ // gutter drag-resize overlay: track list parse/serialize (fr round-trip),
4
+ // gutter placement, and per-unit drag math.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ computeGutterLines,
10
+ computeTrackDrag,
11
+ type GridTrack,
12
+ gutterTrackIndices,
13
+ parseTrackList,
14
+ serializeTrackList,
15
+ } from '../grid-track-handles.ts';
16
+
17
+ describe('grid-track-handles / parseTrackList', () => {
18
+ test('parses numeric tracks with mixed units', () => {
19
+ expect(parseTrackList('200px 1fr 50%')).toEqual([
20
+ { value: 200, unit: 'px' },
21
+ { value: 1, unit: 'fr' },
22
+ { value: 50, unit: '%' },
23
+ ]);
24
+ });
25
+
26
+ test('parses keyword tracks', () => {
27
+ expect(parseTrackList('auto min-content max-content')).toEqual([
28
+ { value: 0, unit: 'auto' },
29
+ { value: 0, unit: 'min-content' },
30
+ { value: 0, unit: 'max-content' },
31
+ ]);
32
+ });
33
+
34
+ test('mixed numeric + keyword tracks', () => {
35
+ expect(parseTrackList('120px auto 2fr')).toEqual([
36
+ { value: 120, unit: 'px' },
37
+ { value: 0, unit: 'auto' },
38
+ { value: 2, unit: 'fr' },
39
+ ]);
40
+ });
41
+
42
+ test('empty/whitespace-only string parses to []', () => {
43
+ expect(parseTrackList('')).toEqual([]);
44
+ expect(parseTrackList(' ')).toEqual([]);
45
+ expect(parseTrackList(null)).toEqual([]);
46
+ expect(parseTrackList(undefined)).toEqual([]);
47
+ });
48
+
49
+ test('unparseable tokens (repeat()/minmax()) bail to []', () => {
50
+ expect(parseTrackList('repeat(3, 1fr)')).toEqual([]);
51
+ expect(parseTrackList('minmax(100px, 1fr) 1fr')).toEqual([]);
52
+ });
53
+
54
+ test('a malformed multi-dot number (security-auditor correctness finding) bails to [] rather than NaN', () => {
55
+ expect(parseTrackList('1.2.3px')).toEqual([]);
56
+ expect(parseTrackList('200px 1.2.3fr')).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe('grid-track-handles / serializeTrackList', () => {
61
+ test('round-trips a mixed track list, including fr', () => {
62
+ const tracks: GridTrack[] = [
63
+ { value: 200, unit: 'px' },
64
+ { value: 1, unit: 'fr' },
65
+ { value: 2.5, unit: 'fr' },
66
+ ];
67
+ expect(serializeTrackList(tracks)).toBe('200px 1fr 2.5fr');
68
+ expect(parseTrackList(serializeTrackList(tracks))).toEqual(tracks);
69
+ });
70
+
71
+ test('keyword tracks serialize without a unit suffix', () => {
72
+ expect(
73
+ serializeTrackList([
74
+ { value: 0, unit: 'auto' },
75
+ { value: 100, unit: 'px' },
76
+ ])
77
+ ).toBe('auto 100px');
78
+ });
79
+ });
80
+
81
+ describe('grid-track-handles / computeGutterLines', () => {
82
+ const RECT = { x: 100, y: 200, w: 300, h: 150 };
83
+
84
+ test('fewer than 2 tracks → no gutters', () => {
85
+ expect(computeGutterLines(RECT, [], 0, 'col')).toEqual([]);
86
+ expect(computeGutterLines(RECT, [300], 0, 'col')).toEqual([]);
87
+ });
88
+
89
+ test('places gutters at cumulative track boundaries, column axis, no gap', () => {
90
+ const lines = computeGutterLines(RECT, [100, 100, 100], 0, 'col');
91
+ expect(lines).toEqual([
92
+ { index: 0, axis: 'x', x: 200, y: 275 }, // rect.x + 100
93
+ { index: 1, axis: 'x', x: 300, y: 275 }, // rect.x + 200
94
+ ]);
95
+ });
96
+
97
+ test('accounts for gap when placing the gutter at the gap midpoint', () => {
98
+ const lines = computeGutterLines(RECT, [100, 100], 20, 'col');
99
+ // First track ends at x=200; gutter sits at the 20px gap's midpoint (210).
100
+ expect(lines[0]).toEqual({ index: 0, axis: 'x', x: 210, y: 275 });
101
+ });
102
+
103
+ test('row axis places gutters vertically', () => {
104
+ const lines = computeGutterLines(RECT, [50, 50], 0, 'row');
105
+ expect(lines).toEqual([{ index: 0, axis: 'y', x: 250, y: 250 }]); // rect.y + 50
106
+ });
107
+ });
108
+
109
+ describe('grid-track-handles / computeTrackDrag', () => {
110
+ test('px is additive (1 world px = 1 unit), like padding drag', () => {
111
+ expect(computeTrackDrag({ value: 100, unit: 'px' }, 100, 20, 0, 1, 'x')).toBe(120);
112
+ expect(computeTrackDrag({ value: 100, unit: 'px' }, 100, -30, 0, 1, 'x')).toBe(70);
113
+ });
114
+
115
+ test('px clamps at 0', () => {
116
+ expect(computeTrackDrag({ value: 10, unit: 'px' }, 10, -100, 0, 1, 'x')).toBe(0);
117
+ });
118
+
119
+ test('em uses the same additive math as px', () => {
120
+ expect(computeTrackDrag({ value: 5, unit: 'em' }, 80, 16, 0, 1, 'x')).toBe(21);
121
+ });
122
+
123
+ test('fr scales proportionally from the resolved-px ratio', () => {
124
+ // Track authored as 1fr currently resolves to 100px; dragging +50 screen
125
+ // px targets 150px → ratio 1.5 → new fr = 1.5.
126
+ expect(computeTrackDrag({ value: 1, unit: 'fr' }, 100, 50, 0, 1, 'x')).toBe(1.5);
127
+ });
128
+
129
+ test('fr floor prevents collapsing to zero share', () => {
130
+ expect(computeTrackDrag({ value: 1, unit: 'fr' }, 100, -1000, 0, 1, 'x')).toBe(0.1);
131
+ });
132
+
133
+ test('percent scales proportionally with a 1% floor', () => {
134
+ expect(computeTrackDrag({ value: 50, unit: '%' }, 150, 30, 0, 1, 'x')).toBe(60);
135
+ expect(computeTrackDrag({ value: 50, unit: '%' }, 150, -1000, 0, 1, 'x')).toBe(1);
136
+ });
137
+
138
+ test('keyword units are returned unchanged (not draggable)', () => {
139
+ expect(computeTrackDrag({ value: 0, unit: 'auto' }, 100, 50, 0, 1, 'x')).toBe(0);
140
+ });
141
+
142
+ test('divides screen delta by zoom (world units)', () => {
143
+ expect(computeTrackDrag({ value: 100, unit: 'px' }, 100, 40, 0, 2, 'x')).toBe(120); // 40/2=20
144
+ });
145
+
146
+ test('row axis reads dyScreen instead of dxScreen', () => {
147
+ expect(computeTrackDrag({ value: 100, unit: 'px' }, 100, 999, 20, 1, 'y')).toBe(120);
148
+ });
149
+ });
150
+
151
+ describe('grid-track-handles / gutterTrackIndices', () => {
152
+ test('plain drag touches only the track before the gutter', () => {
153
+ expect(gutterTrackIndices(0, false)).toEqual([0]);
154
+ expect(gutterTrackIndices(2, false)).toEqual([2]);
155
+ });
156
+ test('shift touches both neighboring tracks', () => {
157
+ expect(gutterTrackIndices(0, true)).toEqual([0, 1]);
158
+ expect(gutterTrackIndices(2, true)).toEqual([2, 3]);
159
+ });
160
+ });
@@ -15,6 +15,7 @@ import {
15
15
  filterComponentsCss,
16
16
  filterTokensCss,
17
17
  type RegistryItem,
18
+ resolveCanvasKind,
18
19
  stripDataCdId,
19
20
  writeRegistryItem,
20
21
  } from '../handoff.ts';
@@ -186,6 +187,34 @@ describe('handoff / filterTokensCss', () => {
186
187
  });
187
188
  });
188
189
 
190
+ describe('handoff / resolveCanvasKind', () => {
191
+ test('no explicit kind resolves to digital', () => {
192
+ const src = `<DesignCanvas><DCArtboard id="a" label="A" width={1440} height={900}>x</DCArtboard></DesignCanvas>`;
193
+ expect(resolveCanvasKind(src)).toBe('digital');
194
+ });
195
+
196
+ test('single explicit kind resolves to that kind', () => {
197
+ const src = `<DCArtboard id="a" label="A" width={1280} height={800} kind="web">x</DCArtboard>`;
198
+ expect(resolveCanvasKind(src)).toBe('web');
199
+ });
200
+
201
+ test('multiple distinct kinds resolve to mixed', () => {
202
+ const src = `
203
+ <DCArtboard id="a" label="A" width={1280} height={800} kind="web">x</DCArtboard>
204
+ <DCArtboard id="b" label="B" width={2480} height={3508} kind="print">y</DCArtboard>
205
+ `;
206
+ expect(resolveCanvasKind(src)).toBe('mixed');
207
+ });
208
+
209
+ test('repeated identical kind across artboards is not mixed', () => {
210
+ const src = `
211
+ <DCArtboard id="a" label="A" width={1440} height={900} kind="web">x</DCArtboard>
212
+ <DCArtboard id="b" label="B" width={390} height={844} kind="web">y</DCArtboard>
213
+ `;
214
+ expect(resolveCanvasKind(src)).toBe('web');
215
+ });
216
+ });
217
+
189
218
  describe('handoff / emitRegistryItem end-to-end', () => {
190
219
  let tmpDir = '';
191
220
  beforeAll(() => {
@@ -278,4 +307,23 @@ describe('handoff / emitRegistryItem end-to-end', () => {
278
307
  expect(item.files[0]?.type).toBe('registry:component');
279
308
  expect(item.cssVars).toBeUndefined();
280
309
  });
310
+
311
+ test('emit resolves meta.kind from the canvas source (feature-3-web-artboards T6)', async () => {
312
+ const canvasAbs = path.join(tmpDir, 'WebCanvas.tsx');
313
+ await Bun.write(
314
+ canvasAbs,
315
+ `export default function WebCanvas() {
316
+ return <DCArtboard id="a" label="A" width={1280} height={800} kind="web"><div>x</div></DCArtboard>;
317
+ }`
318
+ );
319
+ const item = await emitRegistryItem({ canvasAbsPath: canvasAbs });
320
+ expect(item.meta?.kind).toBe('web');
321
+ });
322
+
323
+ test('emit defaults meta.kind to digital when no artboard declares one', async () => {
324
+ const canvasAbs = path.join(tmpDir, 'DigitalCanvas.tsx');
325
+ await Bun.write(canvasAbs, 'export default function DigitalCanvas() { return <div>x</div>; }');
326
+ const item = await emitRegistryItem({ canvasAbsPath: canvasAbs });
327
+ expect(item.meta?.kind).toBe('digital');
328
+ });
281
329
  });
@@ -17,10 +17,13 @@ const base = (over: Partial<ClassifyInput>): ClassifyInput => ({
17
17
  ...over,
18
18
  });
19
19
 
20
- describe('input-router / pointermove — move tool', () => {
21
- test('bare hover → no-op (native interactions pass through)', () => {
20
+ describe('input-router / pointermove — move (select) tool', () => {
21
+ // feature-4 (browse/move split, DDR-187) — the Move tool is now a SELECT
22
+ // tool: a bare hover paints a TOP-level preview halo (Figma's hover outline),
23
+ // Cmd-hover previews the deepest element.
24
+ test('bare hover → hover preview with deep=false (top-level outline)', () => {
22
25
  const action = classify(base({ type: 'pointermove', clientX: 10, clientY: 20 }));
23
- expect(action.kind).toBe('no-op');
26
+ expect(action).toMatchObject({ kind: 'hover', deep: false });
24
27
  });
25
28
 
26
29
  test('cmd-hover → hover preview with deep=true', () => {
@@ -46,15 +49,22 @@ describe('input-router / pointermove — move tool', () => {
46
49
  });
47
50
  });
48
51
 
49
- describe('input-router / pointerdown — move tool select', () => {
50
- test('bare left-click → no-op (native interactions pass through)', () => {
52
+ describe('input-router / pointerdown — move (select) tool ladder', () => {
53
+ // feature-4 (browse/move split, DDR-187) — the full Figma select ladder.
54
+ test('bare left-click → select replace, deep=false (TOP-level object)', () => {
51
55
  const action = classify(base({ type: 'pointerdown', button: 0, clientX: 100, clientY: 200 }));
52
- expect(action.kind).toBe('no-op');
56
+ expect(action).toEqual({
57
+ kind: 'select',
58
+ mode: 'replace',
59
+ deep: false,
60
+ clientX: 100,
61
+ clientY: 200,
62
+ });
53
63
  });
54
64
 
55
- test('shift+left-click (no cmd) → no-op (no select without Cmd)', () => {
65
+ test('shift+left-click (no cmd) → select add, deep=false (add TOP-level)', () => {
56
66
  const action = classify(base({ type: 'pointerdown', button: 0, shiftKey: true }));
57
- expect(action.kind).toBe('no-op');
67
+ expect(action).toMatchObject({ kind: 'select', mode: 'add', deep: false });
58
68
  });
59
69
 
60
70
  test('cmd+left-click → select replace, deep=true (nested single)', () => {
@@ -128,6 +138,70 @@ describe('input-router / pointerdown — tool-aware', () => {
128
138
  });
129
139
  });
130
140
 
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+ // feature-4 (browse/move split, DDR-187) — POSTURE INVARIANTS. The browse tool
143
+ // is the boot default and must be a PURE native pass-through: it claims NOTHING,
144
+ // so a freshly-opened mock stays alive (buttons click, links follow). This
145
+ // promotes the old `input-router.tsx` comment-invariant ("bare clicks pass
146
+ // through") into an enforced gate. If any of these flip to a claim, an existing
147
+ // canvas silently loses its native interactivity on boot.
148
+ describe('input-router / browse tool — pure pass-through (boot default)', () => {
149
+ const browse = (over: Partial<ClassifyInput>): ClassifyInput =>
150
+ base({ activeTool: 'browse', ...over });
151
+
152
+ test('bare left-click → no-op (native button/link/input fires)', () => {
153
+ expect(classify(browse({ type: 'pointerdown', button: 0, clientX: 5, clientY: 6 })).kind).toBe(
154
+ 'no-op'
155
+ );
156
+ });
157
+
158
+ test('cmd+left-click → select deep (the escape hatch; consumer flips tool to move)', () => {
159
+ // User steer 2026-07-19 — "I clicked to edit" shouldn't require V first.
160
+ expect(classify(browse({ type: 'pointerdown', button: 0, metaKey: true }))).toMatchObject({
161
+ kind: 'select',
162
+ mode: 'replace',
163
+ deep: true,
164
+ });
165
+ });
166
+
167
+ test('shift+left-click → no-op (Shift alone is NOT the escape hatch)', () => {
168
+ expect(classify(browse({ type: 'pointerdown', button: 0, shiftKey: true })).kind).toBe('no-op');
169
+ });
170
+
171
+ test('bare hover → no-op; Cmd-hover previews deep (escape-hatch affordance)', () => {
172
+ expect(classify(browse({ type: 'pointermove', clientX: 1, clientY: 2 })).kind).toBe('no-op');
173
+ expect(classify(browse({ type: 'pointermove', metaKey: true }))).toMatchObject({
174
+ kind: 'hover',
175
+ deep: true,
176
+ });
177
+ });
178
+
179
+ test('right-click → still context-menu (chrome, not canvas content)', () => {
180
+ expect(classify(browse({ type: 'pointerdown', button: 2, clientX: 3, clientY: 4 })).kind).toBe(
181
+ 'context-menu'
182
+ );
183
+ });
184
+
185
+ test('V keydown → tool move (the router stays live in browse so V switches)', () => {
186
+ expect(classify(browse({ type: 'keydown', key: 'v' }))).toEqual({
187
+ kind: 'tool',
188
+ tool: 'move',
189
+ });
190
+ });
191
+ });
192
+
193
+ // The select action must NEVER be produced from a non-select tool on a bare
194
+ // gesture. Guards the "select leaked into comment/draw/hand/browse" regression.
195
+ describe('input-router / select never leaks into non-select tools', () => {
196
+ const nonSelectBare: Tool[] = ['browse', 'hand', 'comment', 'pen', 'shape', 'sticky', 'eraser'];
197
+ for (const activeTool of nonSelectBare) {
198
+ test(`bare left-click in ${activeTool} → not a select`, () => {
199
+ const action = classify(base({ type: 'pointerdown', button: 0, activeTool }));
200
+ expect(action.kind).not.toBe('select');
201
+ });
202
+ }
203
+ });
204
+
131
205
  describe('input-router / contextmenu event', () => {
132
206
  test('always opens menu + carries cursor coords', () => {
133
207
  const action = classify(base({ type: 'contextmenu', clientX: 77, clientY: 88 }));
@@ -0,0 +1,96 @@
1
+ // layers-synthetic-groups — feature-4 T7. The serializer that feeds the shell's
2
+ // Layers panel must emit SYNTHETIC group rows for unstamped wrappers that hold
3
+ // stamped descendants (so the tree mirrors real nesting), while still omitting
4
+ // purely-decorative wrappers with no addressable content. This is the fix for
5
+ // the "some layers don't show up / the tree is flat" report.
6
+ //
7
+ // Needs a live DOM (children / querySelector / closest) — register happy-dom.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
10
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
11
+
12
+ beforeAll(() => {
13
+ GlobalRegistrator.register();
14
+ });
15
+ afterAll(() => {
16
+ GlobalRegistrator.unregister();
17
+ });
18
+
19
+ import { serializeArtboardTree } from '../canvas-shell.tsx';
20
+
21
+ function root(html: string): Element {
22
+ const el = document.createElement('div');
23
+ el.className = 'dc-artboard-body';
24
+ el.innerHTML = html;
25
+ return el;
26
+ }
27
+
28
+ describe('serializeArtboardTree — synthetic group rows for unstamped wrappers', () => {
29
+ test('unstamped wrapper WITH a stamped descendant → synthetic group parent (nesting preserved)', () => {
30
+ const tree = serializeArtboardTree(
31
+ root('<div class="hero-wrap"><button data-cd-id="aabbccdd">Go</button></div>')
32
+ );
33
+ expect(tree).toHaveLength(1);
34
+ const group = tree[0]!;
35
+ expect(group.synthetic).toBe(true);
36
+ expect(group.type).toBe('group');
37
+ expect(group.id.startsWith('__grp:')).toBe(true);
38
+ expect(group.tag).toBe('div');
39
+ // The stamped button is now a CHILD of the group (not hoisted to the root).
40
+ expect(group.children).toHaveLength(1);
41
+ expect(group.children[0]!.id).toBe('aabbccdd');
42
+ expect(group.children[0]!.synthetic).toBeUndefined();
43
+ });
44
+
45
+ test('unstamped wrapper with NO stamped descendant → omitted (no decorative noise)', () => {
46
+ const tree = serializeArtboardTree(root('<div class="deco"><span>just text</span></div>'));
47
+ expect(tree).toHaveLength(0);
48
+ });
49
+
50
+ test('stamped child directly → a normal (non-synthetic) row, no group wrapper', () => {
51
+ const tree = serializeArtboardTree(root('<section data-cd-id="11112222">Hi</section>'));
52
+ expect(tree).toHaveLength(1);
53
+ expect(tree[0]!.synthetic).toBeUndefined();
54
+ expect(tree[0]!.id).toBe('11112222');
55
+ });
56
+
57
+ test('nested unstamped wrappers → nested synthetic groups (each rung shown)', () => {
58
+ const tree = serializeArtboardTree(
59
+ root('<div class="outer"><div class="inner"><a data-cd-id="deadbeef">L</a></div></div>')
60
+ );
61
+ expect(tree).toHaveLength(1);
62
+ const outer = tree[0]!;
63
+ expect(outer.synthetic).toBe(true);
64
+ expect(outer.children).toHaveLength(1);
65
+ const inner = outer.children[0]!;
66
+ expect(inner.synthetic).toBe(true);
67
+ expect(inner.children[0]!.id).toBe('deadbeef');
68
+ });
69
+
70
+ test('synthetic group ids are unique across same-signature siblings (stable keys)', () => {
71
+ const tree = serializeArtboardTree(
72
+ root(
73
+ '<div class="col"><span data-cd-id="aaaa1111">a</span></div>' +
74
+ '<div class="col"><span data-cd-id="bbbb2222">b</span></div>'
75
+ )
76
+ );
77
+ expect(tree).toHaveLength(2);
78
+ expect(tree[0]!.id).not.toBe(tree[1]!.id);
79
+ expect(tree[0]!.synthetic).toBe(true);
80
+ expect(tree[1]!.synthetic).toBe(true);
81
+ });
82
+
83
+ test('mixed: a stamped element AND a sibling unstamped-wrapper-with-content both surface', () => {
84
+ const tree = serializeArtboardTree(
85
+ root(
86
+ '<h1 data-cd-id="head0001">Title</h1>' +
87
+ '<div class="body"><p data-cd-id="para0001">copy</p></div>'
88
+ )
89
+ );
90
+ expect(tree).toHaveLength(2);
91
+ expect(tree[0]!.id).toBe('head0001');
92
+ expect(tree[0]!.synthetic).toBeUndefined();
93
+ expect(tree[1]!.synthetic).toBe(true);
94
+ expect(tree[1]!.children[0]!.id).toBe('para0001');
95
+ });
96
+ });
@@ -8,6 +8,7 @@
8
8
  import { describe, expect, test } from 'bun:test';
9
9
  import { PDFDocument } from 'pdf-lib';
10
10
 
11
+ import { readArtboardPrintProp } from '../canvas-edit.ts';
11
12
  import {
12
13
  applyPageFit,
13
14
  applyPrintBoxesAndMarks,
@@ -238,6 +239,59 @@ describe('pdf-print-boxes — includeBleed:false crops the bleed strip out', ()
238
239
  });
239
240
  });
240
241
 
242
+ // RCA issue-pdf-print-export-marks-missing — the shape that shipped broken:
243
+ // two `kind="print"` artboards sharing one `print` spec via a top-level const
244
+ // (AlligatorsAcko.tsx's actual authoring pattern), not a repeated inline
245
+ // literal per artboard. The multi-page loop in exporters/pdf.ts `run()`
246
+ // re-resolves readArtboardPrintProp independently per written page (keyed by
247
+ // the artboard's own data-dc-screen id — see safeArtboardFilename), so
248
+ // asserting it resolves per-artboard-id here is the load-bearing regression
249
+ // guard: both pages must get the SAME resolved geometry, not just page 1.
250
+ describe('readArtboardPrintProp — shared const print spec resolves for every page (multi-page correlation)', () => {
251
+ const canvas = [
252
+ 'const A1_PRINT = { paper: "a1", orientation: "portrait", bleedMm: 3 } as const;',
253
+ 'export default function Demo() {',
254
+ ' return (',
255
+ ' <DesignCanvas>',
256
+ ' <DCArtboard id="acko-front" label="A" kind="print" width={816} height={1146} print={A1_PRINT as any}>',
257
+ ' <div>front</div>',
258
+ ' </DCArtboard>',
259
+ ' <DCArtboard id="acko-back" label="B" kind="print" width={816} height={1146} print={A1_PRINT as any}>',
260
+ ' <div>back</div>',
261
+ ' </DCArtboard>',
262
+ ' </DesignCanvas>',
263
+ ' );',
264
+ '}',
265
+ ].join('\n');
266
+
267
+ test('both artboard ids resolve to the same non-null print geometry, driving non-degenerate boxes+marks on each page', async () => {
268
+ const front = readArtboardPrintProp('/abs/AlligatorsAcko.tsx', canvas, 'acko-front');
269
+ const back = readArtboardPrintProp('/abs/AlligatorsAcko.tsx', canvas, 'acko-back');
270
+ expect(front).toEqual({ paper: 'a1', orientation: 'portrait', bleedMm: 3 });
271
+ expect(back).toEqual(front);
272
+
273
+ const printOpts: PdfPrintOptions = {
274
+ marks: { crop: true, registration: true, colorBars: false, pageInfo: false },
275
+ };
276
+ for (const printProp of [front, back]) {
277
+ if (!printProp) throw new Error('expected a resolved print prop');
278
+ const bleedMm = typeof printProp.bleedMm === 'number' ? printProp.bleedMm : 0;
279
+ const resolved = resolvePrintArtboard({ paper: printProp.paper as string, bleedMm });
280
+ const pageWidthPt = pxToPt(resolved.widthPx);
281
+ const pageHeightPt = pxToPt(resolved.heightPx);
282
+ const doc = await PDFDocument.create();
283
+ const page = doc.addPage([pageWidthPt, pageHeightPt]);
284
+ applyPrintBoxesAndMarks(page, bleedMm, printOpts);
285
+ const media = page.getMediaBox();
286
+ const trim = page.getTrimBox();
287
+ // Non-degenerate: TrimBox strictly inside MediaBox on every side — the
288
+ // exact thing that silently failed to happen when printProp was null.
289
+ expect(media.width).toBeGreaterThan(trim.width);
290
+ expect(media.height).toBeGreaterThan(trim.height);
291
+ }
292
+ });
293
+ });
294
+
241
295
  describe('pdf-print-boxes — applyPageFit (non-print artboard scale-to-paper)', () => {
242
296
  test('the resulting page is sized to the target paper, content preserved', async () => {
243
297
  const doc = await PDFDocument.create();
@@ -12,8 +12,10 @@ import {
12
12
  } from '../use-tool-mode.tsx';
13
13
 
14
14
  describe('use-tool-mode / static', () => {
15
- test('DEFAULT_TOOLS exposes V/H/C + draw set B/I/R/N/⇧S/A/T/E (single Shape tool + highlighter + section)', () => {
15
+ test('DEFAULT_TOOLS exposes browse (boot default, no letter) + V/H/C + draw set B/I/R/N/⇧S/A/T/E', () => {
16
16
  expect(DEFAULT_TOOLS.map((t) => t.id)).toEqual([
17
+ // feature-4 (browse/move split) — browse leads, Move (V) is the select tool.
18
+ 'browse',
17
19
  'move',
18
20
  'hand',
19
21
  'comment',
@@ -27,6 +29,7 @@ describe('use-tool-mode / static', () => {
27
29
  'eraser',
28
30
  ]);
29
31
  expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual([
32
+ '', // browse — no letter shortcut (palette / Esc-from-draw only)
30
33
  'V',
31
34
  'H',
32
35
  'C',
@@ -72,6 +75,10 @@ describe('use-tool-mode / static', () => {
72
75
  expect(byId.eraser).toMatch(/, cell$/);
73
76
  expect(byId.pen).toMatch(/, crosshair$/);
74
77
  expect(byId.shape).toMatch(/, crosshair$/);
78
+ // feature-4 — browse is the ONE tool with NO custom glyph: it's a pure
79
+ // native pass-through, so it shows the system `default` cursor over chrome
80
+ // and (special-cased in the provider) native element cursors over the mock.
81
+ expect(byId.browse).toBe('default');
75
82
  });
76
83
  });
77
84
 
@@ -97,7 +104,8 @@ describe('use-tool-mode / SSR render', () => {
97
104
  <Consumer />
98
105
  </ToolProvider>
99
106
  );
100
- expect(html).toContain('data-tool="move"');
107
+ // feature-4 — boot default is browse (the mock is alive until V is pressed).
108
+ expect(html).toContain('data-tool="browse"');
101
109
  });
102
110
 
103
111
  test('ToolProvider honors initial tool', () => {
@@ -256,7 +256,9 @@ function ensurePaletteStyles(): void {
256
256
  document.head.appendChild(s);
257
257
  }
258
258
 
259
- const NAV_TOOLS = ['move', 'hand', 'comment'] as const;
259
+ // feature-4 (browse/move split) — Browse leads the nav group (it's the boot
260
+ // default), then Move (the select tool, V), Hand, Comment.
261
+ const NAV_TOOLS = ['browse', 'move', 'hand', 'comment'] as const;
260
262
  // Phase 24 — the two rect/ellipse buttons collapse into one Shape tool (with a
261
263
  // kind popover); sticky/arrow/text/eraser keep their order. Wave H — section
262
264
  // joins the row (it was ⇧S-only before; users couldn't discover it).