@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/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,
|
|
@@ -34,7 +40,10 @@ import {
|
|
|
34
40
|
retimeSequence,
|
|
35
41
|
retimeSequenceByClip,
|
|
36
42
|
editText as runEditText,
|
|
43
|
+
setArtboardGuides,
|
|
37
44
|
setArtboardHug,
|
|
45
|
+
setArtboardKind,
|
|
46
|
+
setArtboardPrint,
|
|
38
47
|
setArtboardStyle,
|
|
39
48
|
toggleClipHidden,
|
|
40
49
|
} from './canvas-edit.ts';
|
|
@@ -47,6 +56,7 @@ import {
|
|
|
47
56
|
} from './generation/audio-library.ts';
|
|
48
57
|
import { createHistory } from './history.ts';
|
|
49
58
|
import { STICKERS_DIR } from './paths.ts';
|
|
59
|
+
import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
|
|
50
60
|
|
|
51
61
|
// Directories that never hold user-facing canvases. Exported so the
|
|
52
62
|
// external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
|
|
@@ -438,6 +448,21 @@ export interface Api {
|
|
|
438
448
|
}): Promise<
|
|
439
449
|
{ ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
440
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 }>;
|
|
441
466
|
/**
|
|
442
467
|
* Insert a synthesized div/text/image relative to a reference element, OR —
|
|
443
468
|
* when the artboard has no element to anchor on yet — as a direct child of
|
|
@@ -465,6 +490,14 @@ export interface Api {
|
|
|
465
490
|
}): Promise<
|
|
466
491
|
{ ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
|
|
467
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
|
+
>;
|
|
468
501
|
/** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */
|
|
469
502
|
resizeArtboardOp(input: {
|
|
470
503
|
canvas?: unknown;
|
|
@@ -488,6 +521,24 @@ export interface Api {
|
|
|
488
521
|
layout?: unknown;
|
|
489
522
|
gap?: unknown;
|
|
490
523
|
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
524
|
+
/** Kind-switch surfaces (T8) — context menu + Inspector picker. */
|
|
525
|
+
setArtboardKindOp(input: {
|
|
526
|
+
canvas?: unknown;
|
|
527
|
+
artboardId?: unknown;
|
|
528
|
+
kind?: unknown;
|
|
529
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
530
|
+
/** Generic layout guides (T5) — replace-whole-prop write. */
|
|
531
|
+
setArtboardGuidesOp(input: {
|
|
532
|
+
canvas?: unknown;
|
|
533
|
+
artboardId?: unknown;
|
|
534
|
+
guides?: unknown;
|
|
535
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
536
|
+
/** feature-2-print-artboards T2 — paper/orientation/bleed/margins, replace-whole-prop write. */
|
|
537
|
+
setArtboardPrintOp(input: {
|
|
538
|
+
canvas?: unknown;
|
|
539
|
+
artboardId?: unknown;
|
|
540
|
+
print?: unknown;
|
|
541
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
491
542
|
/** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */
|
|
492
543
|
duplicateElementOp(input: {
|
|
493
544
|
canvas?: unknown;
|
|
@@ -507,6 +558,21 @@ export interface Api {
|
|
|
507
558
|
id?: unknown;
|
|
508
559
|
rendered?: unknown;
|
|
509
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
|
+
>;
|
|
510
576
|
// Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
|
|
511
577
|
// file content swap from the in-memory revert log — immune to the positional
|
|
512
578
|
// data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
|
|
@@ -1214,16 +1280,87 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1214
1280
|
return path.join(paths.canvasStateDir, `${fileSlug(file)}.view.json`);
|
|
1215
1281
|
}
|
|
1216
1282
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1283
|
+
const MAX_OVERLAY_KEYS = 32;
|
|
1284
|
+
const MAX_OVERLAY_KEY_LEN = 64;
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Validate a candidate overlay-visibility bag — feature-1-artboard-kinds-
|
|
1288
|
+
* foundation T6. A flat string→boolean map (`{ guides: true }`); downstream
|
|
1289
|
+
* plans (print bleed, web breakpoints) add their own keys without a schema
|
|
1290
|
+
* change here. Reachable from the untrusted canvas origin (DDR-054) via the
|
|
1291
|
+
* same `/_api/canvas-meta` PATCH the viewport lane already uses, so it's
|
|
1292
|
+
* capped the same way `set-artboard-style` caps its patch shape — bounded
|
|
1293
|
+
* key COUNT and key LENGTH, not just type. Empty object is valid (an
|
|
1294
|
+
* explicit "nothing on" state); malformed input is null (silent no-op).
|
|
1295
|
+
*/
|
|
1296
|
+
function normalizeOverlays(v: unknown): Record<string, boolean> | null {
|
|
1297
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
1298
|
+
const entries = Object.entries(v as Record<string, unknown>);
|
|
1299
|
+
if (entries.length > MAX_OVERLAY_KEYS) return null;
|
|
1300
|
+
const out: Record<string, boolean> = {};
|
|
1301
|
+
for (const [k, val] of entries) {
|
|
1302
|
+
if (k.length === 0 || k.length > MAX_OVERLAY_KEY_LEN) return null;
|
|
1303
|
+
if (typeof val !== 'boolean') return null;
|
|
1304
|
+
out[k] = val;
|
|
1305
|
+
}
|
|
1306
|
+
return out;
|
|
1307
|
+
}
|
|
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
|
+
|
|
1326
|
+
/** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
|
|
1327
|
+
* read half of the read-modify-write both `saveCanvasView` and
|
|
1328
|
+
* `saveCanvasOverlays` need so writing one lane never clobbers the other. */
|
|
1329
|
+
async function readCanvasViewRaw(file: string): Promise<Record<string, unknown>> {
|
|
1330
|
+
const viewAbs = canvasViewPath(file);
|
|
1331
|
+
if (!viewAbs) return {};
|
|
1332
|
+
try {
|
|
1333
|
+
const obj = JSON.parse(await Bun.file(viewAbs).text());
|
|
1334
|
+
return obj && typeof obj === 'object' && !Array.isArray(obj)
|
|
1335
|
+
? (obj as Record<string, unknown>)
|
|
1336
|
+
: {};
|
|
1337
|
+
} catch {
|
|
1338
|
+
return {};
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function loadCanvasView(file: string): Promise<{
|
|
1343
|
+
viewport?: { x: number; y: number; zoom: number };
|
|
1344
|
+
overlays?: Record<string, boolean>;
|
|
1345
|
+
locked?: string[];
|
|
1346
|
+
} | null> {
|
|
1220
1347
|
const viewAbs = canvasViewPath(file);
|
|
1221
1348
|
if (!viewAbs) return null;
|
|
1222
1349
|
try {
|
|
1223
1350
|
const obj = JSON.parse(await Bun.file(viewAbs).text());
|
|
1224
1351
|
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
1225
1352
|
const vp = normalizeViewport((obj as { viewport?: unknown }).viewport);
|
|
1226
|
-
|
|
1353
|
+
const ov = normalizeOverlays((obj as { overlays?: unknown }).overlays);
|
|
1354
|
+
const lk = normalizeLocked((obj as { locked?: unknown }).locked);
|
|
1355
|
+
const result: {
|
|
1356
|
+
viewport?: { x: number; y: number; zoom: number };
|
|
1357
|
+
overlays?: Record<string, boolean>;
|
|
1358
|
+
locked?: string[];
|
|
1359
|
+
} = {};
|
|
1360
|
+
if (vp) result.viewport = vp;
|
|
1361
|
+
if (ov && Object.keys(ov).length > 0) result.overlays = ov;
|
|
1362
|
+
if (lk && lk.length > 0) result.locked = lk;
|
|
1363
|
+
return result;
|
|
1227
1364
|
}
|
|
1228
1365
|
return null;
|
|
1229
1366
|
} catch {
|
|
@@ -1233,7 +1370,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1233
1370
|
|
|
1234
1371
|
/** Persist the per-user camera. Validates + clamps; best-effort (mkdir the
|
|
1235
1372
|
* bucket if absent). Returns the normalized viewport on write, null when the
|
|
1236
|
-
* path is rejected or the viewport is invalid (no write).
|
|
1373
|
+
* path is rejected or the viewport is invalid (no write). Read-modify-write
|
|
1374
|
+
* so an existing `overlays` key in the same view file survives. */
|
|
1237
1375
|
async function saveCanvasView(
|
|
1238
1376
|
file: string,
|
|
1239
1377
|
viewport: unknown
|
|
@@ -1244,13 +1382,44 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1244
1382
|
if (!vp) return null;
|
|
1245
1383
|
try {
|
|
1246
1384
|
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1247
|
-
|
|
1385
|
+
const current = await readCanvasViewRaw(file);
|
|
1386
|
+
await Bun.write(viewAbs, `${JSON.stringify({ ...current, viewport: vp }, null, 2)}\n`);
|
|
1248
1387
|
return vp;
|
|
1249
1388
|
} catch {
|
|
1250
1389
|
return null;
|
|
1251
1390
|
}
|
|
1252
1391
|
}
|
|
1253
1392
|
|
|
1393
|
+
/**
|
|
1394
|
+
* Persist the per-user overlay-visibility bag (T6). Shallow-merges into
|
|
1395
|
+
* whatever `overlays` the view file already has — a `{ guides: true }` patch
|
|
1396
|
+
* doesn't erase a `bleed` key a downstream print-plan toggle already set.
|
|
1397
|
+
* Never touches the versioned `.meta.json`. Read-modify-write so an existing
|
|
1398
|
+
* `viewport` key survives.
|
|
1399
|
+
*/
|
|
1400
|
+
async function saveCanvasOverlays(
|
|
1401
|
+
file: string,
|
|
1402
|
+
overlays: unknown
|
|
1403
|
+
): Promise<Record<string, boolean> | null> {
|
|
1404
|
+
const viewAbs = canvasViewPath(file);
|
|
1405
|
+
if (!viewAbs) return null;
|
|
1406
|
+
const ov = normalizeOverlays(overlays);
|
|
1407
|
+
if (ov === null) return null;
|
|
1408
|
+
try {
|
|
1409
|
+
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1410
|
+
const current = await readCanvasViewRaw(file);
|
|
1411
|
+
const prevOverlays =
|
|
1412
|
+
current.overlays && typeof current.overlays === 'object' && !Array.isArray(current.overlays)
|
|
1413
|
+
? (current.overlays as Record<string, unknown>)
|
|
1414
|
+
: {};
|
|
1415
|
+
const merged = normalizeOverlays({ ...prevOverlays, ...ov }) ?? ov;
|
|
1416
|
+
await Bun.write(viewAbs, `${JSON.stringify({ ...current, overlays: merged }, null, 2)}\n`);
|
|
1417
|
+
return merged;
|
|
1418
|
+
} catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1254
1423
|
async function loadCanvasMeta(file: string): Promise<Record<string, unknown> | null> {
|
|
1255
1424
|
const metaAbs = canvasMetaPath(file);
|
|
1256
1425
|
if (!metaAbs) return null;
|
|
@@ -1274,11 +1443,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1274
1443
|
// codebase convention + biome's noDelete.)
|
|
1275
1444
|
obj.viewport = undefined;
|
|
1276
1445
|
obj.last_modified = undefined;
|
|
1446
|
+
// T6 — `overlays` (per-user guide/mark visibility) is runtime state same as
|
|
1447
|
+
// `viewport`; a versioned `.meta.json` should never carry one (sanitizer),
|
|
1448
|
+
// but strip defensively before overlaying the real per-user value below.
|
|
1449
|
+
obj.overlays = undefined;
|
|
1450
|
+
// feature-4 T7b — `locked` (per-user locked layer keys) is the same class.
|
|
1451
|
+
obj.locked = undefined;
|
|
1277
1452
|
const view = await loadCanvasView(file);
|
|
1278
1453
|
if (view?.viewport) obj.viewport = view.viewport;
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1454
|
+
if (view?.overlays) obj.overlays = view.overlays;
|
|
1455
|
+
if (view?.locked) obj.locked = view.locked;
|
|
1456
|
+
// Preserve the historic contract: no meta AND no camera/overlays → null
|
|
1457
|
+
// (GET → {}, PATCH-on-rejected-path → 404). A view-only canvas still
|
|
1458
|
+
// returns its camera/overlays.
|
|
1459
|
+
if (!hadMeta && !view?.viewport && !view?.overlays && !view?.locked) return null;
|
|
1282
1460
|
return obj;
|
|
1283
1461
|
}
|
|
1284
1462
|
|
|
@@ -1331,6 +1509,53 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1331
1509
|
}
|
|
1332
1510
|
}
|
|
1333
1511
|
|
|
1512
|
+
// --- Per-user overlay-visibility lane (T6): overlays → view file, never
|
|
1513
|
+
// the versioned meta. Same shape as the viewport lane above — `null`
|
|
1514
|
+
// clears, an invalid bag is a silent no-op (normalizeOverlays → null).
|
|
1515
|
+
if (patch.overlays !== undefined) {
|
|
1516
|
+
if (patch.overlays === null) {
|
|
1517
|
+
const viewAbs = canvasViewPath(file);
|
|
1518
|
+
if (viewAbs) {
|
|
1519
|
+
try {
|
|
1520
|
+
const current = await readCanvasViewRaw(file);
|
|
1521
|
+
const { overlays: _drop, ...rest } = current;
|
|
1522
|
+
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1523
|
+
await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
|
|
1524
|
+
} catch {
|
|
1525
|
+
/* best-effort clear */
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
} else {
|
|
1529
|
+
await saveCanvasOverlays(file, patch.overlays);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
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
|
+
|
|
1334
1559
|
// --- Shared document lane: layout → versioned meta, stamps last_modified. ---
|
|
1335
1560
|
if (patch.layout !== undefined) {
|
|
1336
1561
|
let current: Record<string, unknown> = {};
|
|
@@ -2263,7 +2488,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2263
2488
|
}
|
|
2264
2489
|
|
|
2265
2490
|
// 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
|
|
2266
|
-
|
|
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}$/;
|
|
2267
2497
|
|
|
2268
2498
|
// RC1 (rca/issue-canvas-hmr-optimistic-update-consistency) — every inline
|
|
2269
2499
|
// edit is USER-originated (inspector CSS knobs, inline text, attr panel, and
|
|
@@ -2803,6 +3033,229 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2803
3033
|
}
|
|
2804
3034
|
}
|
|
2805
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
|
+
|
|
2806
3259
|
/** Insert a new empty artboard (id/label/width/height) after the last one. */
|
|
2807
3260
|
async function insertArtboardOp(input: {
|
|
2808
3261
|
canvas?: unknown;
|
|
@@ -2858,6 +3311,59 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2858
3311
|
}
|
|
2859
3312
|
}
|
|
2860
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
|
+
|
|
2861
3367
|
/** Free-hand artboard resize (D4) — write width/height NUMERIC props (DDR-027). */
|
|
2862
3368
|
async function resizeArtboardOp(input: {
|
|
2863
3369
|
canvas?: unknown;
|
|
@@ -2952,6 +3458,193 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2952
3458
|
}
|
|
2953
3459
|
}
|
|
2954
3460
|
|
|
3461
|
+
const ARTBOARD_KIND_VALUES = new Set(['digital', 'print', 'web', 'video']);
|
|
3462
|
+
|
|
3463
|
+
/** Kind-switch surfaces (T8): context menu + Inspector picker. */
|
|
3464
|
+
async function setArtboardKindOp(input: {
|
|
3465
|
+
canvas?: unknown;
|
|
3466
|
+
artboardId?: unknown;
|
|
3467
|
+
kind?: unknown;
|
|
3468
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
3469
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
3470
|
+
if (!r.ok) return r;
|
|
3471
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
3472
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
|
|
3473
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
|
|
3474
|
+
return { ok: false, status: 400, error: 'invalid artboard id' };
|
|
3475
|
+
}
|
|
3476
|
+
let kind: string | null;
|
|
3477
|
+
if (input.kind === null) {
|
|
3478
|
+
kind = null;
|
|
3479
|
+
} else if (typeof input.kind === 'string' && ARTBOARD_KIND_VALUES.has(input.kind)) {
|
|
3480
|
+
kind = input.kind;
|
|
3481
|
+
} else {
|
|
3482
|
+
return { ok: false, status: 400, error: 'invalid kind' };
|
|
3483
|
+
}
|
|
3484
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
3485
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
3486
|
+
try {
|
|
3487
|
+
const before = await Bun.file(r.abs).text();
|
|
3488
|
+
await setArtboardKind(r.abs, artboardId, kind);
|
|
3489
|
+
const after = await Bun.file(r.abs).text();
|
|
3490
|
+
if (after === before) {
|
|
3491
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3492
|
+
return { ok: true };
|
|
3493
|
+
}
|
|
3494
|
+
try {
|
|
3495
|
+
await history.writeSnapshot(rel, before, 'pre-set-artboard-kind');
|
|
3496
|
+
} catch {
|
|
3497
|
+
/* snapshot best-effort */
|
|
3498
|
+
}
|
|
3499
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
3500
|
+
} catch (err) {
|
|
3501
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3502
|
+
return {
|
|
3503
|
+
ok: false,
|
|
3504
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
3505
|
+
error: err instanceof Error ? err.message : 'set-artboard-kind failed',
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
const MAX_GUIDES_JSON_BYTES = 4096;
|
|
3511
|
+
|
|
3512
|
+
/** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
|
|
3513
|
+
* never a deep merge (see applySetArtboardGuides's own doc comment). */
|
|
3514
|
+
async function setArtboardGuidesOp(input: {
|
|
3515
|
+
canvas?: unknown;
|
|
3516
|
+
artboardId?: unknown;
|
|
3517
|
+
guides?: unknown;
|
|
3518
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
3519
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
3520
|
+
if (!r.ok) return r;
|
|
3521
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
3522
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
|
|
3523
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
|
|
3524
|
+
return { ok: false, status: 400, error: 'invalid artboard id' };
|
|
3525
|
+
}
|
|
3526
|
+
let guides: Record<string, unknown> | null;
|
|
3527
|
+
if (input.guides === null) {
|
|
3528
|
+
guides = null;
|
|
3529
|
+
} else if (
|
|
3530
|
+
input.guides &&
|
|
3531
|
+
typeof input.guides === 'object' &&
|
|
3532
|
+
!Array.isArray(input.guides) &&
|
|
3533
|
+
JSON.stringify(input.guides).length <= MAX_GUIDES_JSON_BYTES
|
|
3534
|
+
) {
|
|
3535
|
+
guides = input.guides as Record<string, unknown>;
|
|
3536
|
+
} else {
|
|
3537
|
+
return { ok: false, status: 400, error: 'invalid guides' };
|
|
3538
|
+
}
|
|
3539
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
3540
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
3541
|
+
try {
|
|
3542
|
+
const before = await Bun.file(r.abs).text();
|
|
3543
|
+
await setArtboardGuides(r.abs, artboardId, guides);
|
|
3544
|
+
const after = await Bun.file(r.abs).text();
|
|
3545
|
+
if (after === before) {
|
|
3546
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3547
|
+
return { ok: true };
|
|
3548
|
+
}
|
|
3549
|
+
try {
|
|
3550
|
+
await history.writeSnapshot(rel, before, 'pre-set-artboard-guides');
|
|
3551
|
+
} catch {
|
|
3552
|
+
/* snapshot best-effort */
|
|
3553
|
+
}
|
|
3554
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
3555
|
+
} catch (err) {
|
|
3556
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3557
|
+
return {
|
|
3558
|
+
ok: false,
|
|
3559
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
3560
|
+
error: err instanceof Error ? err.message : 'set-artboard-guides failed',
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
const PRINT_ORIENTATION_VALUES = new Set(['portrait', 'landscape']);
|
|
3566
|
+
|
|
3567
|
+
function isFiniteMmOrUndefined(v: unknown): v is number | undefined {
|
|
3568
|
+
return (
|
|
3569
|
+
v === undefined ||
|
|
3570
|
+
(typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_PRINT_MM)
|
|
3571
|
+
);
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
/**
|
|
3575
|
+
* feature-2-print-artboards T2 — paper/orientation/bleed/margins. Replace-
|
|
3576
|
+
* whole-prop (see applySetArtboardPrint's own doc comment) — the caller
|
|
3577
|
+
* sends the full merged object, not a delta. `print: null` clears the
|
|
3578
|
+
* prop. Shape validation lives HERE (canvas-edit.ts stays generic
|
|
3579
|
+
* Record<string, unknown> AST surgery, same split as setArtboardGuidesOp).
|
|
3580
|
+
*/
|
|
3581
|
+
async function setArtboardPrintOp(input: {
|
|
3582
|
+
canvas?: unknown;
|
|
3583
|
+
artboardId?: unknown;
|
|
3584
|
+
print?: unknown;
|
|
3585
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
3586
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
3587
|
+
if (!r.ok) return r;
|
|
3588
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
3589
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
|
|
3590
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
|
|
3591
|
+
return { ok: false, status: 400, error: 'invalid artboard id' };
|
|
3592
|
+
}
|
|
3593
|
+
let print: Record<string, unknown> | null;
|
|
3594
|
+
if (input.print === null) {
|
|
3595
|
+
print = null;
|
|
3596
|
+
} else if (input.print && typeof input.print === 'object' && !Array.isArray(input.print)) {
|
|
3597
|
+
const p = input.print as Record<string, unknown>;
|
|
3598
|
+
if (typeof p.paper !== 'string' || !getPaperPreset(p.paper)) {
|
|
3599
|
+
return { ok: false, status: 400, error: 'invalid or unknown paper preset' };
|
|
3600
|
+
}
|
|
3601
|
+
if (p.orientation !== undefined && !PRINT_ORIENTATION_VALUES.has(p.orientation as string)) {
|
|
3602
|
+
return { ok: false, status: 400, error: 'invalid orientation' };
|
|
3603
|
+
}
|
|
3604
|
+
if (!isFiniteMmOrUndefined(p.bleedMm)) {
|
|
3605
|
+
return { ok: false, status: 400, error: 'invalid bleedMm' };
|
|
3606
|
+
}
|
|
3607
|
+
if (p.marginsMm !== undefined) {
|
|
3608
|
+
if (typeof p.marginsMm !== 'object' || p.marginsMm === null || Array.isArray(p.marginsMm)) {
|
|
3609
|
+
return { ok: false, status: 400, error: 'invalid marginsMm' };
|
|
3610
|
+
}
|
|
3611
|
+
const m = p.marginsMm as Record<string, unknown>;
|
|
3612
|
+
for (const side of ['top', 'right', 'bottom', 'left']) {
|
|
3613
|
+
if (!isFiniteMmOrUndefined(m[side])) {
|
|
3614
|
+
return { ok: false, status: 400, error: `invalid marginsMm.${side}` };
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
print = p;
|
|
3619
|
+
} else {
|
|
3620
|
+
return { ok: false, status: 400, error: 'invalid print' };
|
|
3621
|
+
}
|
|
3622
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
3623
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
3624
|
+
try {
|
|
3625
|
+
const before = await Bun.file(r.abs).text();
|
|
3626
|
+
await setArtboardPrint(r.abs, artboardId, print);
|
|
3627
|
+
const after = await Bun.file(r.abs).text();
|
|
3628
|
+
if (after === before) {
|
|
3629
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3630
|
+
return { ok: true };
|
|
3631
|
+
}
|
|
3632
|
+
try {
|
|
3633
|
+
await history.writeSnapshot(rel, before, 'pre-set-artboard-print');
|
|
3634
|
+
} catch {
|
|
3635
|
+
/* snapshot best-effort */
|
|
3636
|
+
}
|
|
3637
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
3638
|
+
} catch (err) {
|
|
3639
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
3640
|
+
return {
|
|
3641
|
+
ok: false,
|
|
3642
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
3643
|
+
error: err instanceof Error ? err.message : 'set-artboard-print failed',
|
|
3644
|
+
};
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
|
|
2955
3648
|
const ARTBOARD_LAYOUT_VALUES = new Set(['block', 'flex-col', 'flex-row', 'grid']);
|
|
2956
3649
|
|
|
2957
3650
|
/** Set artboard "more settings" — background / padding / layout / gap. */
|
|
@@ -2972,9 +3665,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2972
3665
|
}
|
|
2973
3666
|
const patch: {
|
|
2974
3667
|
background?: string | null;
|
|
2975
|
-
padding?: number | null;
|
|
3668
|
+
padding?: number | string | null;
|
|
2976
3669
|
layout?: string | null;
|
|
2977
|
-
gap?: number | null;
|
|
3670
|
+
gap?: number | string | null;
|
|
2978
3671
|
} = {};
|
|
2979
3672
|
if ('background' in input) {
|
|
2980
3673
|
if (input.background === null) patch.background = null;
|
|
@@ -2992,19 +3685,28 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2992
3685
|
return { ok: false, status: 400, error: 'invalid layout' };
|
|
2993
3686
|
}
|
|
2994
3687
|
}
|
|
2995
|
-
|
|
3688
|
+
// Dogfood (artboard panel ↔ shared inspector controls) — padding/gap
|
|
3689
|
+
// accept a design-token binding alongside raw px. STRICT single-var shape
|
|
3690
|
+
// (not a free-form CSS string like `background`): the value lands in a
|
|
3691
|
+
// JSX prop AND in `.dc-artboard-body`'s inline style, so anything beyond
|
|
3692
|
+
// one `var(--…)` reference is rejected rather than round-tripped.
|
|
3693
|
+
const BOX_TOKEN_RE = /^var\(--[a-zA-Z0-9_-]{1,64}\)$/;
|
|
3694
|
+
const clampBoxDim = (v: unknown): number | string | null | undefined => {
|
|
2996
3695
|
if (v === null) return null;
|
|
2997
3696
|
if (v === undefined) return undefined;
|
|
3697
|
+
if (typeof v === 'string' && BOX_TOKEN_RE.test(v)) return v;
|
|
2998
3698
|
return Number.isFinite(Number(v)) ? Math.max(0, Math.min(512, Math.round(Number(v)))) : NaN;
|
|
2999
3699
|
};
|
|
3000
3700
|
if ('padding' in input) {
|
|
3001
3701
|
const p = clampBoxDim(input.padding);
|
|
3002
|
-
if (Number.isNaN(p))
|
|
3702
|
+
if (typeof p === 'number' && Number.isNaN(p))
|
|
3703
|
+
return { ok: false, status: 400, error: 'invalid padding' };
|
|
3003
3704
|
patch.padding = p;
|
|
3004
3705
|
}
|
|
3005
3706
|
if ('gap' in input) {
|
|
3006
3707
|
const g = clampBoxDim(input.gap);
|
|
3007
|
-
if (Number.isNaN(g))
|
|
3708
|
+
if (typeof g === 'number' && Number.isNaN(g))
|
|
3709
|
+
return { ok: false, status: 400, error: 'invalid gap' };
|
|
3008
3710
|
patch.gap = g;
|
|
3009
3711
|
}
|
|
3010
3712
|
if (Object.keys(patch).length === 0) {
|
|
@@ -3146,6 +3848,42 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3146
3848
|
}
|
|
3147
3849
|
}
|
|
3148
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
|
+
|
|
3149
3887
|
async function toggleHideOp(input: {
|
|
3150
3888
|
canvas?: unknown;
|
|
3151
3889
|
stableId?: unknown;
|
|
@@ -3766,15 +4504,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3766
4504
|
compClips,
|
|
3767
4505
|
reorder,
|
|
3768
4506
|
retimeSequenceOp,
|
|
4507
|
+
convertChildrenToAbsoluteOp,
|
|
4508
|
+
detachComponentOp,
|
|
3769
4509
|
deleteElementOp,
|
|
3770
4510
|
insertElementOp,
|
|
3771
4511
|
insertArtboardOp,
|
|
3772
4512
|
resizeArtboardOp,
|
|
3773
4513
|
setArtboardHugOp,
|
|
3774
4514
|
setArtboardStyleOp,
|
|
4515
|
+
setArtboardKindOp,
|
|
4516
|
+
setArtboardGuidesOp,
|
|
4517
|
+
setArtboardPrintOp,
|
|
3775
4518
|
deleteArtboardOp,
|
|
4519
|
+
duplicateArtboardOp,
|
|
3776
4520
|
duplicateElementOp,
|
|
3777
4521
|
editScopeOp,
|
|
4522
|
+
componentMapOp,
|
|
3778
4523
|
reorderRevert,
|
|
3779
4524
|
buildIndexData,
|
|
3780
4525
|
buildSystemData,
|