@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -3,13 +3,22 @@
3
3
 
4
4
  import crypto from 'node:crypto';
5
5
  import type { Dirent } from 'node:fs';
6
- import { mkdir, readdir, readFile, rename, stat as statp } from 'node:fs/promises';
6
+ import { mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
 
9
9
  import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
10
+ import {
11
+ CanvasEditError,
12
+ editAttribute,
13
+ removeAttribute,
14
+ editText as runEditText,
15
+ } from './canvas-edit.ts';
10
16
  import type { Context } from './context.ts';
11
17
 
12
- const SKIP_DIRS = new Set([
18
+ // Directories that never hold user-facing canvases. Exported so the
19
+ // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
20
+ // a hand-synced copy. (activity.ts still carries its own historical mirror.)
21
+ export const SKIP_DIRS = new Set([
13
22
  'node_modules',
14
23
  '.git',
15
24
  '.next',
@@ -56,6 +65,31 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
56
65
  return out;
57
66
  }
58
67
 
68
+ /**
69
+ * Canonical canvas slug from a (repo- or design-root-relative) canvas path.
70
+ * Pure — the `fileSlug` closure inside `createApi` delegates here, and the
71
+ * external-canvas watcher (`canvas-list-watch.ts`) imports it so both creation
72
+ * paths derive identical `canvas-list-update` slugs. Strips an optional
73
+ * `<designRel>/` prefix, then `/`→`-`, whitespace→`_`, drops the `.tsx`/`.html`
74
+ * extension, and lowercases.
75
+ */
76
+ export function canvasSlugFromRel(file: string, designRel: string): string {
77
+ let p = String(file).replace(/^\/+|\/+$/g, '');
78
+ try {
79
+ p = decodeURIComponent(p);
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
84
+ if (p.startsWith(prefix)) p = p.slice(prefix.length);
85
+ return p
86
+ .replace(/\//g, '-')
87
+ .replace(/\s+/g, '_')
88
+ .replace(/\.(tsx|html)$/i, '')
89
+ .replace(/^\.+/, '')
90
+ .toLowerCase();
91
+ }
92
+
59
93
  async function findFiles(absRoot: string, prefix: string, exts: string[]): Promise<string[]> {
60
94
  const out: string[] = [];
61
95
  let entries: Dirent[];
@@ -141,6 +175,11 @@ export type DeleteCanvasResult =
141
175
  | { ok: true; rel: string; slug: string; trashed: string[]; trashDir: string }
142
176
  | { ok: false; status: number; error: string };
143
177
 
178
+ /** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
179
+ export type EditOpResult =
180
+ | { ok: true; delta: number }
181
+ | { ok: false; status: number; error: string };
182
+
144
183
  export interface Api {
145
184
  // File tree
146
185
  fileSlug(file: string): string;
@@ -190,6 +229,25 @@ export interface Api {
190
229
  }): Promise<CreateCanvasResult>;
191
230
  // Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
192
231
  deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
232
+ // Phase 12 (DDR-103) — single-property inline CSS edit (POST /_api/edit-css).
233
+ // Main-origin only: writes one key into the element's inline `style={{}}` object.
234
+ editCss(input: {
235
+ canvas?: unknown;
236
+ id?: unknown;
237
+ property?: unknown;
238
+ value?: unknown;
239
+ }): Promise<EditOpResult>;
240
+ // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
241
+ editText(input: { canvas?: unknown; id?: unknown; text?: unknown }): Promise<EditOpResult>;
242
+ // Phase 12.2 (DDR-104) — custom HTML attribute edit (POST /_api/edit-attr). Main-origin
243
+ // only. The CSS panel's "custom HTML attribute" escape hatch (data-*, aria-*, role, …);
244
+ // writes a plain JSX attribute via editAttribute's non-`style.` path.
245
+ editAttr(input: {
246
+ canvas?: unknown;
247
+ id?: unknown;
248
+ attr?: unknown;
249
+ value?: unknown;
250
+ }): Promise<EditOpResult>;
193
251
  // Aggregate data
194
252
  buildIndexData(): Promise<unknown>;
195
253
  buildSystemData(dsName?: string | null): Promise<unknown>;
@@ -204,45 +262,14 @@ export interface ApiHooks {
204
262
  onAnnotationsChanged?: (file: string, svg: string) => void;
205
263
  }
206
264
 
207
- /**
208
- * Elements the annotation tool legitimately emits (`strokesToSvg` in
209
- * annotations-layer.tsx). This is the WHOLE vocabulary purely presentational.
210
- */
211
- const ANNOTATION_SVG_ELEMENTS = new Set([
212
- 'svg',
213
- 'g',
214
- 'path',
215
- 'rect',
216
- 'ellipse',
217
- 'line',
218
- 'polyline',
219
- // Phase 24 — `polygon` (diamond / triangle shape primitives + closed arrowhead
220
- // outlines + diamond heads) and `circle` (circle arrowhead). Inert shape
221
- // elements with no script capability — same safety class as the rest.
222
- 'polygon',
223
- 'circle',
224
- 'text',
225
- // Annotation polish (item 4a) — `tspan` carries one line of multi-line text
226
- // inside a `<text>` (SVG `<text>` ignores `\n`). Inert presentational element
227
- // with no script capability — same safety class as `text`. Only `x`/`dy`
228
- // (geometry) survive; the attribute denylist strips any on*/style/href.
229
- 'tspan',
230
- // Phase 23 — `image` (dropped/pasted raster). The ONLY element allowed to keep
231
- // an href, and ONLY a relative assets/<sha8>.<ext> path (ASSET_IMAGE_HREF_RE) —
232
- // every external / data: / javascript: / `..` href is still stripped. <image>
233
- // is a passive include with no script capability. See DDR (Task 9).
234
- 'image',
235
- ]);
265
+ // FigJam v3 — the annotation sanitizer moved to annotations-model.ts (the
266
+ // schema owner; the allowlist guards exactly that vocabulary, and the
267
+ // headless `maude design annotate` write verb needs it without pulling the
268
+ // server modules). Re-exported here so every existing `from './api.ts'`
269
+ // import keeps working unchanged.
270
+ export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.ts';
236
271
 
237
- /**
238
- * Phase 23 — the ONLY href shape allowed to survive on an `<image>`: a relative,
239
- * single-segment `assets/<name>.<ext>` path. Anchored (`^…$`), no scheme, no `/`
240
- * beyond the one `assets/` segment (blocks `assets/../../etc/passwd`), no query,
241
- * ext ∈ the four raster types the upload route accepts. Everything else —
242
- * external, `data:`, `javascript:`, `..`, or any href on a non-image element —
243
- * is stripped by Rule 3. Pairs with the asset-write caps (DDR Task 9).
244
- */
245
- export const ASSET_IMAGE_HREF_RE = /^assets\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif)$/;
272
+ import { sanitizeAnnotationSvg } from './annotations-model.ts';
246
273
 
247
274
  /** Phase 23 — hard ceiling on a single uploaded asset (10 MB). */
248
275
  export const ASSET_MAX_BYTES = 10 * 1024 * 1024;
@@ -323,104 +350,13 @@ export interface SaveAssetResult {
323
350
  path?: string;
324
351
  }
325
352
 
326
- /**
327
- * A3 (DDR-060 F1 re-audit) — sanitize an annotation SVG before it is persisted /
328
- * synced / mirrored into the collab Y.Map. ALLOWLIST, not denylist (the F1
329
- * confirming pass showed a denylist loses the race: `<svg:script>` via namespace,
330
- * `javascript&#58;` via HTML entity, `<style>@import url()>`, CSS `url(javascript:)`
331
- * all slipped a tag-name denylist). Two rules, both keyed to the fixed legit
332
- * vocabulary so they're zero-regression on real annotations:
333
- *
334
- * 1. Element allowlist — drop the markup of any tag whose LOCAL name (namespace
335
- * stripped, so `svg:script` → `script`) isn't in ANNOTATION_SVG_ELEMENTS.
336
- * Dropped-tag text content survives as inert text (not in a script/style
337
- * context), which is harmless.
338
- * 2. Attribute denylist on survivors — the legit vocabulary uses none of
339
- * `on*=`, `style=`, or any `href`, so stripping them closes inline handlers,
340
- * CSS `url(javascript:)`, and entity-encoded `xlink:href` in one pass.
341
- *
342
- * The current consumer (`svgToStrokes` → DOMParser image/svg+xml → structured
343
- * strokes → React) never raw-renders this string, so the live XSS risk is already
344
- * nil; this keeps "inert" TRUE for any future raw-render consumer and for the
345
- * synced file a peer / Claude-context ingests (defense-in-depth, DDR-054 §3).
346
- */
347
- export function sanitizeAnnotationSvg(svg: string): string {
348
- // Phase 23 — <image> href handling. Rule 3 below strips EVERY href (that's the
349
- // zero-bypass invariant). To let a legit assets/ href survive WITHOUT
350
- // re-opening the denylist race, we (a) neutralize any input-supplied marker so
351
- // only this pre-pass can mint one, (b) on `<image>` ONLY, hoist a regex-valid
352
- // assets href into a sanitizer-inert `data-mdcc-asset` marker (an external /
353
- // data: / `..` / non-image href is left as a plain href → stripped by Rule 3),
354
- // then (c) after the three rules, restore the marker back to `href` — but ONLY
355
- // after RE-validating the value, so a forged marker can never smuggle a
356
- // scheme/traversal back in. The restored value is, by construction, a safe
357
- // same-origin path. See ASSET_IMAGE_HREF_RE + DDR (Task 9).
358
- const hoisted = svg
359
- .replace(/\sdata-mdcc-asset\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
360
- .replace(/<\s*(?:[\w-]+:)?image\b[^>]*>/gi, (tag) => {
361
- const m = tag.match(/(?:xlink:)?href\s*=\s*"([^"]*)"|(?:xlink:)?href\s*=\s*'([^']*)'/i);
362
- const val = m ? (m[1] ?? m[2] ?? '') : '';
363
- if (!val || !ASSET_IMAGE_HREF_RE.test(val)) return tag;
364
- return tag.replace(/(?:xlink:)?href\s*=\s*("[^"]*"|'[^']*')/i, `data-mdcc-asset="${val}"`);
365
- });
366
- const cleaned = hoisted
367
- // 1. Remove the CONTENT of executable / instruction-bearing elements (not
368
- // just their tags) so an injected script body / `@import` / prompt-
369
- // injection string can't survive as inert text a future raw-renderer or
370
- // Claude-context read might act on. Namespace-tolerant (`svg:script`),
371
- // non-greedy to the first matching dangerous close tag.
372
- .replace(
373
- /<\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\b[\s\S]*?<\s*\/\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\s*>/gi,
374
- ''
375
- )
376
- // 2. Element allowlist — drop the markup of any tag whose LOCAL name isn't
377
- // in the fixed annotation vocabulary. `[^>]*` stops at the first `>`;
378
- // annotation attrs never contain a literal `>`.
379
- .replace(/<\/?\s*([a-zA-Z][\w:-]*)\b[^>]*>/g, (match, rawName: string) => {
380
- const local = String(rawName).split(':').pop()?.toLowerCase() ?? '';
381
- return ANNOTATION_SVG_ELEMENTS.has(local) ? match : '';
382
- })
383
- // 3. Attribute denylist on the surviving allowlisted elements — the legit
384
- // vocabulary uses no on*= / style= / *href=, so stripping them closes
385
- // inline handlers, CSS url(javascript:), and entity-encoded hrefs.
386
- // The leading boundary is a LOOKBEHIND on whitespace / quote / slash
387
- // (not a consumed `\s`) so a handler glued to the previous attribute's
388
- // closing quote — `<circle r="2"onload="…"/>`, which HTML/SVG parsers
389
- // accept as a distinct attribute — is also stripped (Phase 24 security
390
- // review, DDR-067). The non-consuming lookbehind leaves the preceding
391
- // quote intact so the legit attribute it belonged to survives.
392
- .replace(
393
- /(?<=[\s"'/])(?:on[a-z]+|style|(?:[\w-]+:)?href)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi,
394
- ''
395
- );
396
- // Post-pass — restore the validated assets href on <image>. Re-validate the
397
- // marker value (defense-in-depth: a forged data-mdcc-asset can only resolve to
398
- // a safe same-origin assets path; anything with a scheme/traversal is dropped).
399
- return cleaned.replace(/\sdata-mdcc-asset\s*=\s*"([^"]*)"/gi, (_whole, val: string) =>
400
- ASSET_IMAGE_HREF_RE.test(val) ? ` href="${val}"` : ''
401
- );
402
- }
403
-
404
353
  export function createApi(ctx: Context, hooks: ApiHooks): Api {
405
354
  const onCommentsChanged = hooks.onCommentsChanged;
406
355
  const onAnnotationsChanged = hooks.onAnnotationsChanged;
407
356
  const { paths, cfg } = ctx;
408
357
 
409
358
  function fileSlug(file: string): string {
410
- let p = String(file).replace(/^\/+|\/+$/g, '');
411
- try {
412
- p = decodeURIComponent(p);
413
- } catch {
414
- /* ignore */
415
- }
416
- const prefix = `${paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
417
- if (p.startsWith(prefix)) p = p.slice(prefix.length);
418
- return p
419
- .replace(/\//g, '-')
420
- .replace(/\s+/g, '_')
421
- .replace(/\.(tsx|html)$/i, '')
422
- .replace(/^\.+/, '')
423
- .toLowerCase();
359
+ return canvasSlugFromRel(file, paths.designRel);
424
360
  }
425
361
 
426
362
  async function fileForSlug(slug: string): Promise<string | null> {
@@ -716,21 +652,28 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
716
652
  }
717
653
  }
718
654
 
719
- // ---------- Canvas meta sidecar (Phase 4 T5) ----------
655
+ // ---------- Canvas meta sidecar (Phase 4 T5; split DDR-115) ----------
720
656
  //
721
657
  // Each canvas under `<designRoot>/ui/<name>.tsx` has a sibling
722
- // `<name>.meta.json`. Phase 4 stores `layout` (per-artboard world-coord
723
- // rects) and `viewport` (last pan/zoom) inside that file so the canvas
724
- // runtime can restore state on reload. The PATCH path is intentionally
725
- // merge-shallow on top-level keys never clobber `title`, `sections`,
726
- // `ai_context`, or any other authoring metadata.
658
+ // `<name>.meta.json` the SHARED, versioned document (title, sections,
659
+ // `layout` per-artboard world-coord rects, css_mode, …). The PATCH path is
660
+ // intentionally merge-shallow on top-level keys never clobber `title`,
661
+ // `sections`, `ai_context`, or any other authoring metadata.
662
+ //
663
+ // DDR-115 — the PER-USER camera (`viewport` pan/zoom) NO LONGER lives in
664
+ // `.meta.json`. It churns on every mouse pan/zoom, so persisting it inline
665
+ // dirtied a tracked file. It now lives in a gitignored per-machine view file
666
+ // (`canvasViewPath` below). PATCH splits the lanes (viewport → view file,
667
+ // layout → meta); GET merges them back so the client (`window.__canvas_meta__`)
668
+ // is unchanged. `last_modified` is stamped into meta ONLY on a real shared
669
+ // (layout) change — never on a viewport-only patch.
727
670
 
728
671
  /**
729
- * Resolve `file` (a path relative to repoRoot like `.design/ui/Foo.tsx`)
730
- * into the absolute path of its sibling `.meta.json` sidecar. Refuses
731
- * paths that escape repoRoot.
672
+ * Resolve `file` (a path relative to repoRoot like `.design/ui/Foo.tsx`) into
673
+ * the absolute path of the canvas SOURCE file. Refuses traversal, paths that
674
+ * escape repoRoot, and non-canvas extensions. Returns null on rejection.
732
675
  */
733
- function canvasMetaPath(file: string): string | null {
676
+ function canvasSourceAbs(file: string): string | null {
734
677
  let p = String(file).replace(/^\/+/, '');
735
678
  try {
736
679
  p = decodeURIComponent(p);
@@ -742,29 +685,133 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
742
685
  if (!abs.startsWith(`${paths.repoRoot}/`)) return null;
743
686
  const ext = path.extname(abs).toLowerCase();
744
687
  if (ext !== '.tsx' && ext !== '.html') return null;
745
- return abs.replace(/\.(tsx|html)$/i, '.meta.json');
688
+ return abs;
689
+ }
690
+
691
+ /**
692
+ * Resolve `file` into the absolute path of its sibling `.meta.json` sidecar.
693
+ * Same containment guard as `canvasSourceAbs` (refuses paths that escape
694
+ * repoRoot / non-canvas extensions).
695
+ */
696
+ function canvasMetaPath(file: string): string | null {
697
+ const abs = canvasSourceAbs(file);
698
+ return abs ? abs.replace(/\.(tsx|html)$/i, '.meta.json') : null;
699
+ }
700
+
701
+ // ---------- Per-machine canvas view / camera (DDR-115) ----------
702
+ //
703
+ // The canvas pan/zoom ("camera") is PER-USER runtime state, separate from the
704
+ // shared `.meta.json` document. It lives in `_canvas-state/<slug>.view.json`
705
+ // ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from
706
+ // the legacy `_canvas-state/<slug>.json` ({ sections, viewport:{x,y,scale} })
707
+ // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.1–4, so
708
+ // overloading one file would let the two writers clobber each other's shape.
709
+
710
+ /** Validate a candidate viewport — finite x/y, zoom clamped [0.1, 4] (the
711
+ * Phase 4 rule). Returns the normalized viewport, or null when invalid. */
712
+ function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null {
713
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
714
+ const vv = v as { x?: unknown; y?: unknown; zoom?: unknown };
715
+ if (
716
+ Number.isFinite(vv.x as number) &&
717
+ Number.isFinite(vv.y as number) &&
718
+ Number.isFinite(vv.zoom as number)
719
+ ) {
720
+ const zoom = Math.min(4, Math.max(0.1, vv.zoom as number));
721
+ return { x: vv.x as number, y: vv.y as number, zoom };
722
+ }
723
+ return null;
724
+ }
725
+
726
+ /** Per-machine view file for a canvas: `_canvas-state/<slug>.view.json`. Gated
727
+ * by the same containment guard as the meta sidecar (traversal / repoRoot /
728
+ * canvas-ext). Returns null when `file` is not a valid canvas path. */
729
+ function canvasViewPath(file: string): string | null {
730
+ if (!canvasMetaPath(file)) return null; // reuse the containment + ext gate
731
+ return path.join(paths.canvasStateDir, `${fileSlug(file)}.view.json`);
732
+ }
733
+
734
+ async function loadCanvasView(
735
+ file: string
736
+ ): Promise<{ viewport?: { x: number; y: number; zoom: number } } | null> {
737
+ const viewAbs = canvasViewPath(file);
738
+ if (!viewAbs) return null;
739
+ try {
740
+ const obj = JSON.parse(await Bun.file(viewAbs).text());
741
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
742
+ const vp = normalizeViewport((obj as { viewport?: unknown }).viewport);
743
+ return vp ? { viewport: vp } : {};
744
+ }
745
+ return null;
746
+ } catch {
747
+ return null;
748
+ }
749
+ }
750
+
751
+ /** Persist the per-user camera. Validates + clamps; best-effort (mkdir the
752
+ * bucket if absent). Returns the normalized viewport on write, null when the
753
+ * path is rejected or the viewport is invalid (no write). */
754
+ async function saveCanvasView(
755
+ file: string,
756
+ viewport: unknown
757
+ ): Promise<{ x: number; y: number; zoom: number } | null> {
758
+ const viewAbs = canvasViewPath(file);
759
+ if (!viewAbs) return null;
760
+ const vp = normalizeViewport(viewport);
761
+ if (!vp) return null;
762
+ try {
763
+ await mkdir(paths.canvasStateDir, { recursive: true });
764
+ await Bun.write(viewAbs, `${JSON.stringify({ viewport: vp }, null, 2)}\n`);
765
+ return vp;
766
+ } catch {
767
+ return null;
768
+ }
746
769
  }
747
770
 
748
771
  async function loadCanvasMeta(file: string): Promise<Record<string, unknown> | null> {
749
772
  const metaAbs = canvasMetaPath(file);
750
773
  if (!metaAbs) return null;
774
+ let obj: Record<string, unknown> = {};
775
+ let hadMeta = false;
751
776
  try {
752
- const raw = await Bun.file(metaAbs).text();
753
- const obj = JSON.parse(raw);
754
- return obj && typeof obj === 'object' && !Array.isArray(obj)
755
- ? (obj as Record<string, unknown>)
756
- : null;
777
+ const parsed = JSON.parse(await Bun.file(metaAbs).text());
778
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
779
+ obj = parsed as Record<string, unknown>;
780
+ hadMeta = true;
781
+ }
757
782
  } catch {
758
- return null;
783
+ // No meta on disk — fall through to a possible view-only result.
759
784
  }
785
+ // DDR-115 — never surface the per-user camera or the local write-timestamp
786
+ // from the on-disk meta: `viewport` is stale (the live camera lives in the
787
+ // view file), `last_modified` is local bookkeeping. Strip both, then overlay
788
+ // the current camera so the shell's `window.__canvas_meta__.viewport` still
789
+ // restores on reload — the client stays unchanged. (`= undefined` over
790
+ // `delete` — JSON.stringify/Response.json drop undefined keys, matching the
791
+ // codebase convention + biome's noDelete.)
792
+ obj.viewport = undefined;
793
+ obj.last_modified = undefined;
794
+ const view = await loadCanvasView(file);
795
+ if (view?.viewport) obj.viewport = view.viewport;
796
+ // Preserve the historic contract: no meta AND no camera → null (GET → {},
797
+ // PATCH-on-rejected-path → 404). A view-only canvas still returns its camera.
798
+ if (!hadMeta && !view?.viewport) return null;
799
+ return obj;
760
800
  }
761
801
 
762
802
  /**
763
- * Shallow-merge `patch` onto the existing meta sidecar and write back. Only
764
- * the Phase 4 keys `layout` + `viewport` are accepted from untrusted clients;
765
- * the rest of meta (title, sections, brief, ai_context, …) is preserved.
766
- * Returns the merged meta on success, null when the canvas has no meta or
767
- * the patch is rejected.
803
+ * Apply a `patch` from the (untrusted) client, splitting the two lanes
804
+ * (DDR-115):
805
+ * - `viewport` → the per-machine view file (`saveCanvasView`); NEVER the
806
+ * versioned meta. A viewport-only patch leaves `.meta.json` byte-unchanged
807
+ * (no `last_modified` bump) — this is the mouse-move churn killer.
808
+ * - `layout` → the shared `.meta.json`, shallow-merged so `title`,
809
+ * `sections`, `ai_context`, … are preserved; `last_modified` is stamped
810
+ * ONLY here (a real shared change the user wants committable).
811
+ * Returns the same coherent object a GET would produce (shared meta + camera),
812
+ * or null only when the path itself is rejected (traversal / bad ext) so the
813
+ * route maps it to 404. A viewport-only patch on a canvas that has no meta yet
814
+ * still succeeds (writes only the view file) and returns `{ viewport }`.
768
815
  */
769
816
  async function patchCanvasMeta(
770
817
  file: string,
@@ -773,43 +820,63 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
773
820
  const metaAbs = canvasMetaPath(file);
774
821
  if (!metaAbs) return null;
775
822
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return null;
776
- let current: Record<string, unknown> = {};
777
- try {
778
- const raw = await Bun.file(metaAbs).text();
779
- const parsed = JSON.parse(raw);
780
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
781
- current = parsed as Record<string, unknown>;
823
+
824
+ // DDR-115 security (F-A2) — the PATCH lanes are reachable from the untrusted
825
+ // canvas origin (DDR-054). Refuse to mint per-canvas state (view file or
826
+ // `.meta.json`) for a canvas that doesn't exist, so a malicious origin can't
827
+ // spray arbitrary-slug files/inodes via fabricated `file` paths. A valid
828
+ // patch only ever targets a canvas the user actually has; `.meta.json` may
829
+ // still be absent (first layout/viewport write), but the source must exist.
830
+ const srcAbs = canvasSourceAbs(file);
831
+ if (!srcAbs || !(await Bun.file(srcAbs).exists())) return null;
832
+
833
+ // --- Per-user camera lane: viewport → view file, never the versioned meta.
834
+ if (patch.viewport !== undefined) {
835
+ if (patch.viewport === null) {
836
+ // Explicit clear — best-effort remove the view file.
837
+ const viewAbs = canvasViewPath(file);
838
+ if (viewAbs) {
839
+ try {
840
+ await rm(viewAbs);
841
+ } catch {
842
+ /* absent / unreadable — nothing to clear */
843
+ }
844
+ }
845
+ } else {
846
+ // saveCanvasView validates + clamps; an invalid viewport is a silent no-op.
847
+ await saveCanvasView(file, patch.viewport);
782
848
  }
783
- } catch {
784
- // No existing meta — create one with just the Phase 4 keys.
785
849
  }
786
- const next = { ...current };
787
- // Whitelist of patchable top-level keys.
850
+
851
+ // --- Shared document lane: layout → versioned meta, stamps last_modified. ---
788
852
  if (patch.layout !== undefined) {
853
+ let current: Record<string, unknown> = {};
854
+ try {
855
+ const parsed = JSON.parse(await Bun.file(metaAbs).text());
856
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
857
+ current = parsed as Record<string, unknown>;
858
+ }
859
+ } catch {
860
+ // No existing meta — create one with just the layout key.
861
+ }
862
+ const next = { ...current };
789
863
  if (patch.layout === null) {
790
864
  next.layout = undefined;
791
865
  } else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) {
792
866
  next.layout = patch.layout;
793
867
  }
868
+ // Defensive: a stale inline viewport must never persist in the versioned
869
+ // file (the camera lane owns it now). JSON.stringify drops undefined keys.
870
+ next.viewport = undefined;
871
+ next.last_modified = new Date().toISOString();
872
+ // Trailing newline — consistent with canvas-create.ts + sync/codec.ts
873
+ // (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
874
+ await Bun.write(metaAbs, `${JSON.stringify(next, null, 2)}\n`);
794
875
  }
795
- if (patch.viewport !== undefined) {
796
- if (patch.viewport === null) {
797
- next.viewport = undefined;
798
- } else if (typeof patch.viewport === 'object' && !Array.isArray(patch.viewport)) {
799
- const v = patch.viewport as { x?: unknown; y?: unknown; zoom?: unknown };
800
- if (
801
- Number.isFinite(v.x as number) &&
802
- Number.isFinite(v.y as number) &&
803
- Number.isFinite(v.zoom as number)
804
- ) {
805
- const zoom = Math.min(4, Math.max(0.1, v.zoom as number));
806
- next.viewport = { x: v.x as number, y: v.y as number, zoom };
807
- }
808
- }
809
- }
810
- next.last_modified = new Date().toISOString();
811
- await Bun.write(metaAbs, JSON.stringify(next, null, 2));
812
- return next;
876
+
877
+ // Return the merged view (shared meta + camera) — identical to GET, so the
878
+ // client gets a coherent object regardless of which lane(s) the patch hit.
879
+ return await loadCanvasMeta(file);
813
880
  }
814
881
 
815
882
  // ---------- Annotations sidecar (Phase 5) ----------
@@ -1009,6 +1076,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1009
1076
  path.join(groupAbs, `${v.name}.meta.json`),
1010
1077
  `${JSON.stringify(meta, null, 2)}\n`
1011
1078
  );
1079
+ // Phase 30 — same-machine live tree refresh. Other tabs on THIS dev-server
1080
+ // re-read the (branch-scoped, on-disk) canvas list so a freshly-created
1081
+ // canvas appears without a reload. Cross-machine peers get the new canvas
1082
+ // via git "Get latest" — the file travels through git, this event is only a
1083
+ // "refresh your list" nudge for online local tabs (loopback inspector WS).
1084
+ ctx.bus.emit('canvas-list-update', { action: 'added', rel, slug });
1012
1085
  // designRel-prefixed path — matches the file-tree `file.path` shape so the
1013
1086
  // client can open it directly after reloadTree().
1014
1087
  return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
@@ -1110,6 +1183,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1110
1183
  path.join(paths.canvasStateDir, `${slug}.json`),
1111
1184
  `_canvas-state__${slug}.json`
1112
1185
  );
1186
+ // DDR-115 — the per-machine camera view file.
1187
+ await moveIfExists(
1188
+ path.join(paths.canvasStateDir, `${slug}.view.json`),
1189
+ `_canvas-state__${slug}.view.json`
1190
+ );
1113
1191
  await moveIfExists(path.join(paths.commentsDir, `${slug}.json`), `_comments__${slug}.json`);
1114
1192
 
1115
1193
  await Bun.write(
@@ -1117,6 +1195,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1117
1195
  `${JSON.stringify({ canvas: rel, slug, deletedAt: new Date().toISOString(), trashed }, null, 2)}\n`
1118
1196
  );
1119
1197
 
1198
+ // Phase 30 — live tree refresh for other local tabs (see createCanvas).
1199
+ ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
1120
1200
  return {
1121
1201
  ok: true,
1122
1202
  rel,
@@ -1126,6 +1206,174 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1126
1206
  };
1127
1207
  }
1128
1208
 
1209
+ // Phase 12 (DDR-103) — resolve a v2 canvas slug (`selected.canvas`: POSIX,
1210
+ // extension-less, designRoot-relative — matches `_locator.json` keys + the
1211
+ // `canvasSlug()` shape) to its absolute `.tsx` path, with a containment
1212
+ // backstop. Same main-origin-only trust boundary as createCanvas: the
1213
+ // untrusted canvas iframe origin never reaches a source-write endpoint.
1214
+ function resolveCanvasAbs(
1215
+ slugRaw: unknown
1216
+ ): { ok: true; abs: string } | { ok: false; status: number; error: string } {
1217
+ if (typeof slugRaw !== 'string' || !slugRaw.trim()) {
1218
+ return { ok: false, status: 400, error: 'canvas (slug) required' };
1219
+ }
1220
+ let slug = slugRaw.replace(/^\/+|\/+$/g, '').replace(/\.(tsx|html)$/i, '');
1221
+ // Accept either the bare slug (`ui/Foo`) or the designRel-prefixed file path
1222
+ // the client `selected.file` carries (`.design/ui/Foo.tsx`) — strip a leading
1223
+ // designRel so both shapes resolve to the same canvas (mirrors deriveCanvasSlug).
1224
+ const dr = paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
1225
+ if (dr && slug.startsWith(`${dr}/`)) slug = slug.slice(dr.length + 1);
1226
+ if (
1227
+ !slug ||
1228
+ path.isAbsolute(slug) ||
1229
+ slug.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')
1230
+ ) {
1231
+ return { ok: false, status: 400, error: 'invalid canvas slug' };
1232
+ }
1233
+ const abs = `${path.join(paths.designRoot, ...slug.split('/'))}.tsx`;
1234
+ const resolvedRoot = path.resolve(paths.designRoot);
1235
+ const resolved = path.resolve(abs);
1236
+ if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
1237
+ return { ok: false, status: 400, error: 'canvas resolves outside the design root' };
1238
+ }
1239
+ return { ok: true, abs };
1240
+ }
1241
+
1242
+ // 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
1243
+ const CD_ID_RE = /^[0-9a-f]{8}$/;
1244
+
1245
+ async function editCss(input: {
1246
+ canvas?: unknown;
1247
+ id?: unknown;
1248
+ property?: unknown;
1249
+ value?: unknown;
1250
+ reset?: unknown;
1251
+ }): Promise<EditOpResult> {
1252
+ const r = resolveCanvasAbs(input.canvas);
1253
+ if (!r.ok) return r;
1254
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
1255
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
1256
+ const property = typeof input.property === 'string' ? input.property.trim() : '';
1257
+ // CSS property names are ASCII letters + hyphens only (optionally a leading
1258
+ // `-` for vendor prefixes) — reject anything that could smuggle a second key
1259
+ // or an expression into the inline style object.
1260
+ if (!property || !/^-?[a-z][a-z-]*$/.test(property)) {
1261
+ return { ok: false, status: 400, error: 'invalid css property' };
1262
+ }
1263
+ // kebab → camelCase JSX style key.
1264
+ const camel = property.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
1265
+ // Phase 12.3 — `reset: true` REMOVES the inline property (back to class /
1266
+ // inherited). No value needed; a missing key is a no-op (delta 0).
1267
+ if (input.reset === true) {
1268
+ try {
1269
+ const res = await removeAttribute(r.abs, id, `style.${camel}`);
1270
+ return { ok: true, delta: res.delta };
1271
+ } catch (err) {
1272
+ return {
1273
+ ok: false,
1274
+ status: 422,
1275
+ error: err instanceof CanvasEditError ? err.message : 'reset failed',
1276
+ };
1277
+ }
1278
+ }
1279
+ const value = typeof input.value === 'string' ? input.value : '';
1280
+ if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
1281
+ if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
1282
+ // The value is always written as a JS STRING literal: JSON.stringify escapes
1283
+ // quotes/backslashes/newlines so it can never break out of the string, and
1284
+ // React accepts string values for every style prop — so `var(--accent)`,
1285
+ // `#fff`, `8px`, `700`, `1.5` all ride verbatim.
1286
+ try {
1287
+ const res = await editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value));
1288
+ return { ok: true, delta: res.delta };
1289
+ } catch (err) {
1290
+ return {
1291
+ ok: false,
1292
+ status: 422,
1293
+ error: err instanceof CanvasEditError ? err.message : 'edit failed',
1294
+ };
1295
+ }
1296
+ }
1297
+
1298
+ async function editText(input: {
1299
+ canvas?: unknown;
1300
+ id?: unknown;
1301
+ text?: unknown;
1302
+ }): Promise<EditOpResult> {
1303
+ const r = resolveCanvasAbs(input.canvas);
1304
+ if (!r.ok) return r;
1305
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
1306
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
1307
+ if (typeof input.text !== 'string') return { ok: false, status: 400, error: 'text required' };
1308
+ if (input.text.length > 5000) return { ok: false, status: 413, error: 'text too long' };
1309
+ try {
1310
+ const res = await runEditText(r.abs, id, input.text);
1311
+ return { ok: true, delta: res.delta };
1312
+ } catch (err) {
1313
+ return {
1314
+ ok: false,
1315
+ status: 422,
1316
+ error: err instanceof CanvasEditError ? err.message : 'edit failed',
1317
+ };
1318
+ }
1319
+ }
1320
+
1321
+ async function editAttr(input: {
1322
+ canvas?: unknown;
1323
+ id?: unknown;
1324
+ attr?: unknown;
1325
+ value?: unknown;
1326
+ reset?: unknown;
1327
+ }): Promise<EditOpResult> {
1328
+ const r = resolveCanvasAbs(input.canvas);
1329
+ if (!r.ok) return r;
1330
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
1331
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
1332
+ const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
1333
+ // Plain HTML attributes only — data-*, aria-*, role, title, id, lang, dir…
1334
+ // Reject `style*` (that's /_api/edit-css), `data-cd-id` (pipeline-owned, also
1335
+ // refused downstream by editAttribute), and anything that isn't a bare html
1336
+ // attribute name. Digits allowed mid-name (e.g. data-2x).
1337
+ if (
1338
+ !attr ||
1339
+ !/^[a-z][a-z0-9-]*$/.test(attr) ||
1340
+ attr === 'data-cd-id' ||
1341
+ attr.startsWith('style')
1342
+ ) {
1343
+ return { ok: false, status: 400, error: 'invalid attribute' };
1344
+ }
1345
+ // Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
1346
+ if (input.reset === true) {
1347
+ try {
1348
+ const res = await removeAttribute(r.abs, id, attr);
1349
+ return { ok: true, delta: res.delta };
1350
+ } catch (err) {
1351
+ return {
1352
+ ok: false,
1353
+ status: 422,
1354
+ error: err instanceof CanvasEditError ? err.message : 'reset failed',
1355
+ };
1356
+ }
1357
+ }
1358
+ const value = typeof input.value === 'string' ? input.value : '';
1359
+ if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
1360
+ if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
1361
+ try {
1362
+ // Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
1363
+ // Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
1364
+ // on replace, escapeAttr on insert) — pre-stringifying here double-encoded
1365
+ // the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
1366
+ const res = await editAttribute(r.abs, id, attr, value);
1367
+ return { ok: true, delta: res.delta };
1368
+ } catch (err) {
1369
+ return {
1370
+ ok: false,
1371
+ status: 422,
1372
+ error: err instanceof CanvasEditError ? err.message : 'edit failed',
1373
+ };
1374
+ }
1375
+ }
1376
+
1129
1377
  async function saveCanvasState(file: string, state: Record<string, unknown>) {
1130
1378
  if (!state || typeof state !== 'object') return;
1131
1379
  const safe: Record<string, unknown> = {};
@@ -1532,6 +1780,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1532
1780
  saveAsset,
1533
1781
  createCanvas,
1534
1782
  deleteCanvas,
1783
+ editCss,
1784
+ editText,
1785
+ editAttr,
1535
1786
  buildIndexData,
1536
1787
  buildSystemData,
1537
1788
  loadExportHistory,