@1agh/maude 0.45.2 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. package/plugins/design/templates/_shell.html +4 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * @file use-grid-track-handles.tsx — feature-3-web-artboards T5
3
+ * (absorbed feature-grid-track-editor stub).
4
+ * @scope apps/studio/use-grid-track-handles.tsx
5
+ * @purpose On-canvas CSS-Grid gutter drag-resize — the grid sibling of
6
+ * `use-spacing-handles.tsx`'s padding/gap overlay, same shape:
7
+ * live preview via direct inline-style writes, commit rides the
8
+ * SAME `resize-request` lane (`grid-template-columns`/`-rows`
9
+ * added to the shell's `resizeElement` allowed-prop list — no
10
+ * new write surface, mirroring how `padding-*`/`gap` were added
11
+ * there for Stage J). A component-instance drag threads the
12
+ * Stage H3 occurrence index the same way (`globalCdOccurrence`).
13
+ *
14
+ * Gated on computed `display: grid`/`inline-grid` (CLAUDE.md
15
+ * gotcha: "gate all of it on computed display" — a container
16
+ * that was converted to absolute, or never was a grid, is not an
17
+ * editing target). Only tracks AUTHORED inline via
18
+ * `grid-template-columns`/`-rows` (parseable by
19
+ * `grid-track-handles.ts`'s `parseTrackList`) get drag handles —
20
+ * `repeat()`/`minmax()`/a stylesheet-authored grid have no
21
+ * drag surface here (same constraint every curated knob already
22
+ * has: these on-canvas tools edit inline-style-authored values
23
+ * only); the Inspector's Grid section still shows the raw value
24
+ * for those cases.
25
+ */
26
+
27
+ import { type ReactNode, useCallback, useEffect, useRef } from 'react';
28
+ import { globalCdOccurrence, resolveSelectionEl } from './dom-selection.ts';
29
+ import { isElementDragActive } from './drag-state.ts';
30
+ import {
31
+ computeGutterLines,
32
+ computeTrackDrag,
33
+ GRID_KEYWORD_UNITS,
34
+ type GridTrack,
35
+ type GutterLine,
36
+ gutterTrackIndices,
37
+ parseTrackList,
38
+ serializeTrackList,
39
+ } from './grid-track-handles.ts';
40
+ import { worldZoomFor } from './use-element-resize.tsx';
41
+ import { useSelectionSet } from './use-selection-set.tsx';
42
+ import { useToolMode } from './use-tool-mode.tsx';
43
+
44
+ const GRID_HANDLE_CSS = `
45
+ .dc-grid-gutter-handle {
46
+ position: fixed;
47
+ transform: translate(-50%, -50%);
48
+ /* Same layering rationale as the spacing handles (z-index 4) — below the
49
+ Stage-D resize handles (6), which win any frame-edge hit-test tie. */
50
+ z-index: 4;
51
+ pointer-events: auto;
52
+ touch-action: none;
53
+ width: 10px;
54
+ height: 10px;
55
+ border-radius: 2px;
56
+ background: var(--maude-hud-accent, oklch(0.680 0.180 268));
57
+ border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
58
+ opacity: 0.55;
59
+ transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
60
+ }
61
+ .dc-grid-gutter-handle:hover,
62
+ .dc-grid-gutter-handle[data-dragging="true"] { opacity: 1; }
63
+ .dc-grid-gutter-handle[data-axis="x"] { cursor: ew-resize; }
64
+ .dc-grid-gutter-handle[data-axis="y"] { cursor: ns-resize; }
65
+ @media (prefers-reduced-motion: reduce) {
66
+ .dc-grid-gutter-handle { transition-duration: 1ms; }
67
+ }
68
+ `.trim();
69
+
70
+ function ensureGridHandleStyles(): void {
71
+ if (typeof document === 'undefined') return;
72
+ if (document.getElementById('dc-grid-gutter-css')) return;
73
+ const s = document.createElement('style');
74
+ s.id = 'dc-grid-gutter-css';
75
+ s.textContent = GRID_HANDLE_CSS;
76
+ document.head.appendChild(s);
77
+ }
78
+
79
+ function computedPx(cs: CSSStyleDeclaration, prop: string): number {
80
+ return Number.parseFloat(cs.getPropertyValue(prop)) || 0;
81
+ }
82
+
83
+ /** Parse a browser-RESOLVED `grid-template-columns`/`-rows` computed value
84
+ * ("100px 200px 100px") into a plain px array. Never contains keywords or
85
+ * `fr` — the browser already ran the grid-sizing algorithm. */
86
+ function resolvedTrackSizes(computedValue: string): number[] {
87
+ const s = computedValue.trim();
88
+ if (!s || s === 'none') return [];
89
+ return s.split(/\s+/).map((tok) => Number.parseFloat(tok) || 0);
90
+ }
91
+
92
+ interface AxisState {
93
+ container: 'col' | 'row';
94
+ authored: GridTrack[];
95
+ resolvedPx: number[];
96
+ gutters: GutterLine[];
97
+ }
98
+
99
+ interface GridDrag {
100
+ pointerId: number;
101
+ axis: 'x' | 'y';
102
+ container: 'col' | 'row';
103
+ gutterIndex: number;
104
+ el: HTMLElement;
105
+ cdId: string;
106
+ idIndex: number;
107
+ startClientX: number;
108
+ startClientY: number;
109
+ zoom: number;
110
+ /** Snapshot of the AUTHORED track list for this axis at drag-start. */
111
+ tracks: GridTrack[];
112
+ /** Snapshot of each touched track's resolved px at drag-start. */
113
+ resolvedAtStart: number[];
114
+ touched: number[];
115
+ prop: 'grid-template-columns' | 'grid-template-rows';
116
+ before: string;
117
+ }
118
+
119
+ export function GridTrackHandlesOverlay(): ReactNode {
120
+ ensureGridHandleStyles();
121
+ const { selected } = useSelectionSet();
122
+ const { tool } = useToolMode();
123
+ const containerRef = useRef<HTMLDivElement | null>(null);
124
+ const rafRef = useRef<number | null>(null);
125
+ const dragRef = useRef<GridDrag | null>(null);
126
+ const lastCommitRef = useRef<{ el: HTMLElement; prop: string; before: string } | null>(null);
127
+ // Latest per-axis geometry, read by the pointerdown handler (it fires
128
+ // between rAF ticks, so it needs the most recent computed snapshot rather
129
+ // than recomputing getComputedStyle itself).
130
+ const axesRef = useRef<{ col: AxisState | null; row: AxisState | null }>({
131
+ col: null,
132
+ row: null,
133
+ });
134
+
135
+ const one = selected.length === 1 ? selected[0] : null;
136
+ const cdId = one && typeof one.id === 'string' ? one.id : null;
137
+ const active = tool === 'move' && !!cdId;
138
+
139
+ useEffect(() => {
140
+ const c = containerRef.current;
141
+ if (!c) return;
142
+ const hideAll = () => {
143
+ for (const child of Array.from(c.children)) (child as HTMLElement).style.display = 'none';
144
+ axesRef.current = { col: null, row: null };
145
+ };
146
+ if (!active || !one) {
147
+ hideAll();
148
+ return;
149
+ }
150
+ const tick = () => {
151
+ rafRef.current = null;
152
+ const reschedule = () => {
153
+ rafRef.current = requestAnimationFrame(tick);
154
+ };
155
+ const bail = () => {
156
+ hideAll();
157
+ reschedule();
158
+ };
159
+ if (isElementDragActive()) {
160
+ bail();
161
+ return;
162
+ }
163
+ const el = resolveSelectionEl(document, one) as HTMLElement | null;
164
+ if (!el) {
165
+ bail();
166
+ return;
167
+ }
168
+ const cs = getComputedStyle(el);
169
+ const isGrid = cs.display === 'grid' || cs.display === 'inline-grid';
170
+ if (!isGrid) {
171
+ bail();
172
+ return;
173
+ }
174
+ const r = el.getBoundingClientRect();
175
+ if (r.width <= 0 && r.height <= 0) {
176
+ bail();
177
+ return;
178
+ }
179
+ const zoom = worldZoomFor(el);
180
+ const rect = { x: r.left, y: r.top, w: r.width, h: r.height };
181
+ const colGap = computedPx(cs, 'column-gap') * zoom;
182
+ const rowGap = computedPx(cs, 'row-gap') * zoom;
183
+
184
+ const buildAxis = (
185
+ container: 'col' | 'row',
186
+ prop: string,
187
+ gapPx: number
188
+ ): AxisState | null => {
189
+ const authored = parseTrackList(el.style.getPropertyValue(prop));
190
+ if (authored.length < 2) return null;
191
+ const resolvedPx = resolvedTrackSizes(cs.getPropertyValue(prop)).map((px) => px * zoom);
192
+ if (resolvedPx.length !== authored.length) return null; // repeat()/mismatch guard
193
+ const gutters = computeGutterLines(rect, resolvedPx, gapPx, container).filter((_, i) => {
194
+ const before = authored[i] as GridTrack;
195
+ const after = authored[i + 1] as GridTrack;
196
+ return !GRID_KEYWORD_UNITS.has(before.unit) || !GRID_KEYWORD_UNITS.has(after.unit);
197
+ });
198
+ if (gutters.length === 0) return null;
199
+ return { container, authored, resolvedPx, gutters };
200
+ };
201
+
202
+ const col = buildAxis('col', 'grid-template-columns', colGap);
203
+ const row = buildAxis('row', 'grid-template-rows', rowGap);
204
+ axesRef.current = { col, row };
205
+
206
+ const allGutters = [...(col?.gutters ?? []), ...(row?.gutters ?? [])];
207
+ const TOTAL = allGutters.length;
208
+ while (c.children.length < TOTAL) c.appendChild(document.createElement('div'));
209
+ while (c.children.length > TOTAL) c.lastChild && c.removeChild(c.lastChild);
210
+ let i = 0;
211
+ for (const [container, axisState] of [
212
+ ['col', col],
213
+ ['row', row],
214
+ ] as const) {
215
+ const kind = container === 'col' ? 'column' : 'row';
216
+ for (const g of axisState?.gutters ?? []) {
217
+ const h = c.children[i] as HTMLElement;
218
+ h.className = 'dc-grid-gutter-handle';
219
+ h.dataset.container = container;
220
+ h.dataset.gutterIndex = String(g.index);
221
+ h.dataset.axis = g.axis;
222
+ h.title = `grid ${kind} gutter · drag to resize · shift = resize both neighbors`;
223
+ h.style.display = 'block';
224
+ h.style.left = `${Math.round(g.x)}px`;
225
+ h.style.top = `${Math.round(g.y)}px`;
226
+ i++;
227
+ }
228
+ }
229
+ rafRef.current = requestAnimationFrame(tick);
230
+ };
231
+ rafRef.current = requestAnimationFrame(tick);
232
+ return () => {
233
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
234
+ };
235
+ }, [active, one]);
236
+
237
+ const commit = useCallback((d: GridDrag, serialized: string) => {
238
+ lastCommitRef.current = { el: d.el, prop: d.prop, before: d.before };
239
+ try {
240
+ window.parent.postMessage(
241
+ {
242
+ dgn: 'resize-request',
243
+ id: d.cdId,
244
+ patch: { [d.prop]: serialized },
245
+ before: { [d.prop]: d.before },
246
+ idIndex: d.idIndex,
247
+ },
248
+ '*'
249
+ );
250
+ } catch {
251
+ /* detached / cross-origin teardown */
252
+ }
253
+ }, []);
254
+
255
+ useEffect(() => {
256
+ const c = containerRef.current;
257
+ if (!c) return;
258
+
259
+ const onDown = (e: PointerEvent) => {
260
+ const t = e.target as HTMLElement | null;
261
+ if (!t?.classList.contains('dc-grid-gutter-handle')) return;
262
+ if (!one || typeof one.id !== 'string') return;
263
+ const el = resolveSelectionEl(document, one) as HTMLElement | null;
264
+ if (!el) return;
265
+ const container = t.dataset.container as 'col' | 'row';
266
+ const axisState = axesRef.current[container];
267
+ if (!axisState) return;
268
+ const gutterIndex = Number(t.dataset.gutterIndex);
269
+ if (!Number.isFinite(gutterIndex)) return;
270
+ e.preventDefault();
271
+ e.stopPropagation();
272
+ const zoom = worldZoomFor(el);
273
+ const prop: GridDrag['prop'] =
274
+ container === 'col' ? 'grid-template-columns' : 'grid-template-rows';
275
+ dragRef.current = {
276
+ pointerId: e.pointerId,
277
+ axis: (t.dataset.axis as 'x' | 'y') ?? 'x',
278
+ container,
279
+ gutterIndex,
280
+ el,
281
+ cdId: one.id,
282
+ idIndex: globalCdOccurrence(document, one.id, el),
283
+ startClientX: e.clientX,
284
+ startClientY: e.clientY,
285
+ zoom: zoom > 0 ? zoom : 1,
286
+ tracks: axisState.authored,
287
+ resolvedAtStart: axisState.resolvedPx,
288
+ touched: gutterTrackIndices(gutterIndex, false),
289
+ prop,
290
+ before: serializeTrackList(axisState.authored),
291
+ };
292
+ t.dataset.dragging = 'true';
293
+ try {
294
+ t.setPointerCapture(e.pointerId);
295
+ } catch {
296
+ /* synthetic events may reject capture */
297
+ }
298
+ };
299
+
300
+ const previewFor = (d: GridDrag, e: PointerEvent): GridTrack[] => {
301
+ const dx = e.clientX - d.startClientX;
302
+ const dy = e.clientY - d.startClientY;
303
+ const touched = gutterTrackIndices(d.gutterIndex, e.shiftKey).filter(
304
+ (idx) => idx < d.tracks.length
305
+ );
306
+ const next = d.tracks.map((tr) => ({ ...tr }));
307
+ for (const idx of touched) {
308
+ const tr = d.tracks[idx];
309
+ const resolvedPx = d.resolvedAtStart[idx];
310
+ if (!tr || !Number.isFinite(resolvedPx) || GRID_KEYWORD_UNITS.has(tr.unit)) continue;
311
+ const newValue = computeTrackDrag(tr, resolvedPx as number, dx, dy, d.zoom, d.axis);
312
+ next[idx] = { value: newValue, unit: tr.unit };
313
+ }
314
+ return next;
315
+ };
316
+
317
+ const onMove = (e: PointerEvent) => {
318
+ const d = dragRef.current;
319
+ if (!d || e.pointerId !== d.pointerId) return;
320
+ e.preventDefault();
321
+ const next = previewFor(d, e);
322
+ d.el.style.setProperty(d.prop, serializeTrackList(next));
323
+ };
324
+
325
+ const onUp = (e: PointerEvent) => {
326
+ const d = dragRef.current;
327
+ if (!d || e.pointerId !== d.pointerId) return;
328
+ dragRef.current = null;
329
+ const handle = containerRef.current?.querySelector<HTMLElement>(
330
+ '.dc-grid-gutter-handle[data-dragging="true"]'
331
+ );
332
+ if (handle) delete handle.dataset.dragging;
333
+ const next = previewFor(d, e);
334
+ const serialized = serializeTrackList(next);
335
+ if (serialized === d.before) return; // no-op guard
336
+ commit(d, serialized);
337
+ };
338
+
339
+ const onFail = (e: MessageEvent) => {
340
+ const m = e.data as { dgn?: string } | null;
341
+ if (m?.dgn !== 'resize-failed') return;
342
+ if (e.source !== window.parent) return; // only the parent shell (DDR-054)
343
+ const last = lastCommitRef.current;
344
+ if (!last) return;
345
+ last.el.style.setProperty(last.prop, last.before);
346
+ lastCommitRef.current = null;
347
+ };
348
+
349
+ c.addEventListener('pointerdown', onDown);
350
+ window.addEventListener('pointermove', onMove);
351
+ window.addEventListener('pointerup', onUp);
352
+ window.addEventListener('pointercancel', onUp);
353
+ window.addEventListener('message', onFail);
354
+ return () => {
355
+ c.removeEventListener('pointerdown', onDown);
356
+ window.removeEventListener('pointermove', onMove);
357
+ window.removeEventListener('pointerup', onUp);
358
+ window.removeEventListener('pointercancel', onUp);
359
+ window.removeEventListener('message', onFail);
360
+ };
361
+ }, [one, commit]);
362
+
363
+ return <div ref={containerRef} aria-hidden="true" />;
364
+ }
@@ -29,6 +29,7 @@ import { useArtboardsContext, useDragStateContext } from './canvas-lib.tsx';
29
29
  import { resolveSelectionEl, scopedCdSelector, selectorIndex } from './dom-selection.ts';
30
30
  import { isEditableTarget } from './input-router.tsx';
31
31
  import { type Selection, useSelectionSet } from './use-selection-set.tsx';
32
+ import { useToolModeOptional } from './use-tool-mode.tsx';
32
33
 
33
34
  const STEP_SMALL = 1;
34
35
  const STEP_LARGE = 10;
@@ -62,6 +63,18 @@ export function useKeyboardDiscipline(): void {
62
63
  const selSet = useSelectionSet();
63
64
  const artboardsCtx = useArtboardsContext();
64
65
  const dragBus = useDragStateContext();
66
+ // feature-4 (browse/move split) — every keyboard EDITING op here (Cmd+A,
67
+ // Cmd+D, copy/paste-style, Enter/Tab tree traversal, Delete, arrow-nudge) is
68
+ // scoped OUT of the `browse` tool: in the alive mode the mock owns the
69
+ // keyboard (a real form submits on Enter, an input takes Delete). A live ref
70
+ // keeps the document capture listener from re-attaching on every tool change.
71
+ // Default to 'move' (NOT 'browse') when no ToolProvider is above us — a
72
+ // context without a tool store (a bare specimen) must keep its keyboard
73
+ // editing ops, exactly as before this feature. Only an ACTIVE `browse` tool
74
+ // suppresses them.
75
+ const toolCtx = useToolModeOptional();
76
+ const toolRef = useRef(toolCtx?.tool ?? 'move');
77
+ toolRef.current = toolCtx?.tool ?? 'move';
65
78
  // Element arrow-nudge burst (Task L1): accumulate left/top, preview inline, and
66
79
  // commit once after a short pause (one undo per settle, not per keypress).
67
80
  const nudgeRef = useRef<{
@@ -78,6 +91,8 @@ export function useKeyboardDiscipline(): void {
78
91
 
79
92
  const onKeyDown = (e: KeyboardEvent) => {
80
93
  if (isEditableTarget(e.target)) return;
94
+ // feature-4 — browse is a pure pass-through; the mock owns the keyboard.
95
+ if (toolRef.current === 'browse') return;
81
96
 
82
97
  // Cmd+A → select all stamped elements in the active artboard.
83
98
  const isMeta = e.metaKey || e.ctrlKey;
@@ -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',
@@ -48,7 +48,11 @@ export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle'
48
48
  // crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
49
49
  // the tool-palette icons. `move` keeps the system arrow on purpose.
50
50
  export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
51
- { id: 'move', label: 'Move', shortcut: 'V', cursor: TOOL_CURSORS.move },
51
+ // feature-4 — Browse is the BOOT default: the mock is alive (buttons click).
52
+ // Press V for the Move (select) tool. No letter shortcut — it's a deliberate
53
+ // "I'm done editing" choice via the palette / Esc-from-a-draw-tool.
54
+ { id: 'browse', label: 'Browse', shortcut: '', cursor: TOOL_CURSORS.browse },
55
+ { id: 'move', label: 'Select', shortcut: 'V', cursor: TOOL_CURSORS.move },
52
56
  { id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
53
57
  { id: 'comment', label: 'Comment', shortcut: 'C', cursor: TOOL_CURSORS.comment },
54
58
  { id: 'pen', label: 'Pen', shortcut: 'B', cursor: TOOL_CURSORS.pen },
@@ -89,7 +93,11 @@ const ToolContext = createContext<ToolContextValue | null>(null);
89
93
  export function ToolProvider({
90
94
  children,
91
95
  tools = DEFAULT_TOOLS,
92
- initial = 'move',
96
+ // feature-4 (browse/move split, DDR-187) — boot into `browse` so a freshly
97
+ // opened mock is ALIVE (native pass-through). V flips to the Move (select)
98
+ // tool. This is the user-decided boot posture (2026-07-15), zero regression
99
+ // for existing canvases + every native-input surface.
100
+ initial = 'browse',
93
101
  }: {
94
102
  children: ReactNode;
95
103
  tools?: readonly ToolDescriptor[];
@@ -134,13 +142,32 @@ export function ToolProvider({
134
142
  const desc = tools.find((t) => t.id === tool);
135
143
  if (!desc) return;
136
144
  const prev = document.body.style.cursor;
137
- document.body.style.cursor = desc.cursor;
138
145
  let styleEl = document.getElementById('dc-tool-cursor') as HTMLStyleElement | null;
139
146
  if (!styleEl) {
140
147
  styleEl = document.createElement('style');
141
148
  styleEl.id = 'dc-tool-cursor';
142
149
  document.head.appendChild(styleEl);
143
150
  }
151
+ // feature-4 — the browse tool is a pure pass-through: it must NOT force a
152
+ // global cursor, or the `* { cursor: … !important }` rule below would beat
153
+ // the mock's own affordance cursors (pointer over a button, text over an
154
+ // input) and the canvas would stop reading as alive. Clear the forced rule
155
+ // and let the body/native cursors win.
156
+ if (tool === 'browse') {
157
+ document.body.style.cursor = '';
158
+ styleEl.textContent = '';
159
+ if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
160
+ try {
161
+ window.parent.postMessage({ dgn: 'tool-cursor', tool }, '*');
162
+ } catch {
163
+ /* cross-origin parent rejected */
164
+ }
165
+ }
166
+ return () => {
167
+ document.body.style.cursor = prev;
168
+ };
169
+ }
170
+ document.body.style.cursor = desc.cursor;
144
171
  // Truly GLOBAL inside the canvas document — `*` so it covers the empty grid
145
172
  // host, `.dc-world`, every artboard + its content, AND the floating chrome
146
173
  // (minimap, toolbar). The earlier `.dc-world`-scoped rule left the empty
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @file web-overlay-content.tsx — feature-3-web-artboards T2.
3
+ * @scope apps/studio/web-overlay-content.tsx
4
+ * @purpose Registers the 'web' kind's on-canvas chrome into the
5
+ * foundation's kindOverlayRegistry (artboard-guides-overlay.tsx):
6
+ * a small breakpoint-band chip in the header's top-right corner
7
+ * (the video badge's own slot — 'web' and 'video' are mutually
8
+ * exclusive via DCArtboard's resolvedKind, so there's no
9
+ * collision) showing the artboard's CURRENT live width as
10
+ * "≤ {width}px" — the Framer-breakpoints-bar vocabulary (Design
11
+ * Decisions 2/3). Dragging the artboard's resize handle (T4)
12
+ * visibly updates this label live, which is how "drag width to
13
+ * test reflow" reads as a breakpoint change rather than a plain
14
+ * resize.
15
+ *
16
+ * Decorative — aria-hidden, pointer-events none. Reuses the
17
+ * engine's own HUD chrome tokens (--bg-2/--fg-1/--font-mono),
18
+ * same as the artboard label strip itself, rather than DS tokens
19
+ * — this chip IS chrome, never exported content. Always-on (no
20
+ * visibility gate): unlike print's bleed/trim/margin overlay
21
+ * (which visually covers artboard content and needs an opt-in
22
+ * toggle), this is a single small corner label with nothing to
23
+ * collide with, so there's no reason to hide it.
24
+ */
25
+
26
+ import { type KindOverlayRenderFn, registerKindOverlay } from './artboard-guides-overlay.tsx';
27
+
28
+ const WebOverlayContent: KindOverlayRenderFn = ({ rect }) => {
29
+ const width = Math.round(rect.w);
30
+ return (
31
+ <div
32
+ style={{
33
+ position: 'absolute',
34
+ top: 4,
35
+ right: 6,
36
+ padding: '2px 6px',
37
+ borderRadius: 3,
38
+ background: 'var(--bg-2, #e8e3d8)',
39
+ color: 'var(--fg-1, #4a3f30)',
40
+ fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
41
+ fontSize: 9,
42
+ letterSpacing: '0.04em',
43
+ whiteSpace: 'nowrap',
44
+ }}
45
+ >
46
+ {`≤ ${width}px`}
47
+ </div>
48
+ );
49
+ };
50
+ WebOverlayContent.displayName = 'WebOverlayContent';
51
+
52
+ registerKindOverlay('web', WebOverlayContent);