@1agh/maude 0.45.1 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/apps/studio/acp/bridge.ts +628 -74
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/annotations-snap.ts +50 -10
  6. package/apps/studio/api.ts +344 -14
  7. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  8. package/apps/studio/bin/_import-asset.mjs +35 -5
  9. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  10. package/apps/studio/bin/_png-playwright.mjs +38 -4
  11. package/apps/studio/bin/_pw-launch.mjs +52 -0
  12. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  13. package/apps/studio/bin/_smart-frames.mjs +419 -0
  14. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  15. package/apps/studio/bin/smart-frames.sh +30 -0
  16. package/apps/studio/canvas-edit.ts +302 -3
  17. package/apps/studio/canvas-lib.tsx +203 -11
  18. package/apps/studio/canvas-shell.tsx +167 -28
  19. package/apps/studio/client/app.jsx +678 -176
  20. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  21. package/apps/studio/client/panels/ChatPanel.jsx +846 -133
  22. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  23. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  24. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  25. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  26. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  27. package/apps/studio/client/panels/acp-capabilities.js +82 -0
  28. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  29. package/apps/studio/client/panels/acp-runtime.js +248 -9
  30. package/apps/studio/client/panels/acp-usage.js +65 -0
  31. package/apps/studio/client/panels/transcript-view.js +36 -0
  32. package/apps/studio/client/styles/6-acp-chat.css +689 -11
  33. package/apps/studio/dist/client.bundle.js +1738 -1736
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/generation/whisper-models.test.ts +28 -1
  44. package/apps/studio/generation/whisper-models.ts +22 -7
  45. package/apps/studio/http.ts +246 -9
  46. package/apps/studio/print/marks.ts +113 -0
  47. package/apps/studio/print/units.ts +269 -0
  48. package/apps/studio/print-overlay-content.tsx +132 -0
  49. package/apps/studio/test/acp-bridge.test.ts +11 -6
  50. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  51. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  52. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  53. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  54. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  56. package/apps/studio/test/acp-permission.test.ts +262 -0
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  58. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  59. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  60. package/apps/studio/test/acp-transcript.test.ts +75 -2
  61. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  62. package/apps/studio/test/acp-usage.test.ts +143 -0
  63. package/apps/studio/test/annotations-snap.test.ts +56 -0
  64. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  65. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  66. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  67. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  68. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  69. package/apps/studio/test/exporters/png.test.ts +49 -1
  70. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  73. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  74. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  75. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  76. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  77. package/apps/studio/test/import-asset.test.ts +31 -3
  78. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  79. package/apps/studio/test/print-marks.test.ts +113 -0
  80. package/apps/studio/test/print-units.test.ts +173 -0
  81. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  82. package/apps/studio/use-chrome-visibility.tsx +19 -0
  83. package/apps/studio/use-element-resize.tsx +21 -2
  84. package/apps/studio/use-snap-guides.tsx +73 -5
  85. package/apps/studio/use-spacing-handles.tsx +9 -5
  86. package/apps/studio/whats-new.json +62 -0
  87. package/cli/commands/design.mjs +116 -3
  88. package/cli/lib/pkg-root.mjs +42 -10
  89. package/cli/lib/pkg-root.test.mjs +33 -1
  90. package/package.json +11 -9
  91. package/plugins/design/dependencies.json +35 -0
@@ -34,7 +34,10 @@ import {
34
34
  retimeSequence,
35
35
  retimeSequenceByClip,
36
36
  editText as runEditText,
37
+ setArtboardGuides,
37
38
  setArtboardHug,
39
+ setArtboardKind,
40
+ setArtboardPrint,
38
41
  setArtboardStyle,
39
42
  toggleClipHidden,
40
43
  } from './canvas-edit.ts';
@@ -47,6 +50,7 @@ import {
47
50
  } from './generation/audio-library.ts';
48
51
  import { createHistory } from './history.ts';
49
52
  import { STICKERS_DIR } from './paths.ts';
53
+ import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
50
54
 
51
55
  // Directories that never hold user-facing canvases. Exported so the
52
56
  // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
@@ -488,6 +492,24 @@ export interface Api {
488
492
  layout?: unknown;
489
493
  gap?: unknown;
490
494
  }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
495
+ /** Kind-switch surfaces (T8) — context menu + Inspector picker. */
496
+ setArtboardKindOp(input: {
497
+ canvas?: unknown;
498
+ artboardId?: unknown;
499
+ kind?: unknown;
500
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
501
+ /** Generic layout guides (T5) — replace-whole-prop write. */
502
+ setArtboardGuidesOp(input: {
503
+ canvas?: unknown;
504
+ artboardId?: unknown;
505
+ guides?: unknown;
506
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
507
+ /** feature-2-print-artboards T2 — paper/orientation/bleed/margins, replace-whole-prop write. */
508
+ setArtboardPrintOp(input: {
509
+ canvas?: unknown;
510
+ artboardId?: unknown;
511
+ print?: unknown;
512
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
491
513
  /** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */
492
514
  duplicateElementOp(input: {
493
515
  canvas?: unknown;
@@ -1214,16 +1236,66 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1214
1236
  return path.join(paths.canvasStateDir, `${fileSlug(file)}.view.json`);
1215
1237
  }
1216
1238
 
1217
- async function loadCanvasView(
1218
- file: string
1219
- ): Promise<{ viewport?: { x: number; y: number; zoom: number } } | null> {
1239
+ const MAX_OVERLAY_KEYS = 32;
1240
+ const MAX_OVERLAY_KEY_LEN = 64;
1241
+
1242
+ /**
1243
+ * Validate a candidate overlay-visibility bag — feature-1-artboard-kinds-
1244
+ * foundation T6. A flat string→boolean map (`{ guides: true }`); downstream
1245
+ * plans (print bleed, web breakpoints) add their own keys without a schema
1246
+ * change here. Reachable from the untrusted canvas origin (DDR-054) via the
1247
+ * same `/_api/canvas-meta` PATCH the viewport lane already uses, so it's
1248
+ * capped the same way `set-artboard-style` caps its patch shape — bounded
1249
+ * key COUNT and key LENGTH, not just type. Empty object is valid (an
1250
+ * explicit "nothing on" state); malformed input is null (silent no-op).
1251
+ */
1252
+ function normalizeOverlays(v: unknown): Record<string, boolean> | null {
1253
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
1254
+ const entries = Object.entries(v as Record<string, unknown>);
1255
+ if (entries.length > MAX_OVERLAY_KEYS) return null;
1256
+ const out: Record<string, boolean> = {};
1257
+ for (const [k, val] of entries) {
1258
+ if (k.length === 0 || k.length > MAX_OVERLAY_KEY_LEN) return null;
1259
+ if (typeof val !== 'boolean') return null;
1260
+ out[k] = val;
1261
+ }
1262
+ return out;
1263
+ }
1264
+
1265
+ /** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
1266
+ * read half of the read-modify-write both `saveCanvasView` and
1267
+ * `saveCanvasOverlays` need so writing one lane never clobbers the other. */
1268
+ async function readCanvasViewRaw(file: string): Promise<Record<string, unknown>> {
1269
+ const viewAbs = canvasViewPath(file);
1270
+ if (!viewAbs) return {};
1271
+ try {
1272
+ const obj = JSON.parse(await Bun.file(viewAbs).text());
1273
+ return obj && typeof obj === 'object' && !Array.isArray(obj)
1274
+ ? (obj as Record<string, unknown>)
1275
+ : {};
1276
+ } catch {
1277
+ return {};
1278
+ }
1279
+ }
1280
+
1281
+ async function loadCanvasView(file: string): Promise<{
1282
+ viewport?: { x: number; y: number; zoom: number };
1283
+ overlays?: Record<string, boolean>;
1284
+ } | null> {
1220
1285
  const viewAbs = canvasViewPath(file);
1221
1286
  if (!viewAbs) return null;
1222
1287
  try {
1223
1288
  const obj = JSON.parse(await Bun.file(viewAbs).text());
1224
1289
  if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
1225
1290
  const vp = normalizeViewport((obj as { viewport?: unknown }).viewport);
1226
- return vp ? { viewport: vp } : {};
1291
+ const ov = normalizeOverlays((obj as { overlays?: unknown }).overlays);
1292
+ const result: {
1293
+ viewport?: { x: number; y: number; zoom: number };
1294
+ overlays?: Record<string, boolean>;
1295
+ } = {};
1296
+ if (vp) result.viewport = vp;
1297
+ if (ov && Object.keys(ov).length > 0) result.overlays = ov;
1298
+ return result;
1227
1299
  }
1228
1300
  return null;
1229
1301
  } catch {
@@ -1233,7 +1305,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1233
1305
 
1234
1306
  /** Persist the per-user camera. Validates + clamps; best-effort (mkdir the
1235
1307
  * bucket if absent). Returns the normalized viewport on write, null when the
1236
- * path is rejected or the viewport is invalid (no write). */
1308
+ * path is rejected or the viewport is invalid (no write). Read-modify-write
1309
+ * so an existing `overlays` key in the same view file survives. */
1237
1310
  async function saveCanvasView(
1238
1311
  file: string,
1239
1312
  viewport: unknown
@@ -1244,13 +1317,44 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1244
1317
  if (!vp) return null;
1245
1318
  try {
1246
1319
  await mkdir(paths.canvasStateDir, { recursive: true });
1247
- await Bun.write(viewAbs, `${JSON.stringify({ viewport: vp }, null, 2)}\n`);
1320
+ const current = await readCanvasViewRaw(file);
1321
+ await Bun.write(viewAbs, `${JSON.stringify({ ...current, viewport: vp }, null, 2)}\n`);
1248
1322
  return vp;
1249
1323
  } catch {
1250
1324
  return null;
1251
1325
  }
1252
1326
  }
1253
1327
 
1328
+ /**
1329
+ * Persist the per-user overlay-visibility bag (T6). Shallow-merges into
1330
+ * whatever `overlays` the view file already has — a `{ guides: true }` patch
1331
+ * doesn't erase a `bleed` key a downstream print-plan toggle already set.
1332
+ * Never touches the versioned `.meta.json`. Read-modify-write so an existing
1333
+ * `viewport` key survives.
1334
+ */
1335
+ async function saveCanvasOverlays(
1336
+ file: string,
1337
+ overlays: unknown
1338
+ ): Promise<Record<string, boolean> | null> {
1339
+ const viewAbs = canvasViewPath(file);
1340
+ if (!viewAbs) return null;
1341
+ const ov = normalizeOverlays(overlays);
1342
+ if (ov === null) return null;
1343
+ try {
1344
+ await mkdir(paths.canvasStateDir, { recursive: true });
1345
+ const current = await readCanvasViewRaw(file);
1346
+ const prevOverlays =
1347
+ current.overlays && typeof current.overlays === 'object' && !Array.isArray(current.overlays)
1348
+ ? (current.overlays as Record<string, unknown>)
1349
+ : {};
1350
+ const merged = normalizeOverlays({ ...prevOverlays, ...ov }) ?? ov;
1351
+ await Bun.write(viewAbs, `${JSON.stringify({ ...current, overlays: merged }, null, 2)}\n`);
1352
+ return merged;
1353
+ } catch {
1354
+ return null;
1355
+ }
1356
+ }
1357
+
1254
1358
  async function loadCanvasMeta(file: string): Promise<Record<string, unknown> | null> {
1255
1359
  const metaAbs = canvasMetaPath(file);
1256
1360
  if (!metaAbs) return null;
@@ -1274,11 +1378,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1274
1378
  // codebase convention + biome's noDelete.)
1275
1379
  obj.viewport = undefined;
1276
1380
  obj.last_modified = undefined;
1381
+ // T6 — `overlays` (per-user guide/mark visibility) is runtime state same as
1382
+ // `viewport`; a versioned `.meta.json` should never carry one (sanitizer),
1383
+ // but strip defensively before overlaying the real per-user value below.
1384
+ obj.overlays = undefined;
1277
1385
  const view = await loadCanvasView(file);
1278
1386
  if (view?.viewport) obj.viewport = view.viewport;
1279
- // Preserve the historic contract: no meta AND no camera → null (GET → {},
1280
- // PATCH-on-rejected-path → 404). A view-only canvas still returns its camera.
1281
- if (!hadMeta && !view?.viewport) return null;
1387
+ if (view?.overlays) obj.overlays = view.overlays;
1388
+ // Preserve the historic contract: no meta AND no camera/overlays → null
1389
+ // (GET → {}, PATCH-on-rejected-path → 404). A view-only canvas still
1390
+ // returns its camera/overlays.
1391
+ if (!hadMeta && !view?.viewport && !view?.overlays) return null;
1282
1392
  return obj;
1283
1393
  }
1284
1394
 
@@ -1331,6 +1441,27 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1331
1441
  }
1332
1442
  }
1333
1443
 
1444
+ // --- Per-user overlay-visibility lane (T6): overlays → view file, never
1445
+ // the versioned meta. Same shape as the viewport lane above — `null`
1446
+ // clears, an invalid bag is a silent no-op (normalizeOverlays → null).
1447
+ if (patch.overlays !== undefined) {
1448
+ if (patch.overlays === null) {
1449
+ const viewAbs = canvasViewPath(file);
1450
+ if (viewAbs) {
1451
+ try {
1452
+ const current = await readCanvasViewRaw(file);
1453
+ const { overlays: _drop, ...rest } = current;
1454
+ await mkdir(paths.canvasStateDir, { recursive: true });
1455
+ await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
1456
+ } catch {
1457
+ /* best-effort clear */
1458
+ }
1459
+ }
1460
+ } else {
1461
+ await saveCanvasOverlays(file, patch.overlays);
1462
+ }
1463
+ }
1464
+
1334
1465
  // --- Shared document lane: layout → versioned meta, stamps last_modified. ---
1335
1466
  if (patch.layout !== undefined) {
1336
1467
  let current: Record<string, unknown> = {};
@@ -2952,6 +3083,193 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2952
3083
  }
2953
3084
  }
2954
3085
 
3086
+ const ARTBOARD_KIND_VALUES = new Set(['digital', 'print', 'web', 'video']);
3087
+
3088
+ /** Kind-switch surfaces (T8): context menu + Inspector picker. */
3089
+ async function setArtboardKindOp(input: {
3090
+ canvas?: unknown;
3091
+ artboardId?: unknown;
3092
+ kind?: unknown;
3093
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
3094
+ const r = resolveCanvasAbs(input.canvas);
3095
+ if (!r.ok) return r;
3096
+ if (!takeStructuralToken()) return RATE_LIMITED;
3097
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
3098
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
3099
+ return { ok: false, status: 400, error: 'invalid artboard id' };
3100
+ }
3101
+ let kind: string | null;
3102
+ if (input.kind === null) {
3103
+ kind = null;
3104
+ } else if (typeof input.kind === 'string' && ARTBOARD_KIND_VALUES.has(input.kind)) {
3105
+ kind = input.kind;
3106
+ } else {
3107
+ return { ok: false, status: 400, error: 'invalid kind' };
3108
+ }
3109
+ const rel = path.relative(paths.designRoot, r.abs);
3110
+ ctx.bus.emit('activity:suppress', rel);
3111
+ try {
3112
+ const before = await Bun.file(r.abs).text();
3113
+ await setArtboardKind(r.abs, artboardId, kind);
3114
+ const after = await Bun.file(r.abs).text();
3115
+ if (after === before) {
3116
+ ctx.bus.emit('activity:unsuppress', rel);
3117
+ return { ok: true };
3118
+ }
3119
+ try {
3120
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-kind');
3121
+ } catch {
3122
+ /* snapshot best-effort */
3123
+ }
3124
+ return { ok: true, seq: logUndo(r.abs, before, after) };
3125
+ } catch (err) {
3126
+ ctx.bus.emit('activity:unsuppress', rel);
3127
+ return {
3128
+ ok: false,
3129
+ status: err instanceof CanvasEditError ? 422 : 500,
3130
+ error: err instanceof Error ? err.message : 'set-artboard-kind failed',
3131
+ };
3132
+ }
3133
+ }
3134
+
3135
+ const MAX_GUIDES_JSON_BYTES = 4096;
3136
+
3137
+ /** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
3138
+ * never a deep merge (see applySetArtboardGuides's own doc comment). */
3139
+ async function setArtboardGuidesOp(input: {
3140
+ canvas?: unknown;
3141
+ artboardId?: unknown;
3142
+ guides?: unknown;
3143
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
3144
+ const r = resolveCanvasAbs(input.canvas);
3145
+ if (!r.ok) return r;
3146
+ if (!takeStructuralToken()) return RATE_LIMITED;
3147
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
3148
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
3149
+ return { ok: false, status: 400, error: 'invalid artboard id' };
3150
+ }
3151
+ let guides: Record<string, unknown> | null;
3152
+ if (input.guides === null) {
3153
+ guides = null;
3154
+ } else if (
3155
+ input.guides &&
3156
+ typeof input.guides === 'object' &&
3157
+ !Array.isArray(input.guides) &&
3158
+ JSON.stringify(input.guides).length <= MAX_GUIDES_JSON_BYTES
3159
+ ) {
3160
+ guides = input.guides as Record<string, unknown>;
3161
+ } else {
3162
+ return { ok: false, status: 400, error: 'invalid guides' };
3163
+ }
3164
+ const rel = path.relative(paths.designRoot, r.abs);
3165
+ ctx.bus.emit('activity:suppress', rel);
3166
+ try {
3167
+ const before = await Bun.file(r.abs).text();
3168
+ await setArtboardGuides(r.abs, artboardId, guides);
3169
+ const after = await Bun.file(r.abs).text();
3170
+ if (after === before) {
3171
+ ctx.bus.emit('activity:unsuppress', rel);
3172
+ return { ok: true };
3173
+ }
3174
+ try {
3175
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-guides');
3176
+ } catch {
3177
+ /* snapshot best-effort */
3178
+ }
3179
+ return { ok: true, seq: logUndo(r.abs, before, after) };
3180
+ } catch (err) {
3181
+ ctx.bus.emit('activity:unsuppress', rel);
3182
+ return {
3183
+ ok: false,
3184
+ status: err instanceof CanvasEditError ? 422 : 500,
3185
+ error: err instanceof Error ? err.message : 'set-artboard-guides failed',
3186
+ };
3187
+ }
3188
+ }
3189
+
3190
+ const PRINT_ORIENTATION_VALUES = new Set(['portrait', 'landscape']);
3191
+
3192
+ function isFiniteMmOrUndefined(v: unknown): v is number | undefined {
3193
+ return (
3194
+ v === undefined ||
3195
+ (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_PRINT_MM)
3196
+ );
3197
+ }
3198
+
3199
+ /**
3200
+ * feature-2-print-artboards T2 — paper/orientation/bleed/margins. Replace-
3201
+ * whole-prop (see applySetArtboardPrint's own doc comment) — the caller
3202
+ * sends the full merged object, not a delta. `print: null` clears the
3203
+ * prop. Shape validation lives HERE (canvas-edit.ts stays generic
3204
+ * Record<string, unknown> AST surgery, same split as setArtboardGuidesOp).
3205
+ */
3206
+ async function setArtboardPrintOp(input: {
3207
+ canvas?: unknown;
3208
+ artboardId?: unknown;
3209
+ print?: unknown;
3210
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
3211
+ const r = resolveCanvasAbs(input.canvas);
3212
+ if (!r.ok) return r;
3213
+ if (!takeStructuralToken()) return RATE_LIMITED;
3214
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
3215
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
3216
+ return { ok: false, status: 400, error: 'invalid artboard id' };
3217
+ }
3218
+ let print: Record<string, unknown> | null;
3219
+ if (input.print === null) {
3220
+ print = null;
3221
+ } else if (input.print && typeof input.print === 'object' && !Array.isArray(input.print)) {
3222
+ const p = input.print as Record<string, unknown>;
3223
+ if (typeof p.paper !== 'string' || !getPaperPreset(p.paper)) {
3224
+ return { ok: false, status: 400, error: 'invalid or unknown paper preset' };
3225
+ }
3226
+ if (p.orientation !== undefined && !PRINT_ORIENTATION_VALUES.has(p.orientation as string)) {
3227
+ return { ok: false, status: 400, error: 'invalid orientation' };
3228
+ }
3229
+ if (!isFiniteMmOrUndefined(p.bleedMm)) {
3230
+ return { ok: false, status: 400, error: 'invalid bleedMm' };
3231
+ }
3232
+ if (p.marginsMm !== undefined) {
3233
+ if (typeof p.marginsMm !== 'object' || p.marginsMm === null || Array.isArray(p.marginsMm)) {
3234
+ return { ok: false, status: 400, error: 'invalid marginsMm' };
3235
+ }
3236
+ const m = p.marginsMm as Record<string, unknown>;
3237
+ for (const side of ['top', 'right', 'bottom', 'left']) {
3238
+ if (!isFiniteMmOrUndefined(m[side])) {
3239
+ return { ok: false, status: 400, error: `invalid marginsMm.${side}` };
3240
+ }
3241
+ }
3242
+ }
3243
+ print = p;
3244
+ } else {
3245
+ return { ok: false, status: 400, error: 'invalid print' };
3246
+ }
3247
+ const rel = path.relative(paths.designRoot, r.abs);
3248
+ ctx.bus.emit('activity:suppress', rel);
3249
+ try {
3250
+ const before = await Bun.file(r.abs).text();
3251
+ await setArtboardPrint(r.abs, artboardId, print);
3252
+ const after = await Bun.file(r.abs).text();
3253
+ if (after === before) {
3254
+ ctx.bus.emit('activity:unsuppress', rel);
3255
+ return { ok: true };
3256
+ }
3257
+ try {
3258
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-print');
3259
+ } catch {
3260
+ /* snapshot best-effort */
3261
+ }
3262
+ return { ok: true, seq: logUndo(r.abs, before, after) };
3263
+ } catch (err) {
3264
+ ctx.bus.emit('activity:unsuppress', rel);
3265
+ return {
3266
+ ok: false,
3267
+ status: err instanceof CanvasEditError ? 422 : 500,
3268
+ error: err instanceof Error ? err.message : 'set-artboard-print failed',
3269
+ };
3270
+ }
3271
+ }
3272
+
2955
3273
  const ARTBOARD_LAYOUT_VALUES = new Set(['block', 'flex-col', 'flex-row', 'grid']);
2956
3274
 
2957
3275
  /** Set artboard "more settings" — background / padding / layout / gap. */
@@ -2972,9 +3290,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2972
3290
  }
2973
3291
  const patch: {
2974
3292
  background?: string | null;
2975
- padding?: number | null;
3293
+ padding?: number | string | null;
2976
3294
  layout?: string | null;
2977
- gap?: number | null;
3295
+ gap?: number | string | null;
2978
3296
  } = {};
2979
3297
  if ('background' in input) {
2980
3298
  if (input.background === null) patch.background = null;
@@ -2992,19 +3310,28 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2992
3310
  return { ok: false, status: 400, error: 'invalid layout' };
2993
3311
  }
2994
3312
  }
2995
- const clampBoxDim = (v: unknown): number | null | undefined => {
3313
+ // Dogfood (artboard panel ↔ shared inspector controls) — padding/gap
3314
+ // accept a design-token binding alongside raw px. STRICT single-var shape
3315
+ // (not a free-form CSS string like `background`): the value lands in a
3316
+ // JSX prop AND in `.dc-artboard-body`'s inline style, so anything beyond
3317
+ // one `var(--…)` reference is rejected rather than round-tripped.
3318
+ const BOX_TOKEN_RE = /^var\(--[a-zA-Z0-9_-]{1,64}\)$/;
3319
+ const clampBoxDim = (v: unknown): number | string | null | undefined => {
2996
3320
  if (v === null) return null;
2997
3321
  if (v === undefined) return undefined;
3322
+ if (typeof v === 'string' && BOX_TOKEN_RE.test(v)) return v;
2998
3323
  return Number.isFinite(Number(v)) ? Math.max(0, Math.min(512, Math.round(Number(v)))) : NaN;
2999
3324
  };
3000
3325
  if ('padding' in input) {
3001
3326
  const p = clampBoxDim(input.padding);
3002
- if (Number.isNaN(p)) return { ok: false, status: 400, error: 'invalid padding' };
3327
+ if (typeof p === 'number' && Number.isNaN(p))
3328
+ return { ok: false, status: 400, error: 'invalid padding' };
3003
3329
  patch.padding = p;
3004
3330
  }
3005
3331
  if ('gap' in input) {
3006
3332
  const g = clampBoxDim(input.gap);
3007
- if (Number.isNaN(g)) return { ok: false, status: 400, error: 'invalid gap' };
3333
+ if (typeof g === 'number' && Number.isNaN(g))
3334
+ return { ok: false, status: 400, error: 'invalid gap' };
3008
3335
  patch.gap = g;
3009
3336
  }
3010
3337
  if (Object.keys(patch).length === 0) {
@@ -3772,6 +4099,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3772
4099
  resizeArtboardOp,
3773
4100
  setArtboardHugOp,
3774
4101
  setArtboardStyleOp,
4102
+ setArtboardKindOp,
4103
+ setArtboardGuidesOp,
4104
+ setArtboardPrintOp,
3775
4105
  deleteArtboardOp,
3776
4106
  duplicateElementOp,
3777
4107
  editScopeOp,