@1agh/maude 0.46.0 → 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 (56) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +113 -5
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/api.ts +417 -2
  5. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  6. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  7. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  8. package/apps/studio/bin/_curl-local.mjs +349 -0
  9. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  10. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  11. package/apps/studio/bin/curl-local.sh +28 -0
  12. package/apps/studio/canvas-cursors.ts +6 -0
  13. package/apps/studio/canvas-edit.ts +678 -11
  14. package/apps/studio/canvas-icons.tsx +13 -0
  15. package/apps/studio/canvas-lib.tsx +3 -0
  16. package/apps/studio/canvas-shell.tsx +646 -26
  17. package/apps/studio/client/app.jsx +898 -52
  18. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  19. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  20. package/apps/studio/contextual-toolbar.tsx +5 -3
  21. package/apps/studio/dist/client.bundle.js +1103 -1103
  22. package/apps/studio/dist/comment-mount.js +2 -2
  23. package/apps/studio/dist/styles.css +1 -1
  24. package/apps/studio/grid-track-handles.ts +179 -0
  25. package/apps/studio/handoff.ts +35 -0
  26. package/apps/studio/http.ts +118 -0
  27. package/apps/studio/input-router.tsx +73 -17
  28. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  29. package/apps/studio/test/browse-posture.test.tsx +107 -0
  30. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  31. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  32. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  33. package/apps/studio/test/comment-mount.test.ts +2 -1
  34. package/apps/studio/test/component-map.test.ts +48 -0
  35. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  36. package/apps/studio/test/detach-component.test.ts +94 -0
  37. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  38. package/apps/studio/test/element-structural-api.test.ts +74 -0
  39. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  40. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  41. package/apps/studio/test/handoff.test.ts +48 -0
  42. package/apps/studio/test/input-router.test.ts +82 -8
  43. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  44. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  45. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  46. package/apps/studio/tool-palette.tsx +3 -1
  47. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  48. package/apps/studio/use-element-resize.tsx +3 -1
  49. package/apps/studio/use-grid-track-handles.tsx +364 -0
  50. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  51. package/apps/studio/use-tool-mode.tsx +30 -3
  52. package/apps/studio/web-overlay-content.tsx +52 -0
  53. package/apps/studio/whats-new.json +27 -0
  54. package/cli/commands/design.mjs +16 -0
  55. package/package.json +8 -8
  56. package/plugins/design/templates/_shell.html +4 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @file grid-track-handles.ts — feature-3-web-artboards T5 (absorbed
3
+ * feature-grid-track-editor stub).
4
+ * @purpose Pure geometry + value math for the on-canvas grid gutter
5
+ * drag-resize overlay. Framework-free (unit-testable without a
6
+ * DOM), mirroring `spacing-handles.ts`'s shape exactly — this is
7
+ * the CSS-Grid sibling of that flex/box-model module, deferred
8
+ * there in a doc comment ("CSS-Grid gap editing is out of v1
9
+ * scope — grid tracks are a separate follow-up plan").
10
+ *
11
+ * Track SIZING math is a two-layer split, same idiom as the
12
+ * padding/gap overlay: geometry (gutter positions) reads the
13
+ * browser's RESOLVED px sizes (the caller reads
14
+ * `getComputedStyle(el).gridTemplateColumns` — the browser
15
+ * already ran the grid-sizing algorithm, resolving `fr`/`auto`/
16
+ * `%` tracks to concrete px, so this module never re-implements
17
+ * that algorithm); DRAG math re-derives a new AUTHORED value
18
+ * (the JSX-authored unit — px/%/fr/em — read from the element's
19
+ * inline style, since these on-canvas tools only edit inline-
20
+ * style-authored values, same constraint as every other curated
21
+ * knob) from the ratio between the target resolved size and the
22
+ * track's CURRENT resolved size.
23
+ */
24
+
25
+ export type GridTrackUnit = 'px' | '%' | 'fr' | 'em' | 'auto' | 'min-content' | 'max-content';
26
+
27
+ export const GRID_KEYWORD_UNITS: ReadonlySet<GridTrackUnit> = new Set([
28
+ 'auto',
29
+ 'min-content',
30
+ 'max-content',
31
+ ]);
32
+
33
+ export interface GridTrack {
34
+ /** Numeric value; 0 (ignored) for keyword-only units. */
35
+ value: number;
36
+ unit: GridTrackUnit;
37
+ }
38
+
39
+ const NUMERIC_TRACK_RE = /^(-?\d+(?:\.\d+)?)(px|%|fr|em)$/;
40
+
41
+ /**
42
+ * Parse a `grid-template-columns`/`grid-template-rows` value into a track
43
+ * list. Handles a plain space-separated list of numeric (px/%/fr/em) and
44
+ * keyword (auto/min-content/max-content) tracks. Returns `[]` for an empty
45
+ * string OR anything it can't parse token-for-token (e.g. `repeat(...)`,
46
+ * `minmax(...)`, `subgrid`) — the caller treats an empty parse as "no
47
+ * drag-editable tracks", falling back to the Inspector's raw-value field
48
+ * rather than guessing at a lossy round-trip.
49
+ */
50
+ export function parseTrackList(raw: string | null | undefined): GridTrack[] {
51
+ const s = (raw ?? '').trim();
52
+ if (!s) return [];
53
+ const parts = s.split(/\s+/);
54
+ const out: GridTrack[] = [];
55
+ for (const part of parts) {
56
+ if (GRID_KEYWORD_UNITS.has(part as GridTrackUnit)) {
57
+ out.push({ value: 0, unit: part as GridTrackUnit });
58
+ continue;
59
+ }
60
+ const m = NUMERIC_TRACK_RE.exec(part);
61
+ if (!m) return [];
62
+ out.push({ value: Number(m[1]), unit: m[2] as GridTrackUnit });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** Serialize a track list back to a `grid-template-columns`/`-rows` value —
68
+ * the inverse of `parseTrackList`. `fr` (and every other unit) round-trips
69
+ * byte-for-byte modulo the `round2` value rounding applied during drag. */
70
+ export function serializeTrackList(tracks: GridTrack[]): string {
71
+ return tracks
72
+ .map((t) => (GRID_KEYWORD_UNITS.has(t.unit) ? t.unit : `${round2(t.value)}${t.unit}`))
73
+ .join(' ');
74
+ }
75
+
76
+ export interface ScreenRect {
77
+ x: number;
78
+ y: number;
79
+ w: number;
80
+ h: number;
81
+ }
82
+
83
+ export interface GutterLine {
84
+ /** Index of the track BEFORE this gutter; track `index + 1` is after it. */
85
+ index: number;
86
+ axis: 'x' | 'y';
87
+ x: number;
88
+ y: number;
89
+ }
90
+
91
+ /**
92
+ * Compute gutter handle positions from a grid container's screen rect, each
93
+ * track's RESOLVED size in screen px (already zoom-scaled — same convention
94
+ * as `computePaddingLines`'s `padding` argument), and the resolved gap
95
+ * between tracks. One gutter per adjacent pair, positioned at the gap's
96
+ * midpoint. Fewer than 2 tracks → no gutters.
97
+ */
98
+ export function computeGutterLines(
99
+ rect: ScreenRect,
100
+ resolvedSizesPx: number[],
101
+ gapPx: number,
102
+ axis: 'col' | 'row'
103
+ ): GutterLine[] {
104
+ if (resolvedSizesPx.length < 2) return [];
105
+ const out: GutterLine[] = [];
106
+ let cum = axis === 'col' ? rect.x : rect.y;
107
+ for (let i = 0; i < resolvedSizesPx.length - 1; i++) {
108
+ cum += resolvedSizesPx[i] as number;
109
+ const mid = cum + gapPx / 2;
110
+ if (axis === 'col') {
111
+ out.push({ index: i, axis: 'x', x: mid, y: rect.y + rect.h / 2 });
112
+ } else {
113
+ out.push({ index: i, axis: 'y', x: rect.x + rect.w / 2, y: mid });
114
+ }
115
+ cum += gapPx;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * New AUTHORED value for one track being dragged, given the screen-px delta
122
+ * (world units — the caller already divides by zoom, same convention as
123
+ * `computePaddingDrag`/`computeGapDrag`... actually this one takes the RAW
124
+ * screen delta + zoom together, dividing internally, to match those two
125
+ * functions' own signature exactly) and the track's CURRENT resolved px size.
126
+ *
127
+ * - `px` / `em` — additive: 1 world px of drag = 1 unit (em is treated as a
128
+ * px-equivalent for drag purposes — a precise em→px conversion would need
129
+ * the element's own font-size; this is a disclosed simplification, same
130
+ * spirit as the rest of this drag lane favoring predictable math over
131
+ * pixel-perfect unit fidelity mid-drag).
132
+ * - `fr` / `%` — proportional SHARE units, not absolute sizes: the new value
133
+ * is the OLD value scaled by (targetResolvedPx / currentResolvedPx) — the
134
+ * same "re-derive the ratio" approach a flex-grow-style drag uses. Clamped
135
+ * to a small positive floor so a track never collapses to (or past) zero
136
+ * share.
137
+ * - keyword tracks (auto/min-content/max-content) have no numeric value —
138
+ * returns the track unchanged; the caller must not offer this as a
139
+ * drag target (see `gutterTrackIndices` — the hook filters keyword-only
140
+ * tracks out of the touched set before calling this).
141
+ */
142
+ export function computeTrackDrag(
143
+ track: GridTrack,
144
+ resolvedPx: number,
145
+ dxScreen: number,
146
+ dyScreen: number,
147
+ zoom: number,
148
+ axis: 'x' | 'y'
149
+ ): number {
150
+ if (GRID_KEYWORD_UNITS.has(track.unit)) return track.value;
151
+ const z = zoom > 0 ? zoom : 1;
152
+ const d = (axis === 'x' ? dxScreen : dyScreen) / z;
153
+ if (track.unit === 'px' || track.unit === 'em') {
154
+ return Math.max(0, round2(track.value + d));
155
+ }
156
+ const targetPx = Math.max(1, resolvedPx + d);
157
+ if (resolvedPx <= 0) return track.value; // no ratio to derive from — no-op
158
+ const ratio = targetPx / resolvedPx;
159
+ const floor = track.unit === '%' ? 1 : 0.1;
160
+ return Math.max(floor, round2(track.value * ratio));
161
+ }
162
+
163
+ /**
164
+ * Which track indices a gutter drag touches, given the Shift modifier —
165
+ * mirrors `paddingSideSet`'s Alt/Shift convention: plain drag touches only
166
+ * the track BEFORE the gutter; Shift touches BOTH neighbors (each driven to
167
+ * its own new value from the SAME cursor delta — "symmetric" in this
168
+ * codebase's established vocabulary means both move together, not an
169
+ * inversely-linked splitter, matching how `paddingSideSet`'s Alt sets both
170
+ * opposite padding sides to the identical dragged value rather than trading
171
+ * space between them).
172
+ */
173
+ export function gutterTrackIndices(gutterIndex: number, shiftKey: boolean): number[] {
174
+ return shiftKey ? [gutterIndex, gutterIndex + 1] : [gutterIndex];
175
+ }
176
+
177
+ function round2(n: number): number {
178
+ return Math.round(n * 100) / 100;
179
+ }
@@ -80,6 +80,15 @@ export interface RegistryItem {
80
80
  light?: Record<string, string>;
81
81
  dark?: Record<string, string>;
82
82
  };
83
+ /**
84
+ * shadcn's generic item-specific-metadata extension point. feature-3-web-
85
+ * artboards T6 — carries the resolved artboard `kind` (`digital` | `print`
86
+ * | `web` | `video` | `mixed`) so a consumer (or a future re-import) knows
87
+ * what authoring contract produced this drop without re-parsing the TSX.
88
+ */
89
+ meta?: {
90
+ kind: string;
91
+ };
83
92
  }
84
93
 
85
94
  export interface EmitOptions {
@@ -103,6 +112,31 @@ export interface EmitOptions {
103
112
  designRoot?: string;
104
113
  }
105
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // feature-3-web-artboards T6 — thread the artboard kind into the emitted
117
+ // registry-item's `meta` (a first-class shadcn registry-item.json extension
118
+ // point for item-specific metadata, not a maude-invented field). A regex scan
119
+ // is deliberate here (not an AST walk like stripDataCdId) — this only needs
120
+ // the `kind="…"` string values, never a node reference to edit.
121
+
122
+ /**
123
+ * Resolve a single `kind` value for the whole canvas file, from its
124
+ * `<DCArtboard kind="…">` occurrences. A canvas with zero explicit `kind`
125
+ * attributes resolves to `'digital'` (DCArtboard's own implicit default). A
126
+ * canvas whose artboards carry more than one distinct explicit kind (e.g. a
127
+ * multi-breakpoint web canvas assembled via "Duplicate at width…" next to an
128
+ * unrelated screen) resolves to `'mixed'` rather than picking one arbitrarily.
129
+ */
130
+ export function resolveCanvasKind(rawSource: string): string {
131
+ const kinds = new Set<string>();
132
+ for (const m of rawSource.matchAll(/<DCArtboard\b[^>]*\bkind="([a-z]+)"/g)) {
133
+ if (m[1]) kinds.add(m[1]);
134
+ }
135
+ if (kinds.size === 0) return 'digital';
136
+ if (kinds.size === 1) return [...kinds][0] as string;
137
+ return 'mixed';
138
+ }
139
+
106
140
  // ---------------------------------------------------------------------------
107
141
  // Strip data-cd-id from source — the inverse of canvas-pipeline.ts pass 1.
108
142
 
@@ -668,6 +702,7 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
668
702
  registryDependencies,
669
703
  files,
670
704
  ...(cssVars ? { cssVars } : {}),
705
+ meta: { kind: resolveCanvasKind(rawTsx) },
671
706
  };
672
707
 
673
708
  // Drop undefined keys for a clean JSON.
@@ -2221,6 +2221,70 @@ export function createHttp(
2221
2221
  );
2222
2222
  },
2223
2223
 
2224
+ '/_api/convert-to-absolute': async (req: Request) => {
2225
+ // feature-4 T8 (convert-to-absolute, DDR-188) — "Remove auto layout": POST
2226
+ // { canvas, containerId, containerIdIndex?, containerSetRelative,
2227
+ // children: [{ id, idIndex?, left, top, width, height }] } →
2228
+ // api.convertChildrenToAbsoluteOp. ONE whole-file undo seq. MAIN-ORIGIN
2229
+ // ONLY (absent from both allowlists); sameOriginWrite + loopback-Host gated
2230
+ // — same DDR-054 posture as /_api/insert-element.
2231
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2232
+ if (!sameOriginWrite(req))
2233
+ return new Response('cross-origin write rejected', { status: 403 });
2234
+ if (!isLoopbackHost(req.headers.get('host')))
2235
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2236
+ const body = await readJson<{
2237
+ canvas?: unknown;
2238
+ containerId?: unknown;
2239
+ containerIdIndex?: unknown;
2240
+ containerSetRelative?: unknown;
2241
+ allowShared?: unknown;
2242
+ children?: unknown;
2243
+ containers?: unknown;
2244
+ dissolve?: unknown;
2245
+ }>(req, 256 * 1024);
2246
+ if (!body) return new Response('body required', { status: 400 });
2247
+ const result = await api.convertChildrenToAbsoluteOp(body);
2248
+ if (!result.ok) {
2249
+ return Response.json(
2250
+ { ok: false, error: result.error },
2251
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2252
+ );
2253
+ }
2254
+ return Response.json(
2255
+ { ok: true, seq: result.seq },
2256
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2257
+ );
2258
+ },
2259
+
2260
+ '/_api/detach-component': async (req: Request) => {
2261
+ // feature-4 detach-component (2026-07-19) — POST { canvas, id, idIndex? }
2262
+ // → api.detachComponentOp (clone definition + repoint this usage; one
2263
+ // whole-file undo seq). MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host
2264
+ // gated — same DDR-054 posture as the other structural writes.
2265
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2266
+ if (!sameOriginWrite(req))
2267
+ return new Response('cross-origin write rejected', { status: 403 });
2268
+ if (!isLoopbackHost(req.headers.get('host')))
2269
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2270
+ const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
2271
+ req,
2272
+ 8 * 1024
2273
+ );
2274
+ if (!body) return new Response('body required', { status: 400 });
2275
+ const result = await api.detachComponentOp(body);
2276
+ if (!result.ok) {
2277
+ return Response.json(
2278
+ { ok: false, error: result.error },
2279
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2280
+ );
2281
+ }
2282
+ return Response.json(
2283
+ { ok: true, detachedName: result.detachedName, seq: result.seq },
2284
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2285
+ );
2286
+ },
2287
+
2224
2288
  '/_api/duplicate-element': async (req: Request) => {
2225
2289
  // Cmd+D (Task L3) — duplicate an element (a copy as the next sibling). POST
2226
2290
  // { canvas, id, idIndex? } → api.duplicateElementOp. Whole-file undo seq.
@@ -2280,6 +2344,38 @@ export function createHttp(
2280
2344
  );
2281
2345
  },
2282
2346
 
2347
+ '/_api/duplicate-artboard': async (req: Request) => {
2348
+ // feature-3-web-artboards T3 — "Duplicate at width…". POST
2349
+ // { canvas, artboardId, width } → api.duplicateArtboardOp (clones the
2350
+ // artboard as the next sibling with a suffixed id/label + the new
2351
+ // width; every other prop, incl. kind/guides/print, carries over
2352
+ // verbatim). Whole-file undo seq. MAIN-ORIGIN ONLY (absent from both
2353
+ // allowlists); sameOriginWrite + loopback-Host gated, same posture as
2354
+ // /_api/insert-artboard above.
2355
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2356
+ if (!sameOriginWrite(req))
2357
+ return new Response('cross-origin write rejected', { status: 403 });
2358
+ if (!isLoopbackHost(req.headers.get('host')))
2359
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2360
+ const body = await readJson<{
2361
+ canvas?: unknown;
2362
+ artboardId?: unknown;
2363
+ width?: unknown;
2364
+ }>(req, 8 * 1024);
2365
+ if (!body) return new Response('body required', { status: 400 });
2366
+ const result = await api.duplicateArtboardOp(body);
2367
+ if (!result.ok) {
2368
+ return Response.json(
2369
+ { ok: false, error: result.error },
2370
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2371
+ );
2372
+ }
2373
+ return Response.json(
2374
+ { ok: true, artboardId: result.artboardId, seq: result.seq },
2375
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2376
+ );
2377
+ },
2378
+
2283
2379
  '/_api/assets': async (req: Request) => {
2284
2380
  // Stage F1 (feature-element-editing-robustness) — list content-addressed
2285
2381
  // image/video assets under <designRoot>/assets/ for the AssetPicker (Replace
@@ -2340,6 +2436,28 @@ export function createHttp(
2340
2436
  return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
2341
2437
  },
2342
2438
 
2439
+ '/_api/component-map': async (req: Request) => {
2440
+ // feature-4 T7a — Layers-panel purple instance rows. GET ?canvas →
2441
+ // { ok, map: { [cdId]: { component, root, usages } } }. READ-only parse
2442
+ // (same posture as /_api/edit-scope). MAIN-ORIGIN ONLY: absent from
2443
+ // CANVAS_SAFE_API + startCanvasServer routes (DDR-054); loopback-Host
2444
+ // gated; no sameOriginWrite (GET).
2445
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2446
+ if (!isLoopbackHost(req.headers.get('host')))
2447
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2448
+ const url = new URL(req.url);
2449
+ const result = await api.componentMapOp({
2450
+ canvas: url.searchParams.get('canvas') ?? undefined,
2451
+ });
2452
+ if (!result.ok) {
2453
+ return Response.json(
2454
+ { ok: false, error: result.error },
2455
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2456
+ );
2457
+ }
2458
+ return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
2459
+ },
2460
+
2343
2461
  '/_api/resize-artboard': async (req: Request) => {
2344
2462
  // Stage D4 — free-hand artboard resize. POST { canvas, artboardId, width?,
2345
2463
  // height? } → api.resizeArtboardOp (writes the NUMERIC width/height props on
@@ -64,6 +64,12 @@ export function crossedDragThreshold(
64
64
  * can grab them natively.
65
65
  */
66
66
  export type Tool =
67
+ // feature-4 (browse/move split) — `browse` is the BOOT default: a pure
68
+ // native pass-through so a freshly-opened mock stays ALIVE (buttons click,
69
+ // links follow, inputs focus). It carries ZERO select machinery — the router
70
+ // returns `no-op` for every browse-tool pointer event so descendants see them
71
+ // untouched. Pressing V flips to `move` (the select tool). See DDR-187.
72
+ | 'browse'
67
73
  | 'move'
68
74
  | 'hand'
69
75
  | 'comment'
@@ -113,9 +119,12 @@ export type RouterAction =
113
119
  /**
114
120
  * `true` resolves to the deepest descendant under the cursor (Cmd-held
115
121
  * mode). `false` resolves to the topmost interesting ancestor (top mode).
116
- * Phase 4.1 Move-tool selection always uses deep=true — bare clicks
117
- * are passthrough (no select), and the only entry points are Cmd
118
- * (replace deep) and Cmd+Shift (add deep).
122
+ * feature-4 (browse/move split, DDR-187) — the Move (select) tool now
123
+ * fires the FULL Figma ladder: a BARE click selects the TOP-level object
124
+ * (`deep:false`), Cmd selects the DEEPEST element (`deep:true`), Shift
125
+ * adds, Cmd+Shift adds-deep. (Pre-feature-4 Move-tool select was
126
+ * Cmd-only and always deep; bare clicks were passthrough — that role now
127
+ * belongs to the `browse` tool, which never selects at all.)
119
128
  */
120
129
  deep: boolean;
121
130
  clientX: number;
@@ -210,6 +219,19 @@ export function classify(input: ClassifyInput): RouterAction {
210
219
  if (isAnnotationTool(input.activeTool)) return { kind: 'no-op' };
211
220
  // Hand tool: drag pan is owned by useViewportController; no hover paint.
212
221
  if (input.activeTool === 'hand') return { kind: 'no-op' };
222
+ // Browse tool (feature-4 boot default): pass-through — no hover halo,
223
+ // native interactions flow. Cmd-held hover previews the deepest element
224
+ // (the escape-hatch select affordance, mirroring the old move-tool
225
+ // behavior); bare hover stays native.
226
+ if (input.activeTool === 'browse') {
227
+ if (!metaOrCtrl(input)) return { kind: 'no-op' };
228
+ return {
229
+ kind: 'hover',
230
+ deep: true,
231
+ clientX: input.clientX ?? 0,
232
+ clientY: input.clientY ?? 0,
233
+ };
234
+ }
213
235
  // Comment tool: always paint a preview halo on the deepest element under
214
236
  // cursor — that's the element the user is about to comment on. Comment
215
237
  // pin attachment is to the same element they were hovering.
@@ -221,12 +243,12 @@ export function classify(input: ClassifyInput): RouterAction {
221
243
  clientY: input.clientY ?? 0,
222
244
  };
223
245
  }
224
- // Move tool: bare hover does nothing (native interactions pass through);
225
- // Cmd-held hover paints a halo on the deepest element (preview).
226
- if (!metaOrCtrl(input)) return { kind: 'no-op' };
246
+ // Move (select) tool: paint a preview halo of exactly what a click would
247
+ // select — the TOP-level object on a bare hover (Figma's hover outline),
248
+ // the DEEPEST element while Cmd is held (deep-select preview).
227
249
  return {
228
250
  kind: 'hover',
229
- deep: true,
251
+ deep: metaOrCtrl(input),
230
252
  clientX: input.clientX ?? 0,
231
253
  clientY: input.clientY ?? 0,
232
254
  };
@@ -267,17 +289,38 @@ export function classify(input: ClassifyInput): RouterAction {
267
289
  // the controller's pointerdown listener on the same host claims the drag.
268
290
  if (input.activeTool === 'hand') return { kind: 'no-op' };
269
291
 
270
- // Move tool. Selection ONLY fires with Cmd / Cmd+Shift. Bare clicks and
271
- // Shift-without-Cmd pass through so native canvas interactions (button
272
- // presses, link clicks, input focus) still work — exactly the same as
273
- // pre-Phase-4.1 behavior for everything except Cmd-modified gestures.
292
+ // Browse tool (feature-4 boot default): bare/Shift left-clicks pass
293
+ // through so the mock stays alive (a button press fires, a link follows,
294
+ // an input focuses). Cmd/Ctrl+click is the ESCAPE HATCH (user steer
295
+ // 2026-07-19): it selects the deepest element AND the consumer flips the
296
+ // tool to Move — "I clicked to edit" shouldn't require pressing V first.
297
+ if (input.activeTool === 'browse') {
298
+ if (!metaOrCtrl(input)) return { kind: 'no-op' };
299
+ return {
300
+ kind: 'select',
301
+ mode: input.shiftKey ? 'add' : 'replace',
302
+ deep: true,
303
+ clientX: input.clientX ?? 0,
304
+ clientY: input.clientY ?? 0,
305
+ };
306
+ }
307
+
308
+ // Move (select) tool. feature-4 (DDR-187) — the full Figma ladder:
309
+ // bare click → select TOP-level object (deep:false, replace)
310
+ // Shift+click → add TOP-level object (deep:false, add)
311
+ // Cmd/Ctrl+click → select DEEPEST element (deep:true, replace)
312
+ // Cmd+Shift+click → add DEEPEST element (deep:true, add)
313
+ // Bare clicks are claimed (preventDefault) so native canvas interactions do
314
+ // NOT fire in select mode — that's the browse tool's job. The click-vs-drag
315
+ // threshold (canvas-shell.tsx ReorderDrag / marquee overlays, all still
316
+ // gated on `tool === 'move'`) means a press that turns into a drag reorders
317
+ // / marquees instead; a release-in-place is the select.
274
318
  const cmd = metaOrCtrl(input);
275
- if (!cmd) return { kind: 'no-op' };
276
319
  const shift = !!input.shiftKey;
277
320
  return {
278
321
  kind: 'select',
279
322
  mode: shift ? 'add' : 'replace',
280
- deep: true,
323
+ deep: cmd,
281
324
  clientX: input.clientX ?? 0,
282
325
  clientY: input.clientY ?? 0,
283
326
  };
@@ -434,6 +477,11 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
434
477
  // their own clicks. The router is in capture phase, so we have to
435
478
  // bail HERE before classify can claim the event.
436
479
  if (isOverlayTarget(e.target)) return;
480
+ // feature-4 text-gestures (2026-07-20) — an ACTIVE inline text editor
481
+ // (contenteditable) owns its clicks natively: single click places the
482
+ // caret, dblclick selects a word, triple-click selects all. Claiming
483
+ // these as `select` killed every in-edit gesture.
484
+ if (isEditableTarget(e.target)) return;
437
485
  const action = claim(
438
486
  classify({
439
487
  type: 'pointerdown',
@@ -468,6 +516,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
468
516
  */
469
517
  const onMouseDown = (e: MouseEvent): void => {
470
518
  if (isOverlayTarget(e.target)) return;
519
+ if (isEditableTarget(e.target)) return;
471
520
  const action = claim(
472
521
  classify({
473
522
  type: 'pointerdown',
@@ -508,19 +557,26 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
508
557
  */
509
558
  const onClick = (e: MouseEvent): void => {
510
559
  if (isOverlayTarget(e.target)) return;
560
+ if (isEditableTarget(e.target)) return;
511
561
  const tool = getActiveTool();
512
562
  const mod = e.metaKey || e.ctrlKey;
513
563
  // Map the click to the action kind the matching pointerdown would have
514
564
  // produced, then honor the claim allowlist so a scoped router (the
515
565
  // comment mount layer) doesn't suppress clicks it never claimed.
566
+ // feature-4 — in the Move (select) tool EVERY bare left click is a select
567
+ // (not just Cmd), so its synthetic click must be swallowed too or the
568
+ // native handler under the just-selected element would still fire. Draw
569
+ // tools keep the Cmd-only escape-hatch select. Browse claims nothing.
516
570
  const wouldRouteKind: RouterAction['kind'] | null =
517
571
  tool === 'comment'
518
572
  ? 'drop-comment'
519
- : tool === 'move' && mod && e.button === 0
573
+ : tool === 'move' && e.button === 0
520
574
  ? 'select'
521
- : e.button === 2
522
- ? 'context-menu'
523
- : null;
575
+ : (isAnnotationTool(tool) || tool === 'browse') && mod && e.button === 0
576
+ ? 'select'
577
+ : e.button === 2
578
+ ? 'context-menu'
579
+ : null;
524
580
  if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
525
581
  e.preventDefault();
526
582
  e.stopImmediatePropagation();