@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
@@ -0,0 +1,173 @@
1
+ // feature-2-print-artboards T1 — print/units.ts unit tests + the single-source
2
+ // lint guard (no `25.4` outside this module — see the plan's T1 gotcha).
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { join, relative } from 'node:path';
7
+
8
+ import {
9
+ CSS_DPI,
10
+ defaultBleedMm,
11
+ getPaperPreset,
12
+ MM_PER_INCH,
13
+ mmToPt,
14
+ mmToPx,
15
+ PAPER_PRESETS,
16
+ pxToMm,
17
+ pxToPt,
18
+ resolvePrintArtboard,
19
+ trimSizeMm,
20
+ } from '../print/units.ts';
21
+
22
+ describe('print/units — conversions', () => {
23
+ test('mmToPx / pxToMm are inverses at 96dpi', () => {
24
+ expect(mmToPx(MM_PER_INCH)).toBeCloseTo(CSS_DPI, 10);
25
+ expect(pxToMm(CSS_DPI)).toBeCloseTo(MM_PER_INCH, 10);
26
+ expect(pxToMm(mmToPx(37))).toBeCloseTo(37, 10);
27
+ });
28
+
29
+ test('mmToPt: 25.4mm === 72pt (1 inch)', () => {
30
+ expect(mmToPt(MM_PER_INCH)).toBeCloseTo(72, 10);
31
+ });
32
+
33
+ test('pxToPt: 96px @96dpi === 72pt (1 inch)', () => {
34
+ expect(pxToPt(96)).toBeCloseTo(72, 10);
35
+ });
36
+ });
37
+
38
+ describe('print/units — PAPER_PRESETS', () => {
39
+ test('every ISO A-series preset id resolves', () => {
40
+ for (const id of ['a6', 'a5', 'a4', 'a3', 'a2', 'a1', 'a0']) {
41
+ expect(getPaperPreset(id)).toBeDefined();
42
+ }
43
+ });
44
+
45
+ test('A4 is 210×297mm portrait', () => {
46
+ const a4 = getPaperPreset('a4');
47
+ expect(a4?.width).toBe(210);
48
+ expect(a4?.height).toBe(297);
49
+ expect(a4?.unit).toBe('mm');
50
+ });
51
+
52
+ test('Letter is a US in-unit preset', () => {
53
+ const letter = getPaperPreset('letter');
54
+ expect(letter?.unit).toBe('in');
55
+ expect(letter?.width).toBe(8.5);
56
+ expect(letter?.height).toBe(11);
57
+ });
58
+
59
+ test('unknown id is undefined', () => {
60
+ expect(getPaperPreset('nope')).toBeUndefined();
61
+ });
62
+
63
+ test('no duplicate ids', () => {
64
+ const ids = PAPER_PRESETS.map((p) => p.id);
65
+ expect(new Set(ids).size).toBe(ids.length);
66
+ });
67
+ });
68
+
69
+ describe('print/units — orientation', () => {
70
+ test('landscape swaps width/height', () => {
71
+ const a4 = getPaperPreset('a4');
72
+ expect(a4).toBeDefined();
73
+ const portrait = trimSizeMm(a4 as NonNullable<typeof a4>, 'portrait');
74
+ const landscape = trimSizeMm(a4 as NonNullable<typeof a4>, 'landscape');
75
+ expect(portrait.widthMm).toBe(210);
76
+ expect(portrait.heightMm).toBe(297);
77
+ expect(landscape.widthMm).toBe(297);
78
+ expect(landscape.heightMm).toBe(210);
79
+ });
80
+ });
81
+
82
+ describe('print/units — default bleed', () => {
83
+ test('mm-unit presets default to 3mm bleed', () => {
84
+ const a4 = getPaperPreset('a4');
85
+ expect(a4).toBeDefined();
86
+ expect(defaultBleedMm(a4 as NonNullable<typeof a4>)).toBe(3);
87
+ });
88
+
89
+ test('in-unit presets default to 0.125in bleed, expressed in mm', () => {
90
+ const letter = getPaperPreset('letter');
91
+ expect(letter).toBeDefined();
92
+ expect(defaultBleedMm(letter as NonNullable<typeof letter>)).toBeCloseTo(
93
+ 0.125 * MM_PER_INCH,
94
+ 10
95
+ );
96
+ });
97
+ });
98
+
99
+ describe('print/units — resolvePrintArtboard', () => {
100
+ test('throws on unknown paper', () => {
101
+ expect(() => resolvePrintArtboard({ paper: 'nope' })).toThrow(/unknown paper preset/);
102
+ });
103
+
104
+ test('A4 portrait, default bleed — artboard px = trim + 2×bleed exactly', () => {
105
+ const r = resolvePrintArtboard({ paper: 'a4' });
106
+ expect(r.bleedMm).toBe(3);
107
+ expect(r.widthPx).toBe(r.trimWidthPx + 2 * r.bleedPx);
108
+ expect(r.heightPx).toBe(r.trimHeightPx + 2 * r.bleedPx);
109
+ // Sanity band around the nominal A4-at-96dpi size (~794×1123px trim).
110
+ expect(r.trimWidthPx).toBeGreaterThan(790);
111
+ expect(r.trimWidthPx).toBeLessThan(798);
112
+ expect(r.trimHeightPx).toBeGreaterThan(1118);
113
+ expect(r.trimHeightPx).toBeLessThan(1128);
114
+ });
115
+
116
+ test('landscape orientation swaps the resolved trim dimensions', () => {
117
+ const portrait = resolvePrintArtboard({ paper: 'a4', orientation: 'portrait' });
118
+ const landscape = resolvePrintArtboard({ paper: 'a4', orientation: 'landscape' });
119
+ expect(landscape.trimWidthPx).toBe(portrait.trimHeightPx);
120
+ expect(landscape.trimHeightPx).toBe(portrait.trimWidthPx);
121
+ });
122
+
123
+ test('explicit bleedMm=0 removes the bleed border entirely', () => {
124
+ const r = resolvePrintArtboard({ paper: 'a4', bleedMm: 0 });
125
+ expect(r.bleedPx).toBe(0);
126
+ expect(r.widthPx).toBe(r.trimWidthPx);
127
+ expect(r.heightPx).toBe(r.trimHeightPx);
128
+ });
129
+
130
+ test('margins default to 5mm on all sides when unset', () => {
131
+ const r = resolvePrintArtboard({ paper: 'a4' });
132
+ expect(r.marginsMm).toEqual({ top: 5, right: 5, bottom: 5, left: 5 });
133
+ });
134
+
135
+ test('partial margin override only replaces the given sides', () => {
136
+ const r = resolvePrintArtboard({ paper: 'a4', marginsMm: { top: 10 } });
137
+ expect(r.marginsMm).toEqual({ top: 10, right: 5, bottom: 5, left: 5 });
138
+ });
139
+ });
140
+
141
+ describe('print/units — single-source lint guard', () => {
142
+ const STUDIO_ROOT = join(import.meta.dir, '..');
143
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '_history']);
144
+
145
+ function walk(dir: string, out: string[]): string[] {
146
+ for (const entry of readdirSync(dir)) {
147
+ if (SKIP_DIRS.has(entry)) continue;
148
+ const full = join(dir, entry);
149
+ const st = statSync(full);
150
+ if (st.isDirectory()) walk(full, out);
151
+ else if (/\.(ts|tsx|mjs|jsx)$/.test(entry)) out.push(full);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ test('the mm-per-inch conversion constant (25.4 in arithmetic) appears ONLY in print/units.ts', () => {
157
+ // Matches `25.4` used as a division/multiplication operand (the shape a
158
+ // hand-rolled mm↔px conversion would take, e.g. `mm * 96 / 25.4`) — NOT
159
+ // any bare occurrence of the substring, which also shows up coincidentally
160
+ // in unrelated numeric data (e.g. SVG path coordinates in canvas-cursors.ts).
161
+ const CONVERSION_SHAPE = /[*/]\s*25\.4\b|\b25\.4\s*[*/]/;
162
+ const files = walk(STUDIO_ROOT, []);
163
+ const offenders: string[] = [];
164
+ for (const file of files) {
165
+ const rel = relative(STUDIO_ROOT, file);
166
+ if (rel === join('print', 'units.ts')) continue;
167
+ if (rel === join('test', 'print-units.test.ts')) continue;
168
+ const text = readFileSync(file, 'utf8');
169
+ if (CONVERSION_SHAPE.test(text)) offenders.push(rel);
170
+ }
171
+ expect(offenders).toEqual([]);
172
+ });
173
+ });
@@ -188,3 +188,84 @@ describe('computeSnap / no candidates', () => {
188
188
  expect(r.guides).toEqual([]);
189
189
  });
190
190
  });
191
+
192
+ // feature-1-artboard-kinds-foundation, T7 — generic-layout-guide-line candidates.
193
+ describe('computeSnap / guide lines (T5 guides fed in as snap candidates)', () => {
194
+ test('left edge snaps to a guide line within tolerance, kind "guide"', () => {
195
+ const r = computeSnap(rect(503, 200), [], {
196
+ ...DEFAULTS,
197
+ guideLines: [{ axis: 'x', pos: 500, from: 0, to: 1000 }],
198
+ });
199
+ expect(r.x).toBe(500);
200
+ expect(r.guides.find((g) => g.axis === 'x')).toMatchObject({ pos: 500, kind: 'guide' });
201
+ });
202
+
203
+ test('right edge and center also test against the same guide line', () => {
204
+ // proposed right edge (137+100=237) is within tolerance of the guide at 240.
205
+ const r = computeSnap(rect(137, 200), [], {
206
+ ...DEFAULTS,
207
+ guideLines: [{ axis: 'x', pos: 240, from: 0, to: 1000 }],
208
+ });
209
+ expect(r.x).toBe(140); // shifted so right edge lands exactly on 240
210
+ expect(r.guides.find((g) => g.axis === 'x')).toMatchObject({ pos: 240, kind: 'guide' });
211
+ });
212
+
213
+ test('a closer sibling wins over a farther guide line on the same axis', () => {
214
+ const r = computeSnap(rect(503, 200), [rect(500, 500)], {
215
+ ...DEFAULTS,
216
+ guideLines: [{ axis: 'x', pos: 507, from: 0, to: 1000 }],
217
+ });
218
+ // sibling delta = -3 (|3|), guide delta = +4 (|4|) — sibling is closer.
219
+ expect(r.guides.find((g) => g.axis === 'x')).toMatchObject({ kind: 'sibling', pos: 500 });
220
+ });
221
+
222
+ test('outside tolerance, a guide line is ignored', () => {
223
+ const r = computeSnap(rect(100, 200), [], {
224
+ ...DEFAULTS,
225
+ guideLines: [{ axis: 'x', pos: 500, from: 0, to: 1000 }],
226
+ });
227
+ expect(r.x).toBe(100);
228
+ });
229
+
230
+ test('y-axis guide lines snap independently of x-axis', () => {
231
+ const r = computeSnap(rect(200, 303), [], {
232
+ ...DEFAULTS,
233
+ guideLines: [{ axis: 'y', pos: 300, from: 0, to: 1000 }],
234
+ });
235
+ expect(r.y).toBe(300);
236
+ expect(r.x).toBe(200);
237
+ });
238
+ });
239
+
240
+ describe('computeSnap / intent presets (T7 Design Decision 4)', () => {
241
+ test('"pixel" intent ignores siblings AND guide lines — grid only', () => {
242
+ const r = computeSnap(rect(503, 201), [rect(500, 500)], {
243
+ ...DEFAULTS,
244
+ intent: 'pixel',
245
+ guideLines: [{ axis: 'x', pos: 502, from: 0, to: 1000 }],
246
+ });
247
+ // Neither the sibling (delta -3) nor the guide (delta -1) fire under
248
+ // "pixel" — only the grid pass, out of tolerance from both 503 and 201.
249
+ expect(r.x).toBe(503);
250
+ expect(r.guides.some((g) => g.kind === 'sibling' || g.kind === 'guide')).toBe(false);
251
+ });
252
+
253
+ test('"pixel" intent still snaps to the grid', () => {
254
+ const r = computeSnap(rect(37, 200), [], { ...DEFAULTS, intent: 'pixel' });
255
+ expect(r.x).toBe(40);
256
+ });
257
+
258
+ test('omitting `intent` defaults to "layout" (back-compat with every pre-T7 call site)', () => {
259
+ const r = computeSnap(rect(503, 200), [rect(500, 500)], DEFAULTS);
260
+ expect(r.x).toBe(500);
261
+ });
262
+
263
+ test('"layout" intent (explicit) still snaps to siblings + guides', () => {
264
+ const r = computeSnap(rect(503, 200), [], {
265
+ ...DEFAULTS,
266
+ intent: 'layout',
267
+ guideLines: [{ axis: 'x', pos: 500, from: 0, to: 1000 }],
268
+ });
269
+ expect(r.x).toBe(500);
270
+ });
271
+ });
@@ -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).
@@ -227,6 +227,38 @@ const TOAST_CSS = `
227
227
  @media (prefers-reduced-motion: reduce) {
228
228
  .dc-media-toast { transition: none; }
229
229
  }
230
+ /* feature-4 (2026-07-19) — sandbox-safe confirm dialog (window.confirm is
231
+ silently blocked in the allow-modals-less canvas iframe). HUD-token styled. */
232
+ .dc-confirm-backdrop {
233
+ position: fixed; inset: 0; z-index: 40;
234
+ background: rgba(0,0,0,0.45);
235
+ display: flex; align-items: center; justify-content: center;
236
+ }
237
+ .dc-confirm {
238
+ min-width: 300px; max-width: 420px;
239
+ background: var(--maude-chrome-bg-1, #1b1e24);
240
+ color: var(--maude-chrome-fg-0, #e7eaf0);
241
+ border: 1px solid var(--maude-chrome-border, #333a45);
242
+ border-radius: 10px;
243
+ box-shadow: 0 14px 44px rgba(0,0,0,0.5);
244
+ padding: 16px;
245
+ font-family: var(--maude-chrome-font-ui, system-ui, sans-serif);
246
+ }
247
+ .dc-confirm-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
248
+ .dc-confirm-body { font-size: 12px; line-height: 1.5; color: var(--maude-chrome-fg-1, #b7bec9); white-space: pre-line; }
249
+ .dc-confirm-row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
250
+ .dc-confirm-btn {
251
+ padding: 5px 12px; border-radius: 7px; font-size: 12px; cursor: pointer;
252
+ background: var(--maude-chrome-bg-2, #262b33);
253
+ color: var(--maude-chrome-fg-0, #e7eaf0);
254
+ border: 1px solid var(--maude-chrome-border, #333a45);
255
+ }
256
+ .dc-confirm-btn--primary {
257
+ background: var(--maude-hud-accent, #0d99ff);
258
+ border-color: var(--maude-hud-accent, #0d99ff);
259
+ color: #fff;
260
+ }
261
+ .dc-confirm-btn:focus-visible { outline: 2px solid var(--maude-hud-accent, #0d99ff); outline-offset: 1px; }
230
262
  `.trim();
231
263
 
232
264
  function ensureMediaStyles(): void {
@@ -238,6 +270,100 @@ function ensureMediaStyles(): void {
238
270
  document.head.appendChild(s);
239
271
  }
240
272
 
273
+ /**
274
+ * feature-4 (2026-07-19) — sandbox-safe in-canvas CONFIRM. The canvas iframe
275
+ * runs with sandbox="allow-scripts allow-same-origin" (no allow-modals), so
276
+ * `window.confirm()` silently returns false — this promise-based overlay is
277
+ * the replacement (Esc / backdrop / Cancel → false, primary / Enter → true).
278
+ */
279
+ export function canvasConfirm(
280
+ message: string,
281
+ opts?: { title?: string; confirmLabel?: string; cancelLabel?: string }
282
+ ): Promise<boolean> {
283
+ if (typeof document === 'undefined') return Promise.resolve(false);
284
+ ensureMediaStyles();
285
+ return new Promise((resolve) => {
286
+ const backdrop = document.createElement('div');
287
+ backdrop.className = 'dc-confirm-backdrop';
288
+ const box = document.createElement('div');
289
+ box.className = 'dc-confirm';
290
+ box.setAttribute('role', 'alertdialog');
291
+ box.setAttribute('aria-modal', 'true');
292
+ const title = document.createElement('div');
293
+ title.className = 'dc-confirm-title';
294
+ title.textContent = opts?.title ?? 'Are you sure?';
295
+ const body = document.createElement('div');
296
+ body.className = 'dc-confirm-body';
297
+ body.textContent = message;
298
+ const row = document.createElement('div');
299
+ row.className = 'dc-confirm-row';
300
+ const cancel = document.createElement('button');
301
+ cancel.type = 'button';
302
+ cancel.className = 'dc-confirm-btn';
303
+ cancel.textContent = opts?.cancelLabel ?? 'Cancel';
304
+ const ok = document.createElement('button');
305
+ ok.type = 'button';
306
+ ok.className = 'dc-confirm-btn dc-confirm-btn--primary';
307
+ ok.textContent = opts?.confirmLabel ?? 'Continue';
308
+ // a11y fix (review fan-out, 2026-07-21) — this is a raw DOM overlay, not a
309
+ // native <dialog>, so it must implement the two APG modal-dialog musts
310
+ // itself: (1) restore focus to whatever had it before the dialog opened
311
+ // (a hostile canvas can't spoof this — it's just where focus already was);
312
+ // (2) trap Tab/Shift+Tab inside the two buttons so it can't escape to
313
+ // background content (this overlay mounts in the canvas iframe's own
314
+ // document, so "background" is live, possibly-interactive artboard markup).
315
+ const previouslyFocused = document.activeElement as HTMLElement | null;
316
+ const done = (v: boolean) => {
317
+ document.removeEventListener('keydown', onKey, true);
318
+ backdrop.remove();
319
+ try {
320
+ previouslyFocused?.focus();
321
+ } catch {
322
+ /* detached / no-op target — nothing to restore focus to */
323
+ }
324
+ resolve(v);
325
+ };
326
+ const onKey = (e: KeyboardEvent) => {
327
+ // SECURITY — ignore script-dispatched events (isTrusted: false). This
328
+ // dialog mounts in the canvas iframe's own document (untrusted, DDR-054);
329
+ // without this, hostile canvas JS could synthesize a KeyboardEvent/Event
330
+ // to auto-confirm itself past the "irreversible convert" prompt.
331
+ if (!e.isTrusted) return;
332
+ if (e.key === 'Escape') {
333
+ e.stopPropagation();
334
+ done(false);
335
+ } else if (e.key === 'Enter') {
336
+ e.stopPropagation();
337
+ done(true);
338
+ } else if (e.key === 'Tab') {
339
+ // 2-element focus trap: Tab/Shift+Tab just toggles between the two
340
+ // buttons, never leaving the dialog.
341
+ e.preventDefault();
342
+ e.stopPropagation();
343
+ (document.activeElement === ok ? cancel : ok).focus();
344
+ }
345
+ };
346
+ cancel.addEventListener('click', (e) => {
347
+ if (!e.isTrusted) return;
348
+ done(false);
349
+ });
350
+ ok.addEventListener('click', (e) => {
351
+ if (!e.isTrusted) return;
352
+ done(true);
353
+ });
354
+ backdrop.addEventListener('click', (e) => {
355
+ if (!e.isTrusted) return;
356
+ if (e.target === backdrop) done(false);
357
+ });
358
+ document.addEventListener('keydown', onKey, true);
359
+ row.append(cancel, ok);
360
+ box.append(title, body, row);
361
+ backdrop.append(box);
362
+ document.body.append(backdrop);
363
+ ok.focus();
364
+ });
365
+ }
366
+
241
367
  /** Show a brief auto-dismissing toast in the canvas (e.g. an upload failure). */
242
368
  export function showCanvasToast(message: string): void {
243
369
  if (typeof document === 'undefined') return;
@@ -26,6 +26,23 @@ export interface ChromeVisibilityState {
26
26
  zoom: boolean;
27
27
  /** Presentation Mode — hides ALL chrome regardless of the two above. */
28
28
  present: boolean;
29
+ /**
30
+ * Generic layout guides (feature-1-artboard-kinds-foundation T4–T6) — UNLIKE
31
+ * minimap/zoom/present, this is per-CANVAS persisted state (view.json
32
+ * `overlays.guides`), not a global shell preference. DesignCanvasInner
33
+ * self-seeds it from the canvas's own meta right after mount (see
34
+ * canvas-lib.tsx); the shell's View-menu toggle then live-updates it through
35
+ * the SAME `dgn:'view-chrome'` bridge as the other three flags.
36
+ */
37
+ guides: boolean;
38
+ /**
39
+ * feature-2-print-artboards T3 — "Show print guides" (bleed/trim/margin
40
+ * overlay for `kind="print"` artboards). Same shape as `guides` above:
41
+ * per-CANVAS persisted state (view.json `overlays.print`), self-seeded by
42
+ * DesignCanvasInner, live-updated through the same `dgn:'view-chrome'`
43
+ * bridge.
44
+ */
45
+ print: boolean;
29
46
  }
30
47
 
31
48
  export interface ChromeVisibilityValue extends ChromeVisibilityState {
@@ -52,6 +69,8 @@ export function ChromeVisibilityProvider({
52
69
  minimap: initial?.minimap ?? false,
53
70
  zoom: initial?.zoom ?? false,
54
71
  present: initial?.present ?? false,
72
+ guides: initial?.guides ?? false,
73
+ print: initial?.print ?? false,
55
74
  });
56
75
  const setChrome = useCallback((patch: Partial<ChromeVisibilityState>) => {
57
76
  setState((prev) => ({ ...prev, ...patch }));
@@ -19,7 +19,9 @@
19
19
  * resizes.
20
20
  *
21
21
  * In-flow elements resize by writing explicit `width`/`height` only
22
- * (NEVER convert-to-absolute — out of scope); an out-of-flow
22
+ * (a resize NEVER implicitly converts to absolute; the explicit
23
+ * context-menu "Convert children to absolute" — feature-4 T8,
24
+ * DDR-188 — is the opt-in flatten path); an out-of-flow
23
25
  * (absolute/fixed) element additionally moves `left`/`top` when the
24
26
  * top/left edge is dragged, so the resize feels origin-correct.
25
27
  */
@@ -240,6 +242,26 @@ export function scaleFromMatrix(transform: string | null | undefined): number {
240
242
  return s > 0 ? s : 1;
241
243
  }
242
244
 
245
+ /**
246
+ * The `.dc-world` camera's effective render zoom for an element. Dogfood fix
247
+ * (print-artboards round): the camera writes DIFFERENT properties per engine
248
+ * (writeTransform's IS_WEBKIT split in canvas-lib) — WebKit gets
249
+ * `transform: scale(z)`, Chromium gets the CSS `zoom` PROPERTY (whose
250
+ * computed `transform` is a bare translate). Reading only the matrix
251
+ * therefore returned 1 in every non-WebKit browser and the handle/spacing
252
+ * overlays drew at world size instead of screen size, visibly scaling with
253
+ * the camera (the desktop app is WKWebView, which is why dogfooding never
254
+ * caught it). Multiplying both reads is exact on each engine: the unused
255
+ * property computes to identity.
256
+ */
257
+ export function worldZoomFor(el: Element): number {
258
+ const world = el.closest('.dc-world') as HTMLElement | null;
259
+ if (!world) return 1;
260
+ const cs = getComputedStyle(world);
261
+ const zoomProp = Number.parseFloat((cs as { zoom?: string }).zoom || '1') || 1;
262
+ return scaleFromMatrix(cs.transform) * zoomProp;
263
+ }
264
+
243
265
  /** Rotate a local offset (dx,dy) by `deg` — screen y is down, positive = CW. */
244
266
  export function rotatePointDeg(dx: number, dy: number, deg: number): [number, number] {
245
267
  const r = (deg * Math.PI) / 180;
@@ -458,8 +480,7 @@ export function ElementResizeOverlay(): ReactNode {
458
480
  const cx = r.left + r.width / 2;
459
481
  const cy = r.top + r.height / 2;
460
482
  const deg = rotationDegFromMatrix(getComputedStyle(el).transform);
461
- const world = el.closest('.dc-world') as HTMLElement | null;
462
- const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
483
+ const zoom = worldZoomFor(el);
463
484
  const hw = (el.offsetWidth * zoom) / 2;
464
485
  const hh = (el.offsetHeight * zoom) / 2;
465
486
  // Rotate zones sit ON each corner (same offset), 20×20, z-index below the