@1agh/maude 0.45.2 → 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.
- package/apps/studio/acp/bridge.ts +43 -1
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -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/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/ChatPanel.jsx +45 -1
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/dist/client.bundle.js +1398 -1398
- 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/http.ts +213 -7
- 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 +91 -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/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -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/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +28 -0
- package/cli/commands/design.mjs +9 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
|
@@ -202,6 +202,40 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT
|
|
|
202
202
|
* `LoadSessionRequest` (schema/types.gen.d.ts) — `sessionFor`'s resume path
|
|
203
203
|
* spreads this same object and adds `sessionId` rather than duplicating it.
|
|
204
204
|
*/
|
|
205
|
+
// The curated tool allow-list every Maude bridge session auto-approves, so the
|
|
206
|
+
// design workflow (edit a canvas, then shell out to the `maude` CLI + its design
|
|
207
|
+
// helpers) runs without a permission prompt on EVERY step — the out-of-box
|
|
208
|
+
// "Manual mode blocks every edit" complaint this closes (DDR-184). Deliberately
|
|
209
|
+
// NARROW, and a complement to (not a reversal of) DDR-179's "mode picker stays
|
|
210
|
+
// honest": the session mode is left untouched at its default, so anything NOT on
|
|
211
|
+
// this list still routes through the real approve/deny gate (requestPermission →
|
|
212
|
+
// PermissionPrompt) — arbitrary `Bash(curl …)`/`rm`, WebFetch, unknown MCP tools.
|
|
213
|
+
//
|
|
214
|
+
// • File tools (Read/Edit/Write/Glob/Grep/NotebookEdit) — the canvas-editing
|
|
215
|
+
// surface. Auto-approving Edit/Write is the accepted residual: edits land in
|
|
216
|
+
// the served project (already the edit target) and are reversible via the
|
|
217
|
+
// `_history/` snapshot stack.
|
|
218
|
+
// • `Bash(maude:*)` — the SINGLE rule that covers the entire design-helper
|
|
219
|
+
// surface, because DDR-062 routes every helper through `maude design <verb>`
|
|
220
|
+
// (screenshot / draw-* / canvas-rects / probe-footage / …) and their own deps
|
|
221
|
+
// (agent-browser, playwright, svgo) run as CHILDREN of that one bash call, so
|
|
222
|
+
// they need no separate entry. Bash NOT starting with `maude` still prompts.
|
|
223
|
+
//
|
|
224
|
+
// SOURCE-OF-TRUTH GUARD: this list is asserted against the `maude design` verb
|
|
225
|
+
// dispatch (`cli/commands/design.mjs`) + `plugins/design/dependencies.json` by
|
|
226
|
+
// acp-session-allowed-tools.test.ts — a future helper that is NOT reached via
|
|
227
|
+
// `maude design` (a brand-new top-level tool, not a new verb) fails that test
|
|
228
|
+
// loudly instead of silently prompting the user mid-workflow.
|
|
229
|
+
export const MAUDE_DEFAULT_ALLOWED_TOOLS: readonly string[] = [
|
|
230
|
+
'Read',
|
|
231
|
+
'Edit',
|
|
232
|
+
'Write',
|
|
233
|
+
'Glob',
|
|
234
|
+
'Grep',
|
|
235
|
+
'NotebookEdit',
|
|
236
|
+
'Bash(maude:*)',
|
|
237
|
+
];
|
|
238
|
+
|
|
205
239
|
export function newSessionParams(
|
|
206
240
|
repoRoot: string,
|
|
207
241
|
studioBrief?: string,
|
|
@@ -219,7 +253,15 @@ export function newSessionParams(
|
|
|
219
253
|
// deputy chain (the DDR-143 guard #6 follow-up). The project's CLAUDE.md is read
|
|
220
254
|
// via a separate path and is unaffected. Always injected — every Maude bridge
|
|
221
255
|
// session is auto-approving. `...plugins` (DDR-143) rides the same options object.
|
|
222
|
-
const options: Record<string, unknown> = {
|
|
256
|
+
const options: Record<string, unknown> = {
|
|
257
|
+
settingSources: ['user'],
|
|
258
|
+
// DDR-184 — auto-approve Maude's own first-party tool surface so the design
|
|
259
|
+
// workflow never stalls on a per-edit / per-`maude` permission prompt. Rides
|
|
260
|
+
// the same `_meta.claudeCode.options` spread as `plugins`/`settings` below
|
|
261
|
+
// (`...userProvidedOptions`, acp-agent.js:2333→2455) into the SDK's
|
|
262
|
+
// `allowedTools` (sdk.d.ts:1331). Everything off this list still prompts.
|
|
263
|
+
allowedTools: [...MAUDE_DEFAULT_ALLOWED_TOOLS],
|
|
264
|
+
};
|
|
223
265
|
if (plugins && plugins.length > 0) {
|
|
224
266
|
options.plugins = plugins;
|
|
225
267
|
// DDR-168 — the bundled `design` plugin is now injected UNCONDITIONALLY
|
|
@@ -30,6 +30,20 @@ export interface SnapResult {
|
|
|
30
30
|
guides: SnapGuide[];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* T7 (feature-1-artboard-kinds-foundation) — a generic-layout-guide line
|
|
35
|
+
* (T5), in WORLD coordinates, offered to `computeSnap` the same way a
|
|
36
|
+
* stroke/artboard bbox candidate is. `at`/`from`/`to` mirror `SnapGuide`'s own
|
|
37
|
+
* shape (this IS the line, not a box to derive one from) — the caller
|
|
38
|
+
* (annotations-layer.tsx) resolves an artboard's `guides` prop into these.
|
|
39
|
+
*/
|
|
40
|
+
export interface GuideLineCandidate {
|
|
41
|
+
axis: 'x' | 'y';
|
|
42
|
+
at: number;
|
|
43
|
+
from: number;
|
|
44
|
+
to: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
/** Snap threshold in world px at zoom 1 (callers scale by 1/zoom). */
|
|
34
48
|
export const SNAP_THRESHOLD_PX = 6;
|
|
35
49
|
|
|
@@ -61,17 +75,26 @@ function gridDelta(pos: number, pitch: number, threshold: number): number | null
|
|
|
61
75
|
* leading edge falls back to the canvas dot grid — weaker than smart guides
|
|
62
76
|
* (geometry alignment wins over the lattice) and silent (no guide line, the
|
|
63
77
|
* dots themselves are the visual). ⌘ suppresses both via the caller.
|
|
78
|
+
*
|
|
79
|
+
* T7 — `opts.guideLines` (generic layout guides, T5) feed the SAME candidate
|
|
80
|
+
* pool as stroke/artboard bboxes: each line is tested against all three
|
|
81
|
+
* moving lines (leading edge, center, trailing edge), exactly like a
|
|
82
|
+
* sibling/artboard edge, and competes on equal footing (nearest within
|
|
83
|
+
* threshold wins — a closer stroke edge still beats a farther guide line).
|
|
64
84
|
*/
|
|
65
85
|
export function computeSnap(
|
|
66
86
|
moving: SnapBox,
|
|
67
87
|
candidates: readonly SnapBox[],
|
|
68
88
|
threshold: number,
|
|
69
|
-
opts?: { grid?: number }
|
|
89
|
+
opts?: { grid?: number; guideLines?: readonly GuideLineCandidate[] }
|
|
70
90
|
): SnapResult {
|
|
71
91
|
const grid = opts?.grid ?? 0;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
92
|
+
const guideLines = opts?.guideLines ?? [];
|
|
93
|
+
if (threshold <= 0 || (candidates.length === 0 && guideLines.length === 0 && grid <= 0)) {
|
|
94
|
+
return NO_SNAP;
|
|
95
|
+
}
|
|
96
|
+
let bestX: { d: number; at: number; from: number; to: number } | null = null;
|
|
97
|
+
let bestY: { d: number; at: number; from: number; to: number } | null = null;
|
|
75
98
|
const mx = lines(moving, 'x');
|
|
76
99
|
const my = lines(moving, 'y');
|
|
77
100
|
for (const c of candidates) {
|
|
@@ -81,7 +104,7 @@ export function computeSnap(
|
|
|
81
104
|
for (const v of cx) {
|
|
82
105
|
const d = v - m;
|
|
83
106
|
if (Math.abs(d) <= threshold && (!bestX || Math.abs(d) < Math.abs(bestX.d))) {
|
|
84
|
-
bestX = { d, at: v,
|
|
107
|
+
bestX = { d, at: v, from: c.y, to: c.y + c.h };
|
|
85
108
|
}
|
|
86
109
|
}
|
|
87
110
|
}
|
|
@@ -89,7 +112,24 @@ export function computeSnap(
|
|
|
89
112
|
for (const v of cy) {
|
|
90
113
|
const d = v - m;
|
|
91
114
|
if (Math.abs(d) <= threshold && (!bestY || Math.abs(d) < Math.abs(bestY.d))) {
|
|
92
|
-
bestY = { d, at: v,
|
|
115
|
+
bestY = { d, at: v, from: c.x, to: c.x + c.w };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const g of guideLines) {
|
|
121
|
+
if (g.axis === 'x') {
|
|
122
|
+
for (const m of mx) {
|
|
123
|
+
const d = g.at - m;
|
|
124
|
+
if (Math.abs(d) <= threshold && (!bestX || Math.abs(d) < Math.abs(bestX.d))) {
|
|
125
|
+
bestX = { d, at: g.at, from: g.from, to: g.to };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
for (const m of my) {
|
|
130
|
+
const d = g.at - m;
|
|
131
|
+
if (Math.abs(d) <= threshold && (!bestY || Math.abs(d) < Math.abs(bestY.d))) {
|
|
132
|
+
bestY = { d, at: g.at, from: g.from, to: g.to };
|
|
93
133
|
}
|
|
94
134
|
}
|
|
95
135
|
}
|
|
@@ -109,16 +149,16 @@ export function computeSnap(
|
|
|
109
149
|
guides.push({
|
|
110
150
|
axis: 'x',
|
|
111
151
|
at: bestX.at,
|
|
112
|
-
from: Math.min(moving.y + dy, bestX.
|
|
113
|
-
to: Math.max(moving.y + moving.h + dy, bestX.
|
|
152
|
+
from: Math.min(moving.y + dy, bestX.from),
|
|
153
|
+
to: Math.max(moving.y + moving.h + dy, bestX.to),
|
|
114
154
|
});
|
|
115
155
|
}
|
|
116
156
|
if (bestY) {
|
|
117
157
|
guides.push({
|
|
118
158
|
axis: 'y',
|
|
119
159
|
at: bestY.at,
|
|
120
|
-
from: Math.min(moving.x + dx, bestY.
|
|
121
|
-
to: Math.max(moving.x + moving.w + dx, bestY.
|
|
160
|
+
from: Math.min(moving.x + dx, bestY.from),
|
|
161
|
+
to: Math.max(moving.x + moving.w + dx, bestY.to),
|
|
122
162
|
});
|
|
123
163
|
}
|
|
124
164
|
return { dx, dy, guides };
|
package/apps/studio/api.ts
CHANGED
|
@@ -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
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1280
|
-
//
|
|
1281
|
-
|
|
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
|
-
|
|
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))
|
|
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))
|
|
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,
|