@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.
Files changed (91) hide show
  1. package/apps/studio/acp/bridge.ts +628 -74
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/annotations-snap.ts +50 -10
  6. package/apps/studio/api.ts +344 -14
  7. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  8. package/apps/studio/bin/_import-asset.mjs +35 -5
  9. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  10. package/apps/studio/bin/_png-playwright.mjs +38 -4
  11. package/apps/studio/bin/_pw-launch.mjs +52 -0
  12. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  13. package/apps/studio/bin/_smart-frames.mjs +419 -0
  14. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  15. package/apps/studio/bin/smart-frames.sh +30 -0
  16. package/apps/studio/canvas-edit.ts +302 -3
  17. package/apps/studio/canvas-lib.tsx +203 -11
  18. package/apps/studio/canvas-shell.tsx +167 -28
  19. package/apps/studio/client/app.jsx +678 -176
  20. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  21. package/apps/studio/client/panels/ChatPanel.jsx +846 -133
  22. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  23. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  24. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  25. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  26. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  27. package/apps/studio/client/panels/acp-capabilities.js +82 -0
  28. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  29. package/apps/studio/client/panels/acp-runtime.js +248 -9
  30. package/apps/studio/client/panels/acp-usage.js +65 -0
  31. package/apps/studio/client/panels/transcript-view.js +36 -0
  32. package/apps/studio/client/styles/6-acp-chat.css +689 -11
  33. package/apps/studio/dist/client.bundle.js +1738 -1736
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/generation/whisper-models.test.ts +28 -1
  44. package/apps/studio/generation/whisper-models.ts +22 -7
  45. package/apps/studio/http.ts +246 -9
  46. package/apps/studio/print/marks.ts +113 -0
  47. package/apps/studio/print/units.ts +269 -0
  48. package/apps/studio/print-overlay-content.tsx +132 -0
  49. package/apps/studio/test/acp-bridge.test.ts +11 -6
  50. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  51. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  52. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  53. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  54. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  56. package/apps/studio/test/acp-permission.test.ts +262 -0
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  58. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  59. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  60. package/apps/studio/test/acp-transcript.test.ts +75 -2
  61. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  62. package/apps/studio/test/acp-usage.test.ts +143 -0
  63. package/apps/studio/test/annotations-snap.test.ts +56 -0
  64. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  65. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  66. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  67. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  68. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  69. package/apps/studio/test/exporters/png.test.ts +49 -1
  70. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  73. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  74. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  75. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  76. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  77. package/apps/studio/test/import-asset.test.ts +31 -3
  78. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  79. package/apps/studio/test/print-marks.test.ts +113 -0
  80. package/apps/studio/test/print-units.test.ts +173 -0
  81. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  82. package/apps/studio/use-chrome-visibility.tsx +19 -0
  83. package/apps/studio/use-element-resize.tsx +21 -2
  84. package/apps/studio/use-snap-guides.tsx +73 -5
  85. package/apps/studio/use-spacing-handles.tsx +9 -5
  86. package/apps/studio/whats-new.json +62 -0
  87. package/cli/commands/design.mjs +116 -3
  88. package/cli/lib/pkg-root.mjs +42 -10
  89. package/cli/lib/pkg-root.test.mjs +33 -1
  90. package/package.json +11 -9
  91. 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 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,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",