@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.
- package/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +151 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +760 -15
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +972 -6
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +206 -11
- package/apps/studio/canvas-shell.tsx +809 -50
- package/apps/studio/client/app.jsx +1554 -206
- package/apps/studio/client/panels/ChatPanel.jsx +92 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1627 -1627
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +331 -7
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +237 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +363 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +24 -3
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +55 -0
- package/cli/commands/design.mjs +25 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
- package/plugins/design/templates/_shell.html +4 -0
package/apps/studio/http.ts
CHANGED
|
@@ -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
|
-
{
|
|
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 }>(
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* (
|
|
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:
|
|
225
|
-
//
|
|
226
|
-
|
|
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:
|
|
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
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
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:
|
|
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' &&
|
|
573
|
+
: tool === 'move' && e.button === 0
|
|
520
574
|
? 'select'
|
|
521
|
-
: e.button ===
|
|
522
|
-
? '
|
|
523
|
-
:
|
|
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();
|