@1agh/maude 0.45.1 → 0.46.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/bridge.ts +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- 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/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- 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/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- 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-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -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/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -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/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +35 -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
|
+
});
|
|
@@ -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 }));
|
|
@@ -240,6 +240,26 @@ export function scaleFromMatrix(transform: string | null | undefined): number {
|
|
|
240
240
|
return s > 0 ? s : 1;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The `.dc-world` camera's effective render zoom for an element. Dogfood fix
|
|
245
|
+
* (print-artboards round): the camera writes DIFFERENT properties per engine
|
|
246
|
+
* (writeTransform's IS_WEBKIT split in canvas-lib) — WebKit gets
|
|
247
|
+
* `transform: scale(z)`, Chromium gets the CSS `zoom` PROPERTY (whose
|
|
248
|
+
* computed `transform` is a bare translate). Reading only the matrix
|
|
249
|
+
* therefore returned 1 in every non-WebKit browser and the handle/spacing
|
|
250
|
+
* overlays drew at world size instead of screen size, visibly scaling with
|
|
251
|
+
* the camera (the desktop app is WKWebView, which is why dogfooding never
|
|
252
|
+
* caught it). Multiplying both reads is exact on each engine: the unused
|
|
253
|
+
* property computes to identity.
|
|
254
|
+
*/
|
|
255
|
+
export function worldZoomFor(el: Element): number {
|
|
256
|
+
const world = el.closest('.dc-world') as HTMLElement | null;
|
|
257
|
+
if (!world) return 1;
|
|
258
|
+
const cs = getComputedStyle(world);
|
|
259
|
+
const zoomProp = Number.parseFloat((cs as { zoom?: string }).zoom || '1') || 1;
|
|
260
|
+
return scaleFromMatrix(cs.transform) * zoomProp;
|
|
261
|
+
}
|
|
262
|
+
|
|
243
263
|
/** Rotate a local offset (dx,dy) by `deg` — screen y is down, positive = CW. */
|
|
244
264
|
export function rotatePointDeg(dx: number, dy: number, deg: number): [number, number] {
|
|
245
265
|
const r = (deg * Math.PI) / 180;
|
|
@@ -458,8 +478,7 @@ export function ElementResizeOverlay(): ReactNode {
|
|
|
458
478
|
const cx = r.left + r.width / 2;
|
|
459
479
|
const cy = r.top + r.height / 2;
|
|
460
480
|
const deg = rotationDegFromMatrix(getComputedStyle(el).transform);
|
|
461
|
-
const
|
|
462
|
-
const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
|
|
481
|
+
const zoom = worldZoomFor(el);
|
|
463
482
|
const hw = (el.offsetWidth * zoom) / 2;
|
|
464
483
|
const hh = (el.offsetHeight * zoom) / 2;
|
|
465
484
|
// Rotate zones sit ON each corner (same offset), 20×20, z-index below the
|
|
@@ -44,8 +44,10 @@ export type SnapAxis = 'x' | 'y';
|
|
|
44
44
|
* different visual treatments (grid = lighter gray fallback; sibling = full
|
|
45
45
|
* magenta confidence). `delta` is the signed correction the snap applied;
|
|
46
46
|
* the overlay renders a `Δ{Math.round(delta)}` pill mid-span when |delta| > 0
|
|
47
|
-
* and the guide span > 60 px.
|
|
48
|
-
|
|
47
|
+
* and the guide span > 60 px. `guide` (feature-1-artboard-kinds-foundation
|
|
48
|
+
* T7) is a third source — an artboard's own generic layout guide lines
|
|
49
|
+
* (T5), offered the same confidence treatment as `sibling`. */
|
|
50
|
+
export type SnapKind = 'grid' | 'sibling' | 'guide';
|
|
49
51
|
|
|
50
52
|
export interface SnapGuide {
|
|
51
53
|
/** `"x"` → vertical line (snapping X coord). Line sits at `pos` on X,
|
|
@@ -72,6 +74,29 @@ export interface SnapResult {
|
|
|
72
74
|
guides: SnapGuide[];
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/**
|
|
78
|
+
* T7 — a single generic-layout-guide line (T5) in WORLD coordinates, ready to
|
|
79
|
+
* snap against. The caller (canvas-lib/canvas-shell) resolves an artboard's
|
|
80
|
+
* `guides` prop (columns/rows/grid, artboard-LOCAL coordinates) into these
|
|
81
|
+
* world-space lines — `computeSnap` itself stays artboard-agnostic, same as
|
|
82
|
+
* it already is for sibling rects.
|
|
83
|
+
*/
|
|
84
|
+
export interface GuideLineCandidate {
|
|
85
|
+
axis: SnapAxis;
|
|
86
|
+
pos: number;
|
|
87
|
+
from: number;
|
|
88
|
+
to: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* T7 — the two snap-source presets: `layout` (default) offers sibling edges/
|
|
93
|
+
* centers AND guide lines, falling back to the grid; `pixel` offers ONLY the
|
|
94
|
+
* grid — for pixel-precise placement work where guide/sibling magnetism would
|
|
95
|
+
* get in the way. Affinity's "2 intent presets, not 15 toggles" call (see the
|
|
96
|
+
* plan's Design Decision 4).
|
|
97
|
+
*/
|
|
98
|
+
export type SnapIntent = 'layout' | 'pixel';
|
|
99
|
+
|
|
75
100
|
export interface SnapOptions {
|
|
76
101
|
/** World-units between grid lines. Default 40. */
|
|
77
102
|
gridSize: number;
|
|
@@ -79,6 +104,11 @@ export interface SnapOptions {
|
|
|
79
104
|
tolerance: number;
|
|
80
105
|
/** Skip all snap math + return proposed unchanged (Alt-held bypass). */
|
|
81
106
|
disabled: boolean;
|
|
107
|
+
/** Generic layout guide lines to snap against, in world coordinates. */
|
|
108
|
+
guideLines?: GuideLineCandidate[];
|
|
109
|
+
/** Which candidate sources are active. Default `'layout'` when omitted, so
|
|
110
|
+
* every existing call site (pre-T7) keeps today's sibling+grid behavior. */
|
|
111
|
+
intent?: SnapIntent;
|
|
82
112
|
}
|
|
83
113
|
|
|
84
114
|
interface AxisCandidate {
|
|
@@ -141,6 +171,7 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
|
|
|
141
171
|
return { x: proposed.x, y: proposed.y, guides: [] };
|
|
142
172
|
}
|
|
143
173
|
const { gridSize, tolerance } = opts;
|
|
174
|
+
const intent = opts.intent ?? 'layout';
|
|
144
175
|
const propLeft = proposed.x;
|
|
145
176
|
const propRight = proposed.x + proposed.w;
|
|
146
177
|
const propCenterX = proposed.x + proposed.w / 2;
|
|
@@ -152,7 +183,8 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
|
|
|
152
183
|
const yCands: AxisCandidate[] = [];
|
|
153
184
|
|
|
154
185
|
// Grid candidates — left edge for X, top edge for Y. Perpendicular extent
|
|
155
|
-
// is just the proposed rect's own range (no sibling involved).
|
|
186
|
+
// is just the proposed rect's own range (no sibling involved). Grid is the
|
|
187
|
+
// one source active under BOTH intent presets (T7 Design Decision 4).
|
|
156
188
|
const gridX = nearestGridDelta(propLeft, gridSize, tolerance);
|
|
157
189
|
if (gridX !== null) {
|
|
158
190
|
xCands.push({
|
|
@@ -174,8 +206,44 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
|
|
|
174
206
|
});
|
|
175
207
|
}
|
|
176
208
|
|
|
177
|
-
//
|
|
178
|
-
|
|
209
|
+
// T7 — generic-layout-guide-line candidates ("Layout" intent only). Each
|
|
210
|
+
// world-space line is tested the same three ways a sibling edge/center is
|
|
211
|
+
// (leading edge, trailing edge, center) — Figma's own layout-guide snap
|
|
212
|
+
// behavior — so a guide line snaps whichever part of the proposed rect
|
|
213
|
+
// lands closest, not just its leading edge (unlike the grid pass above).
|
|
214
|
+
if (intent === 'layout') {
|
|
215
|
+
for (const g of opts.guideLines ?? []) {
|
|
216
|
+
if (g.axis === 'x') {
|
|
217
|
+
for (const propCoord of [propLeft, propRight, propCenterX]) {
|
|
218
|
+
const delta = g.pos - propCoord;
|
|
219
|
+
if (Math.abs(delta) > tolerance) continue;
|
|
220
|
+
xCands.push({
|
|
221
|
+
delta,
|
|
222
|
+
pos: g.pos,
|
|
223
|
+
from: Math.min(propTop, g.from),
|
|
224
|
+
to: Math.max(propBottom, g.to),
|
|
225
|
+
kind: 'guide',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
for (const propCoord of [propTop, propBottom, propCenterY]) {
|
|
230
|
+
const delta = g.pos - propCoord;
|
|
231
|
+
if (Math.abs(delta) > tolerance) continue;
|
|
232
|
+
yCands.push({
|
|
233
|
+
delta,
|
|
234
|
+
pos: g.pos,
|
|
235
|
+
from: Math.min(propLeft, g.from),
|
|
236
|
+
to: Math.max(propRight, g.to),
|
|
237
|
+
kind: 'guide',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Sibling candidates ("Layout" intent only — "Pixel" is grid-only per T7
|
|
245
|
+
// Design Decision 4).
|
|
246
|
+
for (const other of intent === 'layout' ? others : []) {
|
|
179
247
|
const oLeft = other.x;
|
|
180
248
|
const oRight = other.x + other.w;
|
|
181
249
|
const oCenterX = other.x + other.w / 2;
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type PaddingSide,
|
|
29
29
|
paddingSideSet,
|
|
30
30
|
} from './spacing-handles.ts';
|
|
31
|
-
import {
|
|
31
|
+
import { worldZoomFor } from './use-element-resize.tsx';
|
|
32
32
|
import { useSelectionSet } from './use-selection-set.tsx';
|
|
33
33
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
34
34
|
|
|
@@ -174,8 +174,10 @@ export function SpacingHandlesOverlay(): ReactNode {
|
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
176
|
const cs = getComputedStyle(el);
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
// Dogfood fix — engine-split-aware zoom read (see worldZoomFor's own
|
|
178
|
+
// doc comment in use-element-resize: the Chromium camera writes the CSS
|
|
179
|
+
// `zoom` property, not a transform matrix).
|
|
180
|
+
const zoom = worldZoomFor(el);
|
|
179
181
|
const padding = {
|
|
180
182
|
top: computedPx(cs, 'padding-top'),
|
|
181
183
|
right: computedPx(cs, 'padding-right'),
|
|
@@ -274,8 +276,10 @@ export function SpacingHandlesOverlay(): ReactNode {
|
|
|
274
276
|
const prop = t.dataset.prop || '';
|
|
275
277
|
const isGap = prop === 'gap';
|
|
276
278
|
const cs = getComputedStyle(el);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
// Dogfood fix — engine-split-aware zoom read (see worldZoomFor's own
|
|
280
|
+
// doc comment in use-element-resize: the Chromium camera writes the CSS
|
|
281
|
+
// `zoom` property, not a transform matrix).
|
|
282
|
+
const zoom = worldZoomFor(el);
|
|
279
283
|
const drag: SpacingDrag = {
|
|
280
284
|
pointerId: e.pointerId,
|
|
281
285
|
kind: isGap ? 'gap' : 'padding',
|
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "scene-aware-video-analyze",
|
|
6
|
+
"version": "0.46.0",
|
|
7
|
+
"date": "2026-07-18",
|
|
8
|
+
"kind": "feature",
|
|
9
|
+
"title": "Analyze a video — picture and sound — in one command",
|
|
10
|
+
"summary": "Run /design:video-analyze on a clip and Maude reads it end to end: it picks keyframes at real scene changes and action beats (not a blind frame rate), transcribes the audio, and writes a summary with shots, on-screen text, mood, and the best moment to use. A new Settings → Video section lets you choose the analysis engine and, on Apple Silicon, download an optional local Gemma model for smarter shot detection.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "print-artboards",
|
|
15
|
+
"version": "0.46.0",
|
|
16
|
+
"date": "2026-07-18",
|
|
17
|
+
"kind": "feature",
|
|
18
|
+
"title": "Design flyers, posters, and business cards — print-ready",
|
|
19
|
+
"summary": "Give a print artboard a real paper size (A4, Letter, business card, and more) and it resolves to the correct bleed-inclusive dimensions automatically. Toggle bleed/trim/margin guides from the View menu while you work, then export a 300–600 DPI PNG or a print-ready PDF with correct page boxes and crop/registration marks.",
|
|
20
|
+
"surface": "design-ui",
|
|
21
|
+
"learnMore": "https://maude.sh/docs/design/print"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "artboard-kinds-foundation",
|
|
25
|
+
"version": "0.46.0",
|
|
26
|
+
"date": "2026-07-18",
|
|
27
|
+
"kind": "feature",
|
|
28
|
+
"title": "Artboards now know what they are",
|
|
29
|
+
"summary": "Give any artboard a kind — Digital, Print, Web, or Video — from the Inspector or the right-click menu, and it gets its own chrome. New generic layout guides (columns, rows, grid) can be toggled on from the View menu for any artboard. Print- and web-specific tools land in follow-up releases.",
|
|
30
|
+
"surface": "design-ui"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "acp-ask-user-question",
|
|
34
|
+
"version": "0.45.2",
|
|
35
|
+
"date": "2026-07-15",
|
|
36
|
+
"kind": "feature",
|
|
37
|
+
"title": "Claude can ask you real questions in chat",
|
|
38
|
+
"summary": "When Claude needs a decision from you mid-task, it can now ask directly — a multi-choice or free-text form appears right in the chat panel, the same AskUserQuestion picker you'd see in the Claude Code CLI, instead of falling back to plain text.",
|
|
39
|
+
"surface": "design-ui",
|
|
40
|
+
"tour": [
|
|
41
|
+
{
|
|
42
|
+
"target": "[data-testid=\"chat-elicit-prompt\"]",
|
|
43
|
+
"title": "Claude asking you something",
|
|
44
|
+
"body": "Pick an option, type your own answer, or Skip — Cancel aborts whatever Claude was about to do with your answer.",
|
|
45
|
+
"placement": "top"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "acp-live-capabilities",
|
|
51
|
+
"version": "0.45.2",
|
|
52
|
+
"date": "2026-07-15",
|
|
53
|
+
"kind": "feature",
|
|
54
|
+
"title": "Model, mode & permission controls in chat",
|
|
55
|
+
"summary": "Switch models, effort, and permission mode right from the chat panel — pulled live from your own Claude Code install, never a fixed list. Pick Manual to approve each tool action yourself, or Bypass for hands-off editing; plus grouped tool calls, message Copy/Retry, and a context-usage meter.",
|
|
56
|
+
"surface": "design-ui",
|
|
57
|
+
"tour": [
|
|
58
|
+
{
|
|
59
|
+
"target": "[data-testid=\"chat-mode-picker\"]",
|
|
60
|
+
"title": "Live from your Claude Code",
|
|
61
|
+
"body": "Model, effort, and permission mode are pulled straight from your own Claude Code install — pick Manual to approve each action, or Bypass for hands-off editing.",
|
|
62
|
+
"placement": "top"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
},
|
|
4
66
|
{
|
|
5
67
|
"id": "bring-your-brand",
|
|
6
68
|
"version": "0.44.0",
|