@1agh/maude 0.45.2 → 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.
Files changed (54) hide show
  1. package/apps/studio/acp/bridge.ts +43 -1
  2. package/apps/studio/annotations-snap.ts +50 -10
  3. package/apps/studio/api.ts +344 -14
  4. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  5. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  6. package/apps/studio/bin/_png-playwright.mjs +38 -4
  7. package/apps/studio/bin/_pw-launch.mjs +52 -0
  8. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  9. package/apps/studio/bin/_smart-frames.mjs +419 -0
  10. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  11. package/apps/studio/bin/smart-frames.sh +30 -0
  12. package/apps/studio/canvas-edit.ts +302 -3
  13. package/apps/studio/canvas-lib.tsx +203 -11
  14. package/apps/studio/canvas-shell.tsx +167 -28
  15. package/apps/studio/client/app.jsx +678 -176
  16. package/apps/studio/client/panels/ChatPanel.jsx +45 -1
  17. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  18. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  19. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  20. package/apps/studio/dist/client.bundle.js +1398 -1398
  21. package/apps/studio/dist/comment-mount.js +2 -2
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/dom-selection.ts +20 -0
  24. package/apps/studio/export-dialog.tsx +138 -18
  25. package/apps/studio/exporters/pdf.ts +332 -18
  26. package/apps/studio/exporters/png.ts +45 -3
  27. package/apps/studio/footage/schema.ts +17 -1
  28. package/apps/studio/generation/gemma-models.ts +224 -0
  29. package/apps/studio/generation/prefs.ts +43 -0
  30. package/apps/studio/http.ts +213 -7
  31. package/apps/studio/print/marks.ts +113 -0
  32. package/apps/studio/print/units.ts +269 -0
  33. package/apps/studio/print-overlay-content.tsx +132 -0
  34. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  35. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  36. package/apps/studio/test/annotations-snap.test.ts +56 -0
  37. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  38. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  39. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  40. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  41. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  42. package/apps/studio/test/exporters/png.test.ts +49 -1
  43. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  44. package/apps/studio/test/print-marks.test.ts +113 -0
  45. package/apps/studio/test/print-units.test.ts +173 -0
  46. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  47. package/apps/studio/use-chrome-visibility.tsx +19 -0
  48. package/apps/studio/use-element-resize.tsx +21 -2
  49. package/apps/studio/use-snap-guides.tsx +73 -5
  50. package/apps/studio/use-spacing-handles.tsx +9 -5
  51. package/apps/studio/whats-new.json +28 -0
  52. package/cli/commands/design.mjs +9 -1
  53. package/package.json +8 -8
  54. package/plugins/design/dependencies.json +35 -0
@@ -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
+ });
@@ -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 world = el.closest('.dc-world') as HTMLElement | null;
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
- export type SnapKind = 'grid' | 'sibling';
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
- // Sibling candidates.
178
- for (const other of others) {
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 { scaleFromMatrix } from './use-element-resize.tsx';
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
- const world = el.closest('.dc-world') as HTMLElement | null;
178
- const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
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
- const world = el.closest('.dc-world') as HTMLElement | null;
278
- const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
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,34 @@
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
+ },
4
32
  {
5
33
  "id": "acp-ask-user-question",
6
34
  "version": "0.45.2",
@@ -87,6 +87,14 @@ const BIN_VERBS = new Set([
87
87
  // reads the clip as a file:// resource).
88
88
  'ingest-footage',
89
89
  'probe-footage',
90
+ // feature-scene-aware-keyframes. `smart-frames` is the scene-AWARE sibling of
91
+ // `probe-footage`: it selects keyframes at scene cuts + semantic action beats +
92
+ // endpoints (three tiers, auto-detected — gemma scout / ffmpeg scene-detect /
93
+ // blind Chromium floor) instead of a blind frame rate, and emits a SUPERSET of
94
+ // probe-footage's manifest (drop-in for the footage-analyst). No dev server.
95
+ // Owned by skill `footage-keyframes`; --engine auto self-detects deps so it
96
+ // works standalone as a terminal video analyzer.
97
+ 'smart-frames',
90
98
  // feature-ai-media-generation (Phase 0, DDR-16x). `generate` is the thin
91
99
  // non-browser BYOK verb (curl → /_api/generate-jobs → poll → print the
92
100
  // produced /assets/<sha8>.<ext>). The provider call happens server-side (the
@@ -290,7 +298,7 @@ Dev-tooling (dispatch to the dev-server bash helpers — DDR-062):
290
298
  screenshot · server-up · prep · slug · bootstrap-check · runtime-health
291
299
  smoke · canvas-edit · handoff · asset-sweep · visual-sanity · fetch-asset
292
300
  draw-build · draw-proof · svg-optimize · to-lottie · read-annotations · annotate
293
- canvas-rects · ingest-footage · probe-footage
301
+ canvas-rects · ingest-footage · probe-footage · smart-frames
294
302
  generate · transcribe · audio-search
295
303
  Invoke the bundled helper of the same name. maude resolves it from its
296
304
  own package root and sets CLAUDE_PLUGIN_ROOT for the child; stdout,