@1agh/maude 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +121 -6
- package/apps/studio/acp/plugin-bootstrap.ts +15 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/api.ts +417 -2
- 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/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +678 -11
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +3 -0
- package/apps/studio/canvas-shell.tsx +646 -26
- package/apps/studio/client/app.jsx +898 -52
- package/apps/studio/client/export-center.jsx +7 -6
- package/apps/studio/client/github.js +11 -4
- package/apps/studio/client/panels/ChatPanel.jsx +47 -2
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1103 -1103
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/examples/perf-100-artboards.tsx +1 -1
- package/apps/studio/exporters/pdf.ts +35 -1
- package/apps/studio/git/service.ts +4 -1
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +118 -0
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/paths.ts +37 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
- package/apps/studio/test/acp-session-plugins.test.ts +6 -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 +70 -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 +113 -0
- package/apps/studio/test/exporters/pdf.test.ts +33 -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 +54 -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-element-resize.tsx +3 -1
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/design.mjs +16 -0
- package/cli/commands/init.mjs +80 -3
- package/cli/commands/kg.mjs +368 -0
- package/cli/commands/kg.test.mjs +118 -0
- package/cli/lib/ddr-to-kgai.mjs +648 -0
- package/cli/lib/ddr-to-kgai.test.mjs +99 -0
- package/cli/lib/flow-design-integration.test.mjs +2 -2
- package/cli/lib/gitignore-block.mjs +4 -0
- package/cli/lib/plugin-name-namespace.test.mjs +71 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +4 -0
- package/plugins/flow/.claude-plugin/config.schema.json +66 -0
- package/plugins/flow/dependencies.json +17 -0
package/apps/studio/api.ts
CHANGED
|
@@ -12,8 +12,14 @@ import {
|
|
|
12
12
|
assembleCompSource,
|
|
13
13
|
CanvasEditError,
|
|
14
14
|
type ClipInfo,
|
|
15
|
+
type ConvertChildBox,
|
|
16
|
+
type ConvertContainerSpec,
|
|
17
|
+
componentMapForCanvas,
|
|
18
|
+
convertToAbsolute,
|
|
15
19
|
deleteArtboard,
|
|
16
20
|
deleteElement,
|
|
21
|
+
detachComponent,
|
|
22
|
+
duplicateArtboard,
|
|
17
23
|
duplicateElement,
|
|
18
24
|
type EditScope,
|
|
19
25
|
editArrayElementString,
|
|
@@ -442,6 +448,21 @@ export interface Api {
|
|
|
442
448
|
}): Promise<
|
|
443
449
|
{ ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
444
450
|
>;
|
|
451
|
+
/**
|
|
452
|
+
* feature-4 T8 (convert-to-absolute, DDR-188) — rewrite a container's stamped
|
|
453
|
+
* children to `position:absolute` with frozen boxes (+ container relative), in
|
|
454
|
+
* ONE whole-file write with ONE undo `seq`.
|
|
455
|
+
*/
|
|
456
|
+
convertChildrenToAbsoluteOp(input: {
|
|
457
|
+
canvas?: unknown;
|
|
458
|
+
containerId?: unknown;
|
|
459
|
+
containerIdIndex?: unknown;
|
|
460
|
+
containerSetRelative?: unknown;
|
|
461
|
+
allowShared?: unknown;
|
|
462
|
+
children?: unknown;
|
|
463
|
+
containers?: unknown;
|
|
464
|
+
dissolve?: unknown;
|
|
465
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
445
466
|
/**
|
|
446
467
|
* Insert a synthesized div/text/image relative to a reference element, OR —
|
|
447
468
|
* when the artboard has no element to anchor on yet — as a direct child of
|
|
@@ -469,6 +490,14 @@ export interface Api {
|
|
|
469
490
|
}): Promise<
|
|
470
491
|
{ ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
471
492
|
>;
|
|
493
|
+
/** Duplicate an artboard at a new width (feature-3-web-artboards T3). */
|
|
494
|
+
duplicateArtboardOp(input: {
|
|
495
|
+
canvas?: unknown;
|
|
496
|
+
artboardId?: unknown;
|
|
497
|
+
width?: unknown;
|
|
498
|
+
}): Promise<
|
|
499
|
+
{ ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
500
|
+
>;
|
|
472
501
|
/** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */
|
|
473
502
|
resizeArtboardOp(input: {
|
|
474
503
|
canvas?: unknown;
|
|
@@ -529,6 +558,21 @@ export interface Api {
|
|
|
529
558
|
id?: unknown;
|
|
530
559
|
rendered?: unknown;
|
|
531
560
|
}): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }>;
|
|
561
|
+
/** feature-4 T7a — Layers-panel component map (purple instance rows). */
|
|
562
|
+
componentMapOp(input: {
|
|
563
|
+
canvas?: unknown;
|
|
564
|
+
}): Promise<
|
|
565
|
+
| { ok: true; map: Record<string, { component: string; root: boolean; usages: number }> }
|
|
566
|
+
| { ok: false; status: number; error: string }
|
|
567
|
+
>;
|
|
568
|
+
/** feature-4 detach-component — clone the definition + repoint ONE usage. */
|
|
569
|
+
detachComponentOp(input: {
|
|
570
|
+
canvas?: unknown;
|
|
571
|
+
id?: unknown;
|
|
572
|
+
idIndex?: unknown;
|
|
573
|
+
}): Promise<
|
|
574
|
+
{ ok: true; detachedName: string; seq?: number } | { ok: false; status: number; error: string }
|
|
575
|
+
>;
|
|
532
576
|
// Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
|
|
533
577
|
// file content swap from the in-memory revert log — immune to the positional
|
|
534
578
|
// data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
|
|
@@ -1262,6 +1306,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1262
1306
|
return out;
|
|
1263
1307
|
}
|
|
1264
1308
|
|
|
1309
|
+
// feature-4 T7b — per-user LOCKED layer keys (`"<cdId>:<occurrenceIndex>"`).
|
|
1310
|
+
// Runtime state per DDR-115 (like the camera + overlays): view.json, never the
|
|
1311
|
+
// versioned `.meta.json`. Reachable from the untrusted canvas origin via the
|
|
1312
|
+
// same PATCH lane, so shape + count are bounded.
|
|
1313
|
+
const MAX_LOCKED_KEYS = 500;
|
|
1314
|
+
const LOCKED_KEY_RE = /^[\w-]{1,64}:\d{1,4}$/;
|
|
1315
|
+
|
|
1316
|
+
function normalizeLocked(v: unknown): string[] | null {
|
|
1317
|
+
if (!Array.isArray(v) || v.length > MAX_LOCKED_KEYS) return null;
|
|
1318
|
+
const out: string[] = [];
|
|
1319
|
+
for (const k of v) {
|
|
1320
|
+
if (typeof k !== 'string' || !LOCKED_KEY_RE.test(k)) return null;
|
|
1321
|
+
out.push(k);
|
|
1322
|
+
}
|
|
1323
|
+
return [...new Set(out)];
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1265
1326
|
/** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
|
|
1266
1327
|
* read half of the read-modify-write both `saveCanvasView` and
|
|
1267
1328
|
* `saveCanvasOverlays` need so writing one lane never clobbers the other. */
|
|
@@ -1281,6 +1342,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1281
1342
|
async function loadCanvasView(file: string): Promise<{
|
|
1282
1343
|
viewport?: { x: number; y: number; zoom: number };
|
|
1283
1344
|
overlays?: Record<string, boolean>;
|
|
1345
|
+
locked?: string[];
|
|
1284
1346
|
} | null> {
|
|
1285
1347
|
const viewAbs = canvasViewPath(file);
|
|
1286
1348
|
if (!viewAbs) return null;
|
|
@@ -1289,12 +1351,15 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1289
1351
|
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
1290
1352
|
const vp = normalizeViewport((obj as { viewport?: unknown }).viewport);
|
|
1291
1353
|
const ov = normalizeOverlays((obj as { overlays?: unknown }).overlays);
|
|
1354
|
+
const lk = normalizeLocked((obj as { locked?: unknown }).locked);
|
|
1292
1355
|
const result: {
|
|
1293
1356
|
viewport?: { x: number; y: number; zoom: number };
|
|
1294
1357
|
overlays?: Record<string, boolean>;
|
|
1358
|
+
locked?: string[];
|
|
1295
1359
|
} = {};
|
|
1296
1360
|
if (vp) result.viewport = vp;
|
|
1297
1361
|
if (ov && Object.keys(ov).length > 0) result.overlays = ov;
|
|
1362
|
+
if (lk && lk.length > 0) result.locked = lk;
|
|
1298
1363
|
return result;
|
|
1299
1364
|
}
|
|
1300
1365
|
return null;
|
|
@@ -1382,13 +1447,16 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1382
1447
|
// `viewport`; a versioned `.meta.json` should never carry one (sanitizer),
|
|
1383
1448
|
// but strip defensively before overlaying the real per-user value below.
|
|
1384
1449
|
obj.overlays = undefined;
|
|
1450
|
+
// feature-4 T7b — `locked` (per-user locked layer keys) is the same class.
|
|
1451
|
+
obj.locked = undefined;
|
|
1385
1452
|
const view = await loadCanvasView(file);
|
|
1386
1453
|
if (view?.viewport) obj.viewport = view.viewport;
|
|
1387
1454
|
if (view?.overlays) obj.overlays = view.overlays;
|
|
1455
|
+
if (view?.locked) obj.locked = view.locked;
|
|
1388
1456
|
// Preserve the historic contract: no meta AND no camera/overlays → null
|
|
1389
1457
|
// (GET → {}, PATCH-on-rejected-path → 404). A view-only canvas still
|
|
1390
1458
|
// returns its camera/overlays.
|
|
1391
|
-
if (!hadMeta && !view?.viewport && !view?.overlays) return null;
|
|
1459
|
+
if (!hadMeta && !view?.viewport && !view?.overlays && !view?.locked) return null;
|
|
1392
1460
|
return obj;
|
|
1393
1461
|
}
|
|
1394
1462
|
|
|
@@ -1462,6 +1530,32 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1462
1530
|
}
|
|
1463
1531
|
}
|
|
1464
1532
|
|
|
1533
|
+
// --- Per-user locked-layers lane (feature-4 T7b): locked → view file,
|
|
1534
|
+
// never the versioned meta. REPLACE semantics (the client sends the full
|
|
1535
|
+
// set — merge semantics would make unlocking impossible); `null` clears; an
|
|
1536
|
+
// invalid array is a silent no-op (normalizeLocked → null).
|
|
1537
|
+
if (patch.locked !== undefined) {
|
|
1538
|
+
const viewAbs = canvasViewPath(file);
|
|
1539
|
+
if (viewAbs) {
|
|
1540
|
+
try {
|
|
1541
|
+
const current = await readCanvasViewRaw(file);
|
|
1542
|
+
if (patch.locked === null) {
|
|
1543
|
+
const { locked: _drop, ...rest } = current;
|
|
1544
|
+
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1545
|
+
await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
|
|
1546
|
+
} else {
|
|
1547
|
+
const lk = normalizeLocked(patch.locked);
|
|
1548
|
+
if (lk !== null) {
|
|
1549
|
+
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1550
|
+
await Bun.write(viewAbs, `${JSON.stringify({ ...current, locked: lk }, null, 2)}\n`);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
} catch {
|
|
1554
|
+
/* best-effort */
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1465
1559
|
// --- Shared document lane: layout → versioned meta, stamps last_modified. ---
|
|
1466
1560
|
if (patch.layout !== undefined) {
|
|
1467
1561
|
let current: Record<string, unknown> = {};
|
|
@@ -2394,7 +2488,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2394
2488
|
}
|
|
2395
2489
|
|
|
2396
2490
|
// 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
|
|
2397
|
-
|
|
2491
|
+
// Pipeline-injected ids are 8-hex, but hand-AUTHORED `data-cd-id` attrs are
|
|
2492
|
+
// preserved verbatim by the transpile (canvas-pipeline.ts) and are equally
|
|
2493
|
+
// valid targets (dogfood 2026-07-20: `data-cd-id="wal-hero-nav"`). Accept
|
|
2494
|
+
// both — word chars + dashes, bounded; the AST walkers do exact matching, so
|
|
2495
|
+
// this is only a shape gate.
|
|
2496
|
+
const CD_ID_RE = /^[\w-]{1,64}$/;
|
|
2398
2497
|
|
|
2399
2498
|
// RC1 (rca/issue-canvas-hmr-optimistic-update-consistency) — every inline
|
|
2400
2499
|
// edit is USER-originated (inspector CSS knobs, inline text, attr panel, and
|
|
@@ -2934,6 +3033,229 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2934
3033
|
}
|
|
2935
3034
|
}
|
|
2936
3035
|
|
|
3036
|
+
/**
|
|
3037
|
+
* feature-4 T8 (convert-to-absolute, DDR-188) — the "Remove auto layout"
|
|
3038
|
+
* analogue. Rewrite every stamped child of a container to `position:absolute`
|
|
3039
|
+
* with the frozen box the canvas measured, and set the container
|
|
3040
|
+
* `position:relative` when static — ONE whole-file write, ONE undo `seq`. The
|
|
3041
|
+
* canvas iframe pre-filters to plain, globally-unique children (no unstamped /
|
|
3042
|
+
* repeated / component instances); the AST writer's `resolveUsageId` guard is
|
|
3043
|
+
* the server-side backstop for a shared-component usage.
|
|
3044
|
+
*/
|
|
3045
|
+
async function convertChildrenToAbsoluteOp(input: {
|
|
3046
|
+
canvas?: unknown;
|
|
3047
|
+
containerId?: unknown;
|
|
3048
|
+
containerIdIndex?: unknown;
|
|
3049
|
+
containerSetRelative?: unknown;
|
|
3050
|
+
allowShared?: unknown;
|
|
3051
|
+
children?: unknown;
|
|
3052
|
+
containers?: unknown;
|
|
3053
|
+
dissolve?: unknown;
|
|
3054
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
3055
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
3056
|
+
if (!r.ok) return r;
|
|
3057
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
3058
|
+
const num = (v: unknown): number | null =>
|
|
3059
|
+
typeof v === 'number' && Number.isFinite(v) && Math.abs(v) <= 1_000_000
|
|
3060
|
+
? Math.round(v)
|
|
3061
|
+
: null;
|
|
3062
|
+
// Simplifier fix (review fan-out, 2026-07-21) — reuse canvas-edit.ts's own
|
|
3063
|
+
// exported spec types instead of a locally re-declared shape, which had
|
|
3064
|
+
// drifted (missing `freezeSize?`, populated a few lines below via a spread
|
|
3065
|
+
// that silently bypassed excess-property checking).
|
|
3066
|
+
const parseChildren = (raw: unknown): ConvertChildBox[] | string => {
|
|
3067
|
+
if (!Array.isArray(raw) || raw.length === 0) return 'children required';
|
|
3068
|
+
const out: ConvertChildBox[] = [];
|
|
3069
|
+
for (const c of raw as Array<Record<string, unknown>>) {
|
|
3070
|
+
const id = typeof c?.id === 'string' ? c.id.trim() : '';
|
|
3071
|
+
if (!CD_ID_RE.test(id)) return 'invalid child data-cd-id';
|
|
3072
|
+
const left = num(c.left);
|
|
3073
|
+
const top = num(c.top);
|
|
3074
|
+
const width = num(c.width);
|
|
3075
|
+
const height = num(c.height);
|
|
3076
|
+
if (left === null || top === null || width === null || height === null) {
|
|
3077
|
+
return 'invalid child box (left/top/width/height)';
|
|
3078
|
+
}
|
|
3079
|
+
const idIndex = Number.isInteger(c.idIndex) ? (c.idIndex as number) : undefined;
|
|
3080
|
+
out.push({ id, idIndex, left, top, width, height });
|
|
3081
|
+
}
|
|
3082
|
+
return out;
|
|
3083
|
+
};
|
|
3084
|
+
|
|
3085
|
+
// feature-4 artboard-level convert (2026-07-19) — MULTI-container batch.
|
|
3086
|
+
// Each container: optional containerId (absent = the artboard-body root
|
|
3087
|
+
// level), a setRelative flag, and its children boxes. Total child count is
|
|
3088
|
+
// capped across the batch.
|
|
3089
|
+
let containersSpec: ConvertContainerSpec[] | undefined;
|
|
3090
|
+
if (input.containers !== undefined) {
|
|
3091
|
+
if (!Array.isArray(input.containers) || input.containers.length === 0) {
|
|
3092
|
+
return { ok: false, status: 400, error: 'invalid containers' };
|
|
3093
|
+
}
|
|
3094
|
+
if (input.containers.length > 200) {
|
|
3095
|
+
return { ok: false, status: 400, error: 'too many containers' };
|
|
3096
|
+
}
|
|
3097
|
+
containersSpec = [];
|
|
3098
|
+
let total = 0;
|
|
3099
|
+
for (const c of input.containers as Array<Record<string, unknown>>) {
|
|
3100
|
+
let containerId: string | undefined;
|
|
3101
|
+
if (c?.containerId !== undefined && c.containerId !== null) {
|
|
3102
|
+
const cid = typeof c.containerId === 'string' ? c.containerId.trim() : '';
|
|
3103
|
+
if (!CD_ID_RE.test(cid)) {
|
|
3104
|
+
return {
|
|
3105
|
+
ok: false,
|
|
3106
|
+
status: 400,
|
|
3107
|
+
error: `invalid container data-cd-id (${String(c.containerId).slice(0, 64)})`,
|
|
3108
|
+
};
|
|
3109
|
+
}
|
|
3110
|
+
containerId = cid;
|
|
3111
|
+
}
|
|
3112
|
+
const kids = parseChildren(c?.children);
|
|
3113
|
+
if (typeof kids === 'string') return { ok: false, status: 400, error: kids };
|
|
3114
|
+
total += kids.length;
|
|
3115
|
+
if (total > 500) return { ok: false, status: 400, error: 'too many children' };
|
|
3116
|
+
let freezeSize: { width: number; height: number } | undefined;
|
|
3117
|
+
if (c?.freezeSize && typeof c.freezeSize === 'object') {
|
|
3118
|
+
const fw = num((c.freezeSize as Record<string, unknown>).width);
|
|
3119
|
+
const fh = num((c.freezeSize as Record<string, unknown>).height);
|
|
3120
|
+
if (fw !== null && fh !== null) freezeSize = { width: fw, height: fh };
|
|
3121
|
+
}
|
|
3122
|
+
containersSpec.push({
|
|
3123
|
+
containerId,
|
|
3124
|
+
containerIdIndex: Number.isInteger(c?.containerIdIndex)
|
|
3125
|
+
? (c.containerIdIndex as number)
|
|
3126
|
+
: undefined,
|
|
3127
|
+
containerSetRelative: c?.containerSetRelative === true,
|
|
3128
|
+
...(freezeSize ? { freezeSize } : {}),
|
|
3129
|
+
children: kids,
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
// Legacy single-container shape (the element context-menu action).
|
|
3135
|
+
let containerId = '';
|
|
3136
|
+
let containerIdIndex: number | undefined;
|
|
3137
|
+
let containerSetRelative = false;
|
|
3138
|
+
let children: ConvertChildBox[] = [];
|
|
3139
|
+
if (!containersSpec) {
|
|
3140
|
+
containerId = typeof input.containerId === 'string' ? input.containerId.trim() : '';
|
|
3141
|
+
if (!CD_ID_RE.test(containerId)) {
|
|
3142
|
+
return { ok: false, status: 400, error: 'invalid container data-cd-id' };
|
|
3143
|
+
}
|
|
3144
|
+
containerIdIndex = Number.isInteger(input.containerIdIndex)
|
|
3145
|
+
? (input.containerIdIndex as number)
|
|
3146
|
+
: undefined;
|
|
3147
|
+
containerSetRelative = input.containerSetRelative === true;
|
|
3148
|
+
if (Array.isArray(input.children) && input.children.length > 500) {
|
|
3149
|
+
return { ok: false, status: 400, error: 'too many children' };
|
|
3150
|
+
}
|
|
3151
|
+
const kids = parseChildren(input.children);
|
|
3152
|
+
if (typeof kids === 'string') return { ok: false, status: 400, error: kids };
|
|
3153
|
+
children = kids;
|
|
3154
|
+
}
|
|
3155
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
3156
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
3157
|
+
try {
|
|
3158
|
+
const before = await Bun.file(r.abs).text();
|
|
3159
|
+
if (before.length > MAX_CANVAS_SOURCE) {
|
|
3160
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3161
|
+
return { ok: false, status: 413, error: 'canvas source too large' };
|
|
3162
|
+
}
|
|
3163
|
+
// feature-4 TRUE FLATTEN — validated dissolve list (batch shape only).
|
|
3164
|
+
let dissolve: string[] | undefined;
|
|
3165
|
+
if (input.dissolve !== undefined) {
|
|
3166
|
+
if (!Array.isArray(input.dissolve) || input.dissolve.length > 200) {
|
|
3167
|
+
return { ok: false, status: 400, error: 'invalid dissolve list' };
|
|
3168
|
+
}
|
|
3169
|
+
dissolve = [];
|
|
3170
|
+
for (const d of input.dissolve) {
|
|
3171
|
+
const id = typeof d === 'string' ? d.trim() : '';
|
|
3172
|
+
if (!CD_ID_RE.test(id)) {
|
|
3173
|
+
return { ok: false, status: 400, error: 'invalid dissolve data-cd-id' };
|
|
3174
|
+
}
|
|
3175
|
+
dissolve.push(id);
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
const res = await convertToAbsolute(
|
|
3179
|
+
r.abs,
|
|
3180
|
+
containersSpec
|
|
3181
|
+
? { allowShared: input.allowShared === true, containers: containersSpec, dissolve }
|
|
3182
|
+
: {
|
|
3183
|
+
containerId,
|
|
3184
|
+
containerIdIndex,
|
|
3185
|
+
containerSetRelative,
|
|
3186
|
+
allowShared: input.allowShared === true,
|
|
3187
|
+
children,
|
|
3188
|
+
}
|
|
3189
|
+
);
|
|
3190
|
+
const after = res.source;
|
|
3191
|
+
if (!res.changed || after === before) {
|
|
3192
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3193
|
+
return { ok: true };
|
|
3194
|
+
}
|
|
3195
|
+
try {
|
|
3196
|
+
await history.writeSnapshot(rel, before, 'pre-convert-absolute');
|
|
3197
|
+
} catch {
|
|
3198
|
+
/* snapshot best-effort */
|
|
3199
|
+
}
|
|
3200
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
3201
|
+
} catch (err) {
|
|
3202
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3203
|
+
return {
|
|
3204
|
+
ok: false,
|
|
3205
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
3206
|
+
error: err instanceof Error ? err.message : 'convert-to-absolute failed',
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
/**
|
|
3212
|
+
* feature-4 detach-component (2026-07-19) — clone the component definition
|
|
3213
|
+
* under a fresh name + repoint THIS usage at the clone, so subsequent edits
|
|
3214
|
+
* stay local to this instance. Whole-file undo seq (Stage-I lane).
|
|
3215
|
+
*/
|
|
3216
|
+
async function detachComponentOp(input: {
|
|
3217
|
+
canvas?: unknown;
|
|
3218
|
+
id?: unknown;
|
|
3219
|
+
idIndex?: unknown;
|
|
3220
|
+
}): Promise<
|
|
3221
|
+
{ ok: true; detachedName: string; seq?: number } | { ok: false; status: number; error: string }
|
|
3222
|
+
> {
|
|
3223
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
3224
|
+
if (!r.ok) return r;
|
|
3225
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
3226
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
3227
|
+
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
3228
|
+
const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
|
|
3229
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
3230
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
3231
|
+
try {
|
|
3232
|
+
const before = await Bun.file(r.abs).text();
|
|
3233
|
+
if (before.length > MAX_CANVAS_SOURCE) {
|
|
3234
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3235
|
+
return { ok: false, status: 413, error: 'canvas source too large to grow' };
|
|
3236
|
+
}
|
|
3237
|
+
const res = await detachComponent(r.abs, id, idIndex);
|
|
3238
|
+
const after = res.source;
|
|
3239
|
+
if (!res.changed || after === before) {
|
|
3240
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3241
|
+
return { ok: true, detachedName: res.detachedName };
|
|
3242
|
+
}
|
|
3243
|
+
try {
|
|
3244
|
+
await history.writeSnapshot(rel, before, 'pre-detach-component');
|
|
3245
|
+
} catch {
|
|
3246
|
+
/* snapshot best-effort */
|
|
3247
|
+
}
|
|
3248
|
+
return { ok: true, detachedName: res.detachedName, seq: logUndo(r.abs, before, after) };
|
|
3249
|
+
} catch (err) {
|
|
3250
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3251
|
+
return {
|
|
3252
|
+
ok: false,
|
|
3253
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
3254
|
+
error: err instanceof Error ? err.message : 'detach failed',
|
|
3255
|
+
};
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
2937
3259
|
/** Insert a new empty artboard (id/label/width/height) after the last one. */
|
|
2938
3260
|
async function insertArtboardOp(input: {
|
|
2939
3261
|
canvas?: unknown;
|
|
@@ -2989,6 +3311,59 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2989
3311
|
}
|
|
2990
3312
|
}
|
|
2991
3313
|
|
|
3314
|
+
/**
|
|
3315
|
+
* Duplicate an artboard at a new width (feature-3-web-artboards T3,
|
|
3316
|
+
* "Duplicate at width…") — clones id/label/width, everything else
|
|
3317
|
+
* (kind/guides/print/style/children) carries over verbatim.
|
|
3318
|
+
*/
|
|
3319
|
+
async function duplicateArtboardOp(input: {
|
|
3320
|
+
canvas?: unknown;
|
|
3321
|
+
artboardId?: unknown;
|
|
3322
|
+
width?: unknown;
|
|
3323
|
+
}): Promise<
|
|
3324
|
+
{ ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
3325
|
+
> {
|
|
3326
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
3327
|
+
if (!r.ok) return r;
|
|
3328
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
3329
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
|
|
3330
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
|
|
3331
|
+
return { ok: false, status: 400, error: 'invalid artboardId' };
|
|
3332
|
+
}
|
|
3333
|
+
const width = Number.isFinite(Number(input.width))
|
|
3334
|
+
? Math.max(64, Math.min(8192, Math.round(Number(input.width))))
|
|
3335
|
+
: 0;
|
|
3336
|
+
if (!width) return { ok: false, status: 400, error: 'width required' };
|
|
3337
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
3338
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
3339
|
+
try {
|
|
3340
|
+
const before = await Bun.file(r.abs).text();
|
|
3341
|
+
if (before.length > MAX_CANVAS_SOURCE) {
|
|
3342
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3343
|
+
return { ok: false, status: 413, error: 'canvas source too large to grow' };
|
|
3344
|
+
}
|
|
3345
|
+
const res = await duplicateArtboard(r.abs, artboardId, width);
|
|
3346
|
+
const after = await Bun.file(r.abs).text();
|
|
3347
|
+
if (after === before) {
|
|
3348
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3349
|
+
return { ok: true, artboardId: res.artboardId };
|
|
3350
|
+
}
|
|
3351
|
+
try {
|
|
3352
|
+
await history.writeSnapshot(rel, before, 'pre-duplicate-artboard');
|
|
3353
|
+
} catch {
|
|
3354
|
+
/* snapshot best-effort */
|
|
3355
|
+
}
|
|
3356
|
+
return { ok: true, artboardId: res.artboardId, seq: logUndo(r.abs, before, after) };
|
|
3357
|
+
} catch (err) {
|
|
3358
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3359
|
+
return {
|
|
3360
|
+
ok: false,
|
|
3361
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
3362
|
+
error: err instanceof Error ? err.message : 'duplicate-artboard failed',
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
|
|
2992
3367
|
/** Free-hand artboard resize (D4) — write width/height NUMERIC props (DDR-027). */
|
|
2993
3368
|
async function resizeArtboardOp(input: {
|
|
2994
3369
|
canvas?: unknown;
|
|
@@ -3473,6 +3848,42 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3473
3848
|
}
|
|
3474
3849
|
}
|
|
3475
3850
|
|
|
3851
|
+
/**
|
|
3852
|
+
* feature-4 T7a (layers purple instances) — the component map for the shell's
|
|
3853
|
+
* Layers panel: `{ [cdId]: { component, root, usages } }` for every element
|
|
3854
|
+
* that renders through an instantiated component. READ-only parse (same
|
|
3855
|
+
* posture as editScopeOp).
|
|
3856
|
+
*/
|
|
3857
|
+
async function componentMapOp(input: {
|
|
3858
|
+
canvas?: unknown;
|
|
3859
|
+
}): Promise<
|
|
3860
|
+
| { ok: true; map: Record<string, { component: string; root: boolean; usages: number }> }
|
|
3861
|
+
| { ok: false; status: number; error: string }
|
|
3862
|
+
> {
|
|
3863
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
3864
|
+
if (!r.ok) return r;
|
|
3865
|
+
try {
|
|
3866
|
+
const file = Bun.file(r.abs);
|
|
3867
|
+
// Security fix (review fan-out, 2026-07-21) — this route is AUTO-fetched
|
|
3868
|
+
// by the shell on every canvas/artboard switch (no user action), unlike
|
|
3869
|
+
// the sibling edit-scope route it mirrors. Per this repo's hub-push
|
|
3870
|
+
// threat model (DDR-060), a peer could otherwise cost a collaborator
|
|
3871
|
+
// CPU/memory the moment they merely open a pathologically large synced
|
|
3872
|
+
// canvas — cap it the same way the structural-write ops cap `before`.
|
|
3873
|
+
if (file.size > MAX_CANVAS_SOURCE) {
|
|
3874
|
+
return { ok: false, status: 413, error: 'canvas source too large' };
|
|
3875
|
+
}
|
|
3876
|
+
const source = await file.text();
|
|
3877
|
+
return { ok: true, map: componentMapForCanvas(r.abs, source) };
|
|
3878
|
+
} catch (err) {
|
|
3879
|
+
return {
|
|
3880
|
+
ok: false,
|
|
3881
|
+
status: 500,
|
|
3882
|
+
error: err instanceof Error ? err.message : 'component map failed',
|
|
3883
|
+
};
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3476
3887
|
async function toggleHideOp(input: {
|
|
3477
3888
|
canvas?: unknown;
|
|
3478
3889
|
stableId?: unknown;
|
|
@@ -4093,6 +4504,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4093
4504
|
compClips,
|
|
4094
4505
|
reorder,
|
|
4095
4506
|
retimeSequenceOp,
|
|
4507
|
+
convertChildrenToAbsoluteOp,
|
|
4508
|
+
detachComponentOp,
|
|
4096
4509
|
deleteElementOp,
|
|
4097
4510
|
insertElementOp,
|
|
4098
4511
|
insertArtboardOp,
|
|
@@ -4103,8 +4516,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4103
4516
|
setArtboardGuidesOp,
|
|
4104
4517
|
setArtboardPrintOp,
|
|
4105
4518
|
deleteArtboardOp,
|
|
4519
|
+
duplicateArtboardOp,
|
|
4106
4520
|
duplicateElementOp,
|
|
4107
4521
|
editScopeOp,
|
|
4522
|
+
componentMapOp,
|
|
4108
4523
|
reorderRevert,
|
|
4109
4524
|
buildIndexData,
|
|
4110
4525
|
buildSystemData,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|