@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
@@ -40,6 +40,12 @@ import {
40
40
  sanitizeReuseText,
41
41
  } from './generation/audio-library.ts';
42
42
  import { localizeGenAsset } from './generation/download.ts';
43
+ import {
44
+ downloadGemmaModel,
45
+ ffmpegAvailable,
46
+ listGemmaModels,
47
+ mlxVlmAvailable,
48
+ } from './generation/gemma-models.ts';
43
49
  import { type GenerationJobQueue, GenerationQueueFullError } from './generation/jobs.ts';
44
50
  import {
45
51
  configuredProviders,
@@ -49,8 +55,11 @@ import {
49
55
  setProviderKey,
50
56
  } from './generation/keys.ts';
51
57
  import {
58
+ isKeyframeEngine,
52
59
  isTranscriptionProvider,
60
+ readKeyframeEngine,
53
61
  readTranscriptionProvider,
62
+ writeKeyframeEngine,
54
63
  writeTranscriptionProvider,
55
64
  } from './generation/prefs.ts';
56
65
  import {
@@ -691,6 +700,12 @@ export function createHttp(
691
700
  error?: string;
692
701
  } | null = null;
693
702
 
703
+ // feature-scene-aware-keyframes — in-flight Gemma-model download state, polled by
704
+ // the Settings "Scene-aware keyframes" card via GET /_api/generate/keyframe-model.
705
+ // Closure-scoped: one server, one download.
706
+ let keyframeDownload: { id: string; received: number; total: number; error?: string } | null =
707
+ null;
708
+
694
709
  // Cache invalidation — when canvas-lib changes, every cached canvas bundle
695
710
  // is stale because canvas-lib is inlined into each one via the resolver
696
711
  // plugin. Drop the whole cache so the next request rebuilds with the fresh
@@ -2206,6 +2221,70 @@ export function createHttp(
2206
2221
  );
2207
2222
  },
2208
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
+
2209
2288
  '/_api/duplicate-element': async (req: Request) => {
2210
2289
  // Cmd+D (Task L3) — duplicate an element (a copy as the next sibling). POST
2211
2290
  // { canvas, id, idIndex? } → api.duplicateElementOp. Whole-file undo seq.
@@ -2265,6 +2344,38 @@ export function createHttp(
2265
2344
  );
2266
2345
  },
2267
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
+
2268
2379
  '/_api/assets': async (req: Request) => {
2269
2380
  // Stage F1 (feature-element-editing-robustness) — list content-addressed
2270
2381
  // image/video assets under <designRoot>/assets/ for the AssetPicker (Replace
@@ -2325,6 +2436,28 @@ export function createHttp(
2325
2436
  return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
2326
2437
  },
2327
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
+
2328
2461
  '/_api/resize-artboard': async (req: Request) => {
2329
2462
  // Stage D4 — free-hand artboard resize. POST { canvas, artboardId, width?,
2330
2463
  // height? } → api.resizeArtboardOp (writes the NUMERIC width/height props on
@@ -2421,6 +2554,98 @@ export function createHttp(
2421
2554
  );
2422
2555
  },
2423
2556
 
2557
+ '/_api/set-artboard-kind': async (req: Request) => {
2558
+ // feature-1-artboard-kinds-foundation, T8. POST { canvas, artboardId,
2559
+ // kind: 'digital'|'print'|'web'|'video'|null } → api.setArtboardKindOp.
2560
+ // `null` clears the explicit prop back to the implicit default. Whole-
2561
+ // file undo seq. MAIN-ORIGIN ONLY; same gate pair as set-artboard-style
2562
+ // (the kind-switch surfaces — context menu, Inspector — are shell UI,
2563
+ // never reachable from the untrusted canvas origin).
2564
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2565
+ if (!sameOriginWrite(req))
2566
+ return new Response('cross-origin write rejected', { status: 403 });
2567
+ if (!isLoopbackHost(req.headers.get('host')))
2568
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2569
+ const body = await readJson<{
2570
+ canvas?: unknown;
2571
+ artboardId?: unknown;
2572
+ kind?: unknown;
2573
+ }>(req, 2 * 1024);
2574
+ if (!body) return new Response('body required', { status: 400 });
2575
+ const result = await api.setArtboardKindOp(body);
2576
+ if (!result.ok) {
2577
+ return Response.json(
2578
+ { ok: false, error: result.error },
2579
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2580
+ );
2581
+ }
2582
+ return Response.json(
2583
+ { ok: true, seq: result.seq },
2584
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2585
+ );
2586
+ },
2587
+
2588
+ '/_api/set-artboard-guides': async (req: Request) => {
2589
+ // feature-1-artboard-kinds-foundation, T5. POST { canvas, artboardId,
2590
+ // guides: {...}|null } → api.setArtboardGuidesOp. REPLACE-whole-prop
2591
+ // (see applySetArtboardGuides doc comment) — the caller sends the full
2592
+ // merged object, not a delta. MAIN-ORIGIN ONLY; same gate pair as
2593
+ // set-artboard-style.
2594
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2595
+ if (!sameOriginWrite(req))
2596
+ return new Response('cross-origin write rejected', { status: 403 });
2597
+ if (!isLoopbackHost(req.headers.get('host')))
2598
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2599
+ const body = await readJson<{
2600
+ canvas?: unknown;
2601
+ artboardId?: unknown;
2602
+ guides?: unknown;
2603
+ }>(req, 8 * 1024);
2604
+ if (!body) return new Response('body required', { status: 400 });
2605
+ const result = await api.setArtboardGuidesOp(body);
2606
+ if (!result.ok) {
2607
+ return Response.json(
2608
+ { ok: false, error: result.error },
2609
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2610
+ );
2611
+ }
2612
+ return Response.json(
2613
+ { ok: true, seq: result.seq },
2614
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2615
+ );
2616
+ },
2617
+
2618
+ '/_api/set-artboard-print': async (req: Request) => {
2619
+ // feature-2-print-artboards T2. POST { canvas, artboardId, print:
2620
+ // {paper, orientation?, bleedMm?, marginsMm?}|null } → api.setArtboardPrintOp.
2621
+ // REPLACE-whole-prop (see applySetArtboardPrint's own doc comment) — the
2622
+ // caller sends the full merged object, not a delta. MAIN-ORIGIN ONLY;
2623
+ // same gate pair as set-artboard-guides (Inspector picker is shell UI,
2624
+ // never reachable from the untrusted canvas origin).
2625
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2626
+ if (!sameOriginWrite(req))
2627
+ return new Response('cross-origin write rejected', { status: 403 });
2628
+ if (!isLoopbackHost(req.headers.get('host')))
2629
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2630
+ const body = await readJson<{
2631
+ canvas?: unknown;
2632
+ artboardId?: unknown;
2633
+ print?: unknown;
2634
+ }>(req, 2 * 1024);
2635
+ if (!body) return new Response('body required', { status: 400 });
2636
+ const result = await api.setArtboardPrintOp(body);
2637
+ if (!result.ok) {
2638
+ return Response.json(
2639
+ { ok: false, error: result.error },
2640
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2641
+ );
2642
+ }
2643
+ return Response.json(
2644
+ { ok: true, seq: result.seq },
2645
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2646
+ );
2647
+ },
2648
+
2424
2649
  '/_api/delete-artboard': async (req: Request) => {
2425
2650
  // Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
2426
2651
  // POST { canvas, artboardId } → api.deleteArtboardOp (removes the
@@ -2942,20 +3167,38 @@ export function createHttp(
2942
3167
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2943
3168
  if (req.method === 'GET') {
2944
3169
  return Response.json(
2945
- { transcriptionProvider: readTranscriptionProvider(ctx.paths.repoRoot) },
3170
+ {
3171
+ transcriptionProvider: readTranscriptionProvider(ctx.paths.repoRoot),
3172
+ keyframeEngine: readKeyframeEngine(ctx.paths.repoRoot),
3173
+ },
2946
3174
  { headers: { 'Cache-Control': 'no-store' } }
2947
3175
  );
2948
3176
  }
2949
3177
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2950
3178
  if (!sameOriginWrite(req))
2951
3179
  return new Response('cross-origin write rejected', { status: 403 });
2952
- const body = await readJson<{ transcriptionProvider?: unknown }>(req, 4 * 1024);
2953
- const provider = body?.transcriptionProvider;
2954
- if (!isTranscriptionProvider(provider))
2955
- return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
2956
- status: 400,
2957
- });
3180
+ const body = await readJson<{ transcriptionProvider?: unknown; keyframeEngine?: unknown }>(
3181
+ req,
3182
+ 4 * 1024
3183
+ );
3184
+ // The panel POSTs ONE pref at a time (transcription engine OR keyframe engine).
2958
3185
  try {
3186
+ if ('keyframeEngine' in (body ?? {})) {
3187
+ const engine = body?.keyframeEngine;
3188
+ if (!isKeyframeEngine(engine))
3189
+ return new Response('keyframeEngine must be auto|gemma|ffmpeg|blind', { status: 400 });
3190
+ await writeKeyframeEngine(ctx.paths.repoRoot, engine);
3191
+ reloadConfig(ctx);
3192
+ return Response.json(
3193
+ { keyframeEngine: engine },
3194
+ { headers: { 'Cache-Control': 'no-store' } }
3195
+ );
3196
+ }
3197
+ const provider = body?.transcriptionProvider;
3198
+ if (!isTranscriptionProvider(provider))
3199
+ return new Response('transcriptionProvider must be whisper|elevenlabs|groq', {
3200
+ status: 400,
3201
+ });
2959
3202
  await writeTranscriptionProvider(ctx.paths.repoRoot, provider);
2960
3203
  reloadConfig(ctx);
2961
3204
  return Response.json(
@@ -3177,6 +3420,87 @@ export function createHttp(
3177
3420
  );
3178
3421
  },
3179
3422
 
3423
+ // feature-scene-aware-keyframes — managed local Gemma-4 MLX scout models: GET
3424
+ // lists the registry + which are downloaded + any in-flight download + the two
3425
+ // availability signals (mlx-vlm runtime, ffmpeg) the Settings card needs to say
3426
+ // which tier will run; POST {id} downloads one via huggingface_hub (gated on
3427
+ // mlx-vlm being installed — the model is useless without it). MAIN-ORIGIN ONLY.
3428
+ '/_api/generate/keyframe-model': async (req: Request) => {
3429
+ if (!isLoopbackHost(req.headers.get('host')))
3430
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3431
+ if (req.method === 'GET') {
3432
+ // The GET builds availability by spawning subprocesses (import mlx_vlm is
3433
+ // heavy). Gate it same-origin so a cross-origin no-cors flood can't drive a
3434
+ // spawn-storm against the loopback server (DDR-183 security finding — the
3435
+ // sibling audio-search GET added this guard for the same reason). The probes
3436
+ // are also TTL-cached in gemma-models.ts as defence-in-depth.
3437
+ if (!sameOriginRead(req))
3438
+ return new Response('cross-origin read rejected', { status: 403 });
3439
+ return Response.json(
3440
+ {
3441
+ models: listGemmaModels(),
3442
+ downloading: keyframeDownload,
3443
+ mlxVlmAvailable: mlxVlmAvailable(),
3444
+ ffmpegAvailable: ffmpegAvailable(),
3445
+ },
3446
+ { headers: { 'Cache-Control': 'no-store' } }
3447
+ );
3448
+ }
3449
+ if (req.method === 'DELETE') {
3450
+ // Free a wedged download slot (a stalled HF connection) without waiting out
3451
+ // the 60-min abort timeout (DDR-183 F4). Same-origin write-gated.
3452
+ if (!sameOriginWrite(req))
3453
+ return new Response('cross-origin write rejected', { status: 403 });
3454
+ keyframeDownload = null;
3455
+ return Response.json({ cancelled: true }, { headers: { 'Cache-Control': 'no-store' } });
3456
+ }
3457
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
3458
+ if (!sameOriginWrite(req))
3459
+ return new Response('cross-origin write rejected', { status: 403 });
3460
+ if (!mlxVlmAvailable())
3461
+ return new Response(
3462
+ 'mlx-vlm not installed — the Gemma scout needs an Apple-Silicon Mac + `pip install mlx-vlm`.',
3463
+ {
3464
+ status: 400,
3465
+ }
3466
+ );
3467
+ const body = await readJson<{ id?: unknown }>(req, 4 * 1024);
3468
+ const id = typeof body?.id === 'string' ? body.id : '';
3469
+ if (!listGemmaModels().some((m) => m.id === id))
3470
+ return new Response('unknown model id', { status: 400 });
3471
+ if (keyframeDownload && !keyframeDownload.error)
3472
+ return new Response(`a download is already in progress (${keyframeDownload.id})`, {
3473
+ status: 409,
3474
+ });
3475
+ keyframeDownload = { id, received: 0, total: 0 };
3476
+ void downloadGemmaModel(
3477
+ id,
3478
+ (received, total) => {
3479
+ if (keyframeDownload && keyframeDownload.id === id) {
3480
+ keyframeDownload.received = received;
3481
+ keyframeDownload.total = total;
3482
+ }
3483
+ },
3484
+ AbortSignal.timeout(60 * 60_000) // multi-GB snapshots on a slow link
3485
+ )
3486
+ .then(() => {
3487
+ if (keyframeDownload?.id === id) keyframeDownload = null;
3488
+ })
3489
+ .catch((err) => {
3490
+ if (keyframeDownload?.id === id)
3491
+ keyframeDownload = {
3492
+ id,
3493
+ received: keyframeDownload.received,
3494
+ total: keyframeDownload.total,
3495
+ error: err instanceof Error ? err.message : 'download failed',
3496
+ };
3497
+ });
3498
+ return Response.json(
3499
+ { started: id },
3500
+ { status: 202, headers: { 'Cache-Control': 'no-store' } }
3501
+ );
3502
+ },
3503
+
3180
3504
  '/_canvas-state': async (req: Request) => {
3181
3505
  const url = new URL(req.url);
3182
3506
  if (req.method === 'GET') {
@@ -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();