@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.
- package/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +151 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +760 -15
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +972 -6
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +206 -11
- package/apps/studio/canvas-shell.tsx +809 -50
- package/apps/studio/client/app.jsx +1554 -206
- package/apps/studio/client/panels/ChatPanel.jsx +92 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1627 -1627
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +331 -7
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +237 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +363 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +24 -3
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +55 -0
- package/cli/commands/design.mjs +25 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|