@1agh/maude 0.46.0 → 0.48.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 (81) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  3. package/apps/studio/acp/bridge.ts +121 -6
  4. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  5. package/apps/studio/annotations-layer.tsx +42 -0
  6. package/apps/studio/api.ts +417 -2
  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/agent-browser-safe.sh +29 -0
  13. package/apps/studio/bin/curl-local.sh +28 -0
  14. package/apps/studio/build.ts +1 -1
  15. package/apps/studio/canvas-cursors.ts +6 -0
  16. package/apps/studio/canvas-edit.ts +678 -11
  17. package/apps/studio/canvas-icons.tsx +13 -0
  18. package/apps/studio/canvas-lib.tsx +3 -0
  19. package/apps/studio/canvas-shell.tsx +646 -26
  20. package/apps/studio/client/app.jsx +898 -52
  21. package/apps/studio/client/export-center.jsx +7 -6
  22. package/apps/studio/client/github.js +11 -4
  23. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  24. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  25. package/apps/studio/contextual-toolbar.tsx +5 -3
  26. package/apps/studio/dist/client.bundle.js +1103 -1103
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  29. package/apps/studio/dist/styles.css +1 -1
  30. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  31. package/apps/studio/exporters/pdf.ts +35 -1
  32. package/apps/studio/git/service.ts +4 -1
  33. package/apps/studio/grid-track-handles.ts +179 -0
  34. package/apps/studio/handoff.ts +35 -0
  35. package/apps/studio/http.ts +118 -0
  36. package/apps/studio/input-router.tsx +73 -17
  37. package/apps/studio/paths.ts +37 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  39. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  40. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  41. package/apps/studio/test/browse-posture.test.tsx +107 -0
  42. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  43. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  45. package/apps/studio/test/comment-mount.test.ts +2 -1
  46. package/apps/studio/test/component-map.test.ts +48 -0
  47. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  48. package/apps/studio/test/detach-component.test.ts +94 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  50. package/apps/studio/test/element-structural-api.test.ts +74 -0
  51. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  52. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  53. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  54. package/apps/studio/test/handoff.test.ts +48 -0
  55. package/apps/studio/test/input-router.test.ts +82 -8
  56. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  57. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  58. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  59. package/apps/studio/tool-palette.tsx +3 -1
  60. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  61. package/apps/studio/use-element-resize.tsx +3 -1
  62. package/apps/studio/use-grid-track-handles.tsx +364 -0
  63. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  64. package/apps/studio/use-tool-mode.tsx +30 -3
  65. package/apps/studio/web-overlay-content.tsx +52 -0
  66. package/apps/studio/whats-new.json +27 -0
  67. package/cli/bin/maude.mjs +1 -0
  68. package/cli/commands/design.mjs +16 -0
  69. package/cli/commands/init.mjs +80 -3
  70. package/cli/commands/kg.mjs +368 -0
  71. package/cli/commands/kg.test.mjs +118 -0
  72. package/cli/lib/ddr-to-kgai.mjs +648 -0
  73. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  74. package/cli/lib/flow-design-integration.test.mjs +2 -2
  75. package/cli/lib/gitignore-block.mjs +4 -0
  76. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  77. package/package.json +8 -8
  78. package/plugins/design/dependencies.json +17 -0
  79. package/plugins/design/templates/_shell.html +4 -0
  80. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  81. package/plugins/flow/dependencies.json +17 -0
@@ -227,6 +227,38 @@ const TOAST_CSS = `
227
227
  @media (prefers-reduced-motion: reduce) {
228
228
  .dc-media-toast { transition: none; }
229
229
  }
230
+ /* feature-4 (2026-07-19) — sandbox-safe confirm dialog (window.confirm is
231
+ silently blocked in the allow-modals-less canvas iframe). HUD-token styled. */
232
+ .dc-confirm-backdrop {
233
+ position: fixed; inset: 0; z-index: 40;
234
+ background: rgba(0,0,0,0.45);
235
+ display: flex; align-items: center; justify-content: center;
236
+ }
237
+ .dc-confirm {
238
+ min-width: 300px; max-width: 420px;
239
+ background: var(--maude-chrome-bg-1, #1b1e24);
240
+ color: var(--maude-chrome-fg-0, #e7eaf0);
241
+ border: 1px solid var(--maude-chrome-border, #333a45);
242
+ border-radius: 10px;
243
+ box-shadow: 0 14px 44px rgba(0,0,0,0.5);
244
+ padding: 16px;
245
+ font-family: var(--maude-chrome-font-ui, system-ui, sans-serif);
246
+ }
247
+ .dc-confirm-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
248
+ .dc-confirm-body { font-size: 12px; line-height: 1.5; color: var(--maude-chrome-fg-1, #b7bec9); white-space: pre-line; }
249
+ .dc-confirm-row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
250
+ .dc-confirm-btn {
251
+ padding: 5px 12px; border-radius: 7px; font-size: 12px; cursor: pointer;
252
+ background: var(--maude-chrome-bg-2, #262b33);
253
+ color: var(--maude-chrome-fg-0, #e7eaf0);
254
+ border: 1px solid var(--maude-chrome-border, #333a45);
255
+ }
256
+ .dc-confirm-btn--primary {
257
+ background: var(--maude-hud-accent, #0d99ff);
258
+ border-color: var(--maude-hud-accent, #0d99ff);
259
+ color: #fff;
260
+ }
261
+ .dc-confirm-btn:focus-visible { outline: 2px solid var(--maude-hud-accent, #0d99ff); outline-offset: 1px; }
230
262
  `.trim();
231
263
 
232
264
  function ensureMediaStyles(): void {
@@ -238,6 +270,100 @@ function ensureMediaStyles(): void {
238
270
  document.head.appendChild(s);
239
271
  }
240
272
 
273
+ /**
274
+ * feature-4 (2026-07-19) — sandbox-safe in-canvas CONFIRM. The canvas iframe
275
+ * runs with sandbox="allow-scripts allow-same-origin" (no allow-modals), so
276
+ * `window.confirm()` silently returns false — this promise-based overlay is
277
+ * the replacement (Esc / backdrop / Cancel → false, primary / Enter → true).
278
+ */
279
+ export function canvasConfirm(
280
+ message: string,
281
+ opts?: { title?: string; confirmLabel?: string; cancelLabel?: string }
282
+ ): Promise<boolean> {
283
+ if (typeof document === 'undefined') return Promise.resolve(false);
284
+ ensureMediaStyles();
285
+ return new Promise((resolve) => {
286
+ const backdrop = document.createElement('div');
287
+ backdrop.className = 'dc-confirm-backdrop';
288
+ const box = document.createElement('div');
289
+ box.className = 'dc-confirm';
290
+ box.setAttribute('role', 'alertdialog');
291
+ box.setAttribute('aria-modal', 'true');
292
+ const title = document.createElement('div');
293
+ title.className = 'dc-confirm-title';
294
+ title.textContent = opts?.title ?? 'Are you sure?';
295
+ const body = document.createElement('div');
296
+ body.className = 'dc-confirm-body';
297
+ body.textContent = message;
298
+ const row = document.createElement('div');
299
+ row.className = 'dc-confirm-row';
300
+ const cancel = document.createElement('button');
301
+ cancel.type = 'button';
302
+ cancel.className = 'dc-confirm-btn';
303
+ cancel.textContent = opts?.cancelLabel ?? 'Cancel';
304
+ const ok = document.createElement('button');
305
+ ok.type = 'button';
306
+ ok.className = 'dc-confirm-btn dc-confirm-btn--primary';
307
+ ok.textContent = opts?.confirmLabel ?? 'Continue';
308
+ // a11y fix (review fan-out, 2026-07-21) — this is a raw DOM overlay, not a
309
+ // native <dialog>, so it must implement the two APG modal-dialog musts
310
+ // itself: (1) restore focus to whatever had it before the dialog opened
311
+ // (a hostile canvas can't spoof this — it's just where focus already was);
312
+ // (2) trap Tab/Shift+Tab inside the two buttons so it can't escape to
313
+ // background content (this overlay mounts in the canvas iframe's own
314
+ // document, so "background" is live, possibly-interactive artboard markup).
315
+ const previouslyFocused = document.activeElement as HTMLElement | null;
316
+ const done = (v: boolean) => {
317
+ document.removeEventListener('keydown', onKey, true);
318
+ backdrop.remove();
319
+ try {
320
+ previouslyFocused?.focus();
321
+ } catch {
322
+ /* detached / no-op target — nothing to restore focus to */
323
+ }
324
+ resolve(v);
325
+ };
326
+ const onKey = (e: KeyboardEvent) => {
327
+ // SECURITY — ignore script-dispatched events (isTrusted: false). This
328
+ // dialog mounts in the canvas iframe's own document (untrusted, DDR-054);
329
+ // without this, hostile canvas JS could synthesize a KeyboardEvent/Event
330
+ // to auto-confirm itself past the "irreversible convert" prompt.
331
+ if (!e.isTrusted) return;
332
+ if (e.key === 'Escape') {
333
+ e.stopPropagation();
334
+ done(false);
335
+ } else if (e.key === 'Enter') {
336
+ e.stopPropagation();
337
+ done(true);
338
+ } else if (e.key === 'Tab') {
339
+ // 2-element focus trap: Tab/Shift+Tab just toggles between the two
340
+ // buttons, never leaving the dialog.
341
+ e.preventDefault();
342
+ e.stopPropagation();
343
+ (document.activeElement === ok ? cancel : ok).focus();
344
+ }
345
+ };
346
+ cancel.addEventListener('click', (e) => {
347
+ if (!e.isTrusted) return;
348
+ done(false);
349
+ });
350
+ ok.addEventListener('click', (e) => {
351
+ if (!e.isTrusted) return;
352
+ done(true);
353
+ });
354
+ backdrop.addEventListener('click', (e) => {
355
+ if (!e.isTrusted) return;
356
+ if (e.target === backdrop) done(false);
357
+ });
358
+ document.addEventListener('keydown', onKey, true);
359
+ row.append(cancel, ok);
360
+ box.append(title, body, row);
361
+ backdrop.append(box);
362
+ document.body.append(backdrop);
363
+ ok.focus();
364
+ });
365
+ }
366
+
241
367
  /** Show a brief auto-dismissing toast in the canvas (e.g. an upload failure). */
242
368
  export function showCanvasToast(message: string): void {
243
369
  if (typeof document === 'undefined') return;
@@ -19,7 +19,9 @@
19
19
  * resizes.
20
20
  *
21
21
  * In-flow elements resize by writing explicit `width`/`height` only
22
- * (NEVER convert-to-absolute — out of scope); an out-of-flow
22
+ * (a resize NEVER implicitly converts to absolute; the explicit
23
+ * context-menu "Convert children to absolute" — feature-4 T8,
24
+ * DDR-188 — is the opt-in flatten path); an out-of-flow
23
25
  * (absolute/fixed) element additionally moves `left`/`top` when the
24
26
  * top/left edge is dragged, so the resize feels origin-correct.
25
27
  */
@@ -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;
@@ -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);