@1agh/maude 0.45.2 → 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 (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. package/plugins/design/templates/_shell.html +4 -0
@@ -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
+ });
@@ -0,0 +1,326 @@
1
+ // feature-2-print-artboards T5 — the load-bearing golden gate: MediaBox ⊇
2
+ // BleedBox ⊇ TrimBox with exact expected pt values, plus marks presence.
3
+ // Exercises pdf-lib directly (no Playwright/Chromium needed — box-setting
4
+ // and mark-drawing are pure pdf-lib API calls); the page size mirrors what
5
+ // Chromium's page.pdf() would actually produce for an artboard authored via
6
+ // resolvePrintArtboard (96dpi CSS px → pt, same ratio as mm → pt).
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { PDFDocument } from 'pdf-lib';
10
+
11
+ import { readArtboardPrintProp } from '../canvas-edit.ts';
12
+ import {
13
+ applyPageFit,
14
+ applyPrintBoxesAndMarks,
15
+ artboardIdFromCssPath,
16
+ type PdfPrintOptions,
17
+ parsePageFit,
18
+ parsePdfPrintOptions,
19
+ resolvePdfDeviceScale,
20
+ } from '../exporters/pdf.ts';
21
+ import { requiredSlugPt } from '../print/marks.ts';
22
+ import {
23
+ getPaperPreset,
24
+ mmToPt,
25
+ pxToPt,
26
+ resolvePrintArtboard,
27
+ trimSizeMm,
28
+ } from '../print/units.ts';
29
+
30
+ const NO_MARKS: PdfPrintOptions = {
31
+ marks: { crop: false, registration: false, colorBars: false, pageInfo: false },
32
+ };
33
+
34
+ describe('pdf.ts — artboardIdFromCssPath', () => {
35
+ test('extracts the id from the artboard-by-id selector shape', () => {
36
+ expect(artboardIdFromCssPath('[data-dc-screen="home"]')).toBe('home');
37
+ });
38
+
39
+ test('a descendant / :first-of-type selector yields null', () => {
40
+ expect(artboardIdFromCssPath('.some .descendant')).toBeNull();
41
+ expect(artboardIdFromCssPath('[data-dc-screen]:first-of-type')).toBeNull();
42
+ expect(artboardIdFromCssPath('[data-dc-screen]')).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe('pdf.ts — parsePdfPrintOptions', () => {
47
+ test('absent/malformed → null (print post-pass off)', () => {
48
+ expect(parsePdfPrintOptions(undefined)).toBeNull();
49
+ expect(parsePdfPrintOptions(null)).toBeNull();
50
+ expect(parsePdfPrintOptions('nope')).toBeNull();
51
+ expect(parsePdfPrintOptions([1, 2])).toBeNull();
52
+ });
53
+
54
+ test('valid shape passes through with marks defaulting false', () => {
55
+ expect(parsePdfPrintOptions({})).toEqual({
56
+ includeBleed: undefined,
57
+ marks: { crop: false, registration: false, colorBars: false, pageInfo: false },
58
+ });
59
+ });
60
+
61
+ test('marks flags coerce to strict booleans (non-true → false)', () => {
62
+ expect(parsePdfPrintOptions({ marks: { crop: 'yes', registration: 1 } })).toEqual({
63
+ includeBleed: undefined,
64
+ marks: { crop: false, registration: false, colorBars: false, pageInfo: false },
65
+ });
66
+ expect(parsePdfPrintOptions({ marks: { crop: true, registration: true } })).toEqual({
67
+ includeBleed: undefined,
68
+ marks: { crop: true, registration: true, colorBars: false, pageInfo: false },
69
+ });
70
+ });
71
+
72
+ test('includeBleed passes through only when a strict boolean', () => {
73
+ expect(parsePdfPrintOptions({ includeBleed: false })?.includeBleed).toBe(false);
74
+ expect(parsePdfPrintOptions({ includeBleed: 'false' })?.includeBleed).toBeUndefined();
75
+ });
76
+ });
77
+
78
+ describe('pdf.ts — parsePageFit', () => {
79
+ test('a known paper id passes through', () => {
80
+ expect(parsePageFit('a4')).toBe('a4');
81
+ expect(parsePageFit('letter')).toBe('letter');
82
+ });
83
+
84
+ test('unknown id / non-string → null', () => {
85
+ expect(parsePageFit('not-a-paper')).toBeNull();
86
+ expect(parsePageFit(42)).toBeNull();
87
+ expect(parsePageFit(undefined)).toBeNull();
88
+ });
89
+ });
90
+
91
+ // Dogfood follow-up (2026-07-16) — a "vector PDF" still embeds raster
92
+ // content (dropped photos, large-format art) as a bitmap whose density is
93
+ // set by the capturing context's deviceScaleFactor; `dpi` controls that.
94
+ describe('pdf.ts — resolvePdfDeviceScale (raster-content density, dogfood follow-up)', () => {
95
+ test("no dpi → 1× (today's unchanged default — NOT png.ts's 2× default)", () => {
96
+ expect(resolvePdfDeviceScale({})).toBe(1);
97
+ });
98
+
99
+ test('dpi=300 → 3.125× (300/96)', () => {
100
+ expect(resolvePdfDeviceScale({ dpi: 300 })).toBeCloseTo(300 / 96, 10);
101
+ });
102
+
103
+ test('dpi=600 → 6.25×', () => {
104
+ expect(resolvePdfDeviceScale({ dpi: 600 })).toBeCloseTo(600 / 96, 10);
105
+ });
106
+
107
+ test("a bare `scale` option (PNG's legacy field) is ignored — PDF has no scale concept", () => {
108
+ expect(resolvePdfDeviceScale({ scale: 3 })).toBe(1);
109
+ });
110
+ });
111
+
112
+ describe('pdf-print-boxes — GOLDEN: A4 + 3mm bleed, no marks', () => {
113
+ test('MediaBox === BleedBox (full page); TrimBox insets by bleedPt exactly', async () => {
114
+ const resolved = resolvePrintArtboard({ paper: 'a4', bleedMm: 3 });
115
+ const pageWidthPt = pxToPt(resolved.widthPx);
116
+ const pageHeightPt = pxToPt(resolved.heightPx);
117
+
118
+ const doc = await PDFDocument.create();
119
+ const page = doc.addPage([pageWidthPt, pageHeightPt]);
120
+ applyPrintBoxesAndMarks(page, 3, NO_MARKS);
121
+
122
+ const bleedPt = mmToPt(3);
123
+ const media = page.getMediaBox();
124
+ const bleed = page.getBleedBox();
125
+ const trim = page.getTrimBox();
126
+
127
+ // No marks requested → no slug → MediaBox is exactly the bleed box.
128
+ expect(media.x).toBeCloseTo(0, 6);
129
+ expect(media.y).toBeCloseTo(0, 6);
130
+ expect(media.width).toBeCloseTo(pageWidthPt, 6);
131
+ expect(media.height).toBeCloseTo(pageHeightPt, 6);
132
+
133
+ expect(bleed.x).toBeCloseTo(0, 6);
134
+ expect(bleed.y).toBeCloseTo(0, 6);
135
+ expect(bleed.width).toBeCloseTo(pageWidthPt, 6);
136
+ expect(bleed.height).toBeCloseTo(pageHeightPt, 6);
137
+
138
+ expect(trim.x).toBeCloseTo(bleedPt, 6);
139
+ expect(trim.y).toBeCloseTo(bleedPt, 6);
140
+ expect(trim.width).toBeCloseTo(pageWidthPt - 2 * bleedPt, 6);
141
+ expect(trim.height).toBeCloseTo(pageHeightPt - 2 * bleedPt, 6);
142
+
143
+ // The load-bearing nesting invariant, independent of the exact numbers above.
144
+ expect(media.x).toBeLessThanOrEqual(bleed.x);
145
+ expect(media.y).toBeLessThanOrEqual(bleed.y);
146
+ expect(media.x + media.width).toBeGreaterThanOrEqual(bleed.x + bleed.width);
147
+ expect(bleed.x).toBeLessThanOrEqual(trim.x);
148
+ expect(bleed.y).toBeLessThanOrEqual(trim.y);
149
+ expect(bleed.x + bleed.width).toBeGreaterThanOrEqual(trim.x + trim.width);
150
+ });
151
+ });
152
+
153
+ describe('pdf-print-boxes — GOLDEN: A4 + 3mm bleed + crop + registration marks', () => {
154
+ test('MediaBox enlarges by the exact required slug, negative origin, content-box unmoved', async () => {
155
+ const resolved = resolvePrintArtboard({ paper: 'a4', bleedMm: 3 });
156
+ const pageWidthPt = pxToPt(resolved.widthPx);
157
+ const pageHeightPt = pxToPt(resolved.heightPx);
158
+
159
+ const doc = await PDFDocument.create();
160
+ const page = doc.addPage([pageWidthPt, pageHeightPt]);
161
+ const printOpts: PdfPrintOptions = {
162
+ marks: { crop: true, registration: true, colorBars: false, pageInfo: false },
163
+ };
164
+ applyPrintBoxesAndMarks(page, 3, printOpts);
165
+
166
+ const bleedPt = mmToPt(3);
167
+ const slugPt = requiredSlugPt({ crop: true, registration: true });
168
+ const media = page.getMediaBox();
169
+ const bleed = page.getBleedBox();
170
+ const trim = page.getTrimBox();
171
+
172
+ // Exact expected pt values — the golden assertion.
173
+ expect(media.x).toBeCloseTo(-slugPt, 6);
174
+ expect(media.y).toBeCloseTo(-slugPt, 6);
175
+ expect(media.width).toBeCloseTo(pageWidthPt + 2 * slugPt, 6);
176
+ expect(media.height).toBeCloseTo(pageHeightPt + 2 * slugPt, 6);
177
+ // MediaBox's negative origin is the marks slug ONLY — bleed box content
178
+ // coordinates are untouched (still start at the page's own (0,0)).
179
+ expect(bleed.x).toBeCloseTo(0, 6);
180
+ expect(bleed.y).toBeCloseTo(0, 6);
181
+ expect(bleed.width).toBeCloseTo(pageWidthPt, 6);
182
+ expect(bleed.height).toBeCloseTo(pageHeightPt, 6);
183
+ expect(trim.x).toBeCloseTo(bleedPt, 6);
184
+ expect(trim.y).toBeCloseTo(bleedPt, 6);
185
+ expect(trim.width).toBeCloseTo(pageWidthPt - 2 * bleedPt, 6);
186
+ expect(trim.height).toBeCloseTo(pageHeightPt - 2 * bleedPt, 6);
187
+
188
+ // MediaBox ⊇ BleedBox ⊇ TrimBox, strictly (marks slug > 0 here).
189
+ expect(media.x).toBeLessThan(bleed.x);
190
+ expect(media.width + media.x).toBeGreaterThan(bleed.width + bleed.x);
191
+ expect(bleed.x).toBeLessThan(trim.x);
192
+ expect(bleed.width + bleed.x).toBeGreaterThan(trim.width + trim.x);
193
+ });
194
+
195
+ test('marks present as vector ops — saved PDF is measurably larger with marks than without', async () => {
196
+ const resolved = resolvePrintArtboard({ paper: 'a4', bleedMm: 3 });
197
+ const pageWidthPt = pxToPt(resolved.widthPx);
198
+ const pageHeightPt = pxToPt(resolved.heightPx);
199
+
200
+ const withoutMarks = await PDFDocument.create();
201
+ applyPrintBoxesAndMarks(withoutMarks.addPage([pageWidthPt, pageHeightPt]), 3, NO_MARKS);
202
+ const withoutBytes = await withoutMarks.save();
203
+
204
+ const withMarks = await PDFDocument.create();
205
+ applyPrintBoxesAndMarks(withMarks.addPage([pageWidthPt, pageHeightPt]), 3, {
206
+ marks: { crop: true, registration: true, colorBars: false, pageInfo: false },
207
+ });
208
+ const withBytes = await withMarks.save();
209
+
210
+ // 8 crop-mark line segments + 4 registration circles (each an approximated
211
+ // bezier ellipse) + 8 crosshair segments is a non-trivial amount of vector
212
+ // content — the byte-size delta is a reliable proxy without depending on
213
+ // pdf-lib's internal (and version-specific) content-stream compression
214
+ // format for a literal operator-string grep.
215
+ expect(withBytes.byteLength).toBeGreaterThan(withoutBytes.byteLength);
216
+ });
217
+ });
218
+
219
+ describe('pdf-print-boxes — includeBleed:false crops the bleed strip out', () => {
220
+ test('MediaBox/BleedBox collapse onto TrimBox (degenerate but valid nesting)', async () => {
221
+ const resolved = resolvePrintArtboard({ paper: 'a4', bleedMm: 3 });
222
+ const pageWidthPt = pxToPt(resolved.widthPx);
223
+ const pageHeightPt = pxToPt(resolved.heightPx);
224
+ const bleedPt = mmToPt(3);
225
+
226
+ const doc = await PDFDocument.create();
227
+ const page = doc.addPage([pageWidthPt, pageHeightPt]);
228
+ applyPrintBoxesAndMarks(page, 3, { ...NO_MARKS, includeBleed: false });
229
+
230
+ const media = page.getMediaBox();
231
+ const bleed = page.getBleedBox();
232
+ const trim = page.getTrimBox();
233
+
234
+ expect(media.x).toBeCloseTo(bleedPt, 6);
235
+ expect(media.y).toBeCloseTo(bleedPt, 6);
236
+ expect(media.width).toBeCloseTo(pageWidthPt - 2 * bleedPt, 6);
237
+ expect(bleed.x).toBeCloseTo(trim.x, 6);
238
+ expect(bleed.width).toBeCloseTo(trim.width, 6);
239
+ });
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
+
295
+ describe('pdf-print-boxes — applyPageFit (non-print artboard scale-to-paper)', () => {
296
+ test('the resulting page is sized to the target paper, content preserved', async () => {
297
+ const doc = await PDFDocument.create();
298
+ // A wide, non-print-sized artboard (e.g. a 1440×1024 digital screen at 96dpi).
299
+ const origWidthPt = pxToPt(1440);
300
+ const origHeightPt = pxToPt(1024);
301
+ doc.addPage([origWidthPt, origHeightPt]);
302
+ expect(doc.getPageCount()).toBe(1);
303
+
304
+ await applyPageFit(doc, 0, 'a4');
305
+
306
+ expect(doc.getPageCount()).toBe(1); // swapped in place, not appended
307
+ const page = doc.getPage(0);
308
+ // Expected size mirrors applyPageFit's OWN derivation (mmToPt of the
309
+ // preset's raw mm dims via trimSizeMm) — NOT a px-rounded roundtrip via
310
+ // resolvePrintArtboard, which would introduce a spurious sub-pt mismatch.
311
+ const preset = getPaperPreset('a4');
312
+ if (!preset) throw new Error('a4 preset missing');
313
+ const { widthMm, heightMm } = trimSizeMm(preset, 'portrait');
314
+ const { width, height } = page.getSize();
315
+ expect(width).toBeCloseTo(mmToPt(widthMm), 6);
316
+ expect(height).toBeCloseTo(mmToPt(heightMm), 6);
317
+ });
318
+
319
+ test('unknown paper id is a no-op (never throws, never mutates)', async () => {
320
+ const doc = await PDFDocument.create();
321
+ doc.addPage([100, 100]);
322
+ await applyPageFit(doc, 0, 'not-a-paper');
323
+ expect(doc.getPageCount()).toBe(1);
324
+ expect(doc.getPage(0).getSize()).toEqual({ width: 100, height: 100 });
325
+ });
326
+ });
@@ -0,0 +1,113 @@
1
+ // feature-2-print-artboards T5 — print/marks.ts pure geometry unit tests.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { computeMarksGeometry, MARK_STROKE_PT, requiredSlugPt } from '../print/marks.ts';
6
+ import { mmToPt } from '../print/units.ts';
7
+
8
+ const PAGE_W = 600; // pt, arbitrary
9
+ const PAGE_H = 800;
10
+ const BLEED = mmToPt(3);
11
+
12
+ describe('print/marks — requiredSlugPt', () => {
13
+ test('neither crop nor registration → 0', () => {
14
+ expect(requiredSlugPt({})).toBe(0);
15
+ });
16
+
17
+ test('crop only → the mark length', () => {
18
+ expect(requiredSlugPt({ crop: true })).toBeCloseTo(mmToPt(3.5), 6);
19
+ });
20
+
21
+ test('registration reaches further than crop alone', () => {
22
+ const cropOnly = requiredSlugPt({ crop: true });
23
+ const both = requiredSlugPt({ crop: true, registration: true });
24
+ expect(both).toBeGreaterThan(cropOnly);
25
+ });
26
+ });
27
+
28
+ describe('print/marks — computeMarksGeometry', () => {
29
+ test('crop:false, registration:false → both arrays empty', () => {
30
+ const g = computeMarksGeometry({ pageWidthPt: PAGE_W, pageHeightPt: PAGE_H, bleedPt: BLEED });
31
+ expect(g.cropMarks).toEqual([]);
32
+ expect(g.registrationMarks).toEqual([]);
33
+ });
34
+
35
+ test('crop:true → exactly 8 segments (2 per corner × 4 corners)', () => {
36
+ const g = computeMarksGeometry({
37
+ pageWidthPt: PAGE_W,
38
+ pageHeightPt: PAGE_H,
39
+ bleedPt: BLEED,
40
+ crop: true,
41
+ });
42
+ expect(g.cropMarks.length).toBe(8);
43
+ });
44
+
45
+ test('every crop-mark segment lies OUTSIDE the bleed box (page) bounds', () => {
46
+ const g = computeMarksGeometry({
47
+ pageWidthPt: PAGE_W,
48
+ pageHeightPt: PAGE_H,
49
+ bleedPt: BLEED,
50
+ crop: true,
51
+ });
52
+ for (const seg of g.cropMarks) {
53
+ const outside = (x: number, y: number) => x < 0 || x > PAGE_W || y < 0 || y > PAGE_H;
54
+ // At least one endpoint of every mark segment must be outside the page —
55
+ // the whole point of a crop mark is to be visible in the bleed/slug area.
56
+ expect(outside(seg.x1, seg.y1) || outside(seg.x2, seg.y2)).toBe(true);
57
+ }
58
+ });
59
+
60
+ test('crop-mark segments align to the trim line (bleed inset)', () => {
61
+ const g = computeMarksGeometry({
62
+ pageWidthPt: PAGE_W,
63
+ pageHeightPt: PAGE_H,
64
+ bleedPt: BLEED,
65
+ crop: true,
66
+ });
67
+ const trimX0 = BLEED;
68
+ const trimX1 = PAGE_W - BLEED;
69
+ const trimY0 = BLEED;
70
+ const trimY1 = PAGE_H - BLEED;
71
+ const xs = g.cropMarks.filter((s) => s.x1 === s.x2).map((s) => s.x1);
72
+ const ys = g.cropMarks.filter((s) => s.y1 === s.y2).map((s) => s.y1);
73
+ expect(new Set(xs)).toEqual(new Set([trimX0, trimX1]));
74
+ expect(new Set(ys)).toEqual(new Set([trimY0, trimY1]));
75
+ });
76
+
77
+ test('registration:true → 4 marks, each a circle + 2-segment crosshair', () => {
78
+ const g = computeMarksGeometry({
79
+ pageWidthPt: PAGE_W,
80
+ pageHeightPt: PAGE_H,
81
+ bleedPt: BLEED,
82
+ registration: true,
83
+ });
84
+ expect(g.registrationMarks.length).toBe(4);
85
+ for (const m of g.registrationMarks) {
86
+ expect(m.crosshair.length).toBe(2);
87
+ expect(m.circle.r).toBeGreaterThan(0);
88
+ }
89
+ });
90
+
91
+ test('registration marks sit beyond the crop marks (no visual collision)', () => {
92
+ const g = computeMarksGeometry({
93
+ pageWidthPt: PAGE_W,
94
+ pageHeightPt: PAGE_H,
95
+ bleedPt: BLEED,
96
+ crop: true,
97
+ registration: true,
98
+ });
99
+ const topCenterReg = g.registrationMarks.find((m) => m.circle.cy > PAGE_H);
100
+ expect(topCenterReg).toBeDefined();
101
+ const topCropTip = Math.max(
102
+ ...g.cropMarks.filter((s) => s.y1 > PAGE_H || s.y2 > PAGE_H).map((s) => Math.max(s.y1, s.y2))
103
+ );
104
+ expect((topCenterReg?.circle.cy ?? 0) - (topCenterReg?.circle.r ?? 0)).toBeGreaterThan(
105
+ topCropTip
106
+ );
107
+ });
108
+ });
109
+
110
+ test('MARK_STROKE_PT is a small positive value (0.25mm)', () => {
111
+ expect(MARK_STROKE_PT).toBeCloseTo(mmToPt(0.25), 6);
112
+ expect(MARK_STROKE_PT).toBeGreaterThan(0);
113
+ });