@agent-native/core 0.80.9 → 0.80.10

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 (111) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/design-connect.ts +28 -2
  5. package/corpus/core/src/cli/skills.ts +45 -24
  6. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  7. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  8. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  9. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  10. package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
  11. package/corpus/templates/design/actions/apply-design-state.ts +200 -0
  12. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
  14. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  15. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  16. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  17. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  18. package/corpus/templates/design/actions/create-component.ts +447 -0
  19. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  20. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  21. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  22. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  23. package/corpus/templates/design/actions/get-component-details.ts +242 -0
  24. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  25. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  26. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  27. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  28. package/corpus/templates/design/actions/index-components.ts +258 -0
  29. package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
  30. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  31. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  32. package/corpus/templates/design/actions/list-design-states.ts +72 -0
  33. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  34. package/corpus/templates/design/actions/open-component-source.ts +237 -0
  35. package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
  36. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  37. package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
  38. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  39. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  40. package/corpus/templates/design/actions/run-design-audit.ts +421 -0
  41. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  42. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  43. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1414 -71
  44. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
  45. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  46. package/corpus/templates/design/app/components/design/EditPanel.tsx +1391 -52
  47. package/corpus/templates/design/app/components/design/LayersPanel.tsx +4 -3
  48. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  49. package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
  50. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1052 -98
  51. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
  52. package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
  53. package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
  54. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  55. package/corpus/templates/design/app/components/design/index.ts +16 -0
  56. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  57. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  58. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  59. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  60. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  61. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  62. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  63. package/corpus/templates/design/app/i18n/zh-TW.ts +31 -0
  64. package/corpus/templates/design/app/i18n-data.ts +269 -0
  65. package/corpus/templates/design/app/pages/DesignEditor.tsx +2478 -386
  66. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  67. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  68. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  69. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  70. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  71. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  72. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  73. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  74. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  75. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  76. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  77. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  78. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  79. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  80. package/corpus/templates/design/e2e/helpers.ts +10 -4
  81. package/corpus/templates/design/server/db/schema.ts +123 -0
  82. package/corpus/templates/design/server/plugins/db.ts +90 -0
  83. package/corpus/templates/design/shared/builder-app.ts +297 -0
  84. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  85. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  86. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  87. package/corpus/templates/design/shared/component-model.ts +239 -0
  88. package/corpus/templates/design/shared/design-review.ts +275 -0
  89. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  90. package/corpus/templates/design/shared/design-state.ts +112 -0
  91. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  92. package/corpus/templates/design/shared/motion-compiler.ts +278 -0
  93. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  94. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  95. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  96. package/corpus/templates/design/shared/source-mode.ts +245 -0
  97. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  98. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  99. package/dist/cli/design-connect.d.ts.map +1 -1
  100. package/dist/cli/design-connect.js +28 -2
  101. package/dist/cli/design-connect.js.map +1 -1
  102. package/dist/cli/skills.d.ts.map +1 -1
  103. package/dist/cli/skills.js +37 -21
  104. package/dist/cli/skills.js.map +1 -1
  105. package/dist/collab/routes.d.ts +1 -1
  106. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  107. package/dist/notifications/routes.d.ts +2 -2
  108. package/dist/observability/routes.d.ts +7 -7
  109. package/dist/resources/handlers.d.ts +2 -2
  110. package/dist/server/transcribe-voice.d.ts +1 -1
  111. package/package.json +1 -1
@@ -0,0 +1,404 @@
1
+ /**
2
+ * apply-shader-fill — PERSISTING apply action.
3
+ *
4
+ * Writes the chosen shader fill onto a design element as a CSS `background`,
5
+ * the same gradient the preview renders. This is the deliberate commit step
6
+ * after `preview-shader-fill`; preview stays non-persisting.
7
+ *
8
+ * Safety model (this action is SAFETY-gated, not Builder-gated):
9
+ * - Editor access is asserted on the target design before any write
10
+ * (`accessFilter` on the read + `assertAccess("design", id, "editor")`).
11
+ * - Only HTML design-file sources are writable — the deterministic HTML editor
12
+ * (`applyVisualEdit`) is the single mutation seam, exactly like
13
+ * `apply-visual-edit`. localhost / fusion / inline-html sources are NOT
14
+ * written; the caller gets the preview + an explanation instead.
15
+ * - The persisted value is a CSS `background` produced by
16
+ * `buildShaderFillBackground`, which runs every colour through the same strict
17
+ * CSS-colour allowlist that `preview-shader-fill` uses (`shader-fill.ts`), so a
18
+ * `descriptor.colors` payload can never inject CSS into the source.
19
+ * - The descriptor is validated against the preset manifest first, so callers
20
+ * get clear errors before any round-trip.
21
+ *
22
+ * The write reuses the canonical persist path (Yjs/collab + SQL via
23
+ * agentEnterDocument / applyText / seedFromText), so collaborators and the agent
24
+ * stay in sync. The edit is a single inline-style `style.background` change on
25
+ * the resolved node — no structural inserts, no new owned rows.
26
+ *
27
+ * Plan reference: DESIGN-STUDIO-PLAN.md §6.7 + §7 (shader fill apply).
28
+ */
29
+
30
+ import { defineAction } from "@agent-native/core";
31
+ import {
32
+ agentEnterDocument,
33
+ agentLeaveDocument,
34
+ agentUpdateSelection,
35
+ applyText,
36
+ getText,
37
+ hasCollabState,
38
+ seedFromText,
39
+ } from "@agent-native/core/collab";
40
+ import { accessFilter, assertAccess } from "@agent-native/core/sharing";
41
+ import { and, eq } from "drizzle-orm";
42
+ import { z } from "zod";
43
+
44
+ import { getDb, schema } from "../server/db/index.js";
45
+ import {
46
+ applyVisualEdit,
47
+ type CodeLayerSource,
48
+ type StyleEditIntent,
49
+ } from "../shared/code-layer.js";
50
+ import {
51
+ buildShaderFillBackground,
52
+ generateShaderFillFallbackCss,
53
+ generateShaderFillPreviewCss,
54
+ } from "../shared/shader-fill.js";
55
+ import {
56
+ SHADER_PRESET_MAP,
57
+ type ShaderDescriptor,
58
+ type ShaderPresetName,
59
+ validateDescriptor,
60
+ } from "../shared/shader-presets.js";
61
+
62
+ // ─── Schema ──────────────────────────────────────────────────────────────────
63
+
64
+ const PRESET_NAMES = Object.keys(SHADER_PRESET_MAP) as [
65
+ ShaderPresetName,
66
+ ...ShaderPresetName[],
67
+ ];
68
+
69
+ const descriptorSchema = z.object({
70
+ preset: z
71
+ .enum(PRESET_NAMES)
72
+ .describe("Shader preset name. One of: " + PRESET_NAMES.join(", ")),
73
+ params: z
74
+ .record(z.string(), z.union([z.number(), z.boolean(), z.string()]))
75
+ .optional()
76
+ .default({})
77
+ .describe("Shader-specific params. Merged with preset defaults."),
78
+ colors: z
79
+ .array(z.string())
80
+ .optional()
81
+ .describe(
82
+ "Colour palette override. Falls back to preset defaults. Every colour " +
83
+ "is validated against a strict CSS-colour allowlist before it is " +
84
+ "written; unsafe entries are neutralised, never injected.",
85
+ ),
86
+ speed: z.number().optional(),
87
+ frame: z.number().optional(),
88
+ fit: z.enum(["none", "contain", "cover"]).optional(),
89
+ scale: z.number().optional(),
90
+ rotation: z.number().optional().describe("Rotation in radians."),
91
+ offsetX: z.number().optional(),
92
+ offsetY: z.number().optional(),
93
+ });
94
+
95
+ const sourceSchema = z
96
+ .object({
97
+ kind: z
98
+ .enum(["design-file", "inline-html", "localhost", "fusion"])
99
+ .default("design-file"),
100
+ designId: z.string().optional(),
101
+ fileId: z.string().optional(),
102
+ filename: z.string().optional(),
103
+ revision: z.string().optional(),
104
+ })
105
+ .describe(
106
+ "Design source. Only kind=design-file (HTML) is persisted. Provide " +
107
+ "designId (and optional filename) or fileId.",
108
+ );
109
+
110
+ const targetSchema = z
111
+ .object({
112
+ nodeId: z.string().optional(),
113
+ selector: z.string().optional(),
114
+ })
115
+ .superRefine((target, ctx) => {
116
+ if (!target.nodeId && !target.selector) {
117
+ ctx.addIssue({
118
+ code: z.ZodIssueCode.custom,
119
+ path: ["nodeId"],
120
+ message: "target.nodeId or target.selector is required",
121
+ });
122
+ }
123
+ })
124
+ .describe(
125
+ "Target element by nodeId (data-agent-native-node-id) or selector.",
126
+ );
127
+
128
+ // ─── Persist helpers (mirrors apply-visual-edit.ts) ───────────────────────────
129
+
130
+ interface ResolvedDesignFile {
131
+ id: string;
132
+ designId: string;
133
+ filename: string;
134
+ content: string;
135
+ codeLayerSource: CodeLayerSource;
136
+ }
137
+
138
+ async function liveContent(
139
+ fileId: string,
140
+ storedContent: string,
141
+ ): Promise<string> {
142
+ try {
143
+ if (await hasCollabState(fileId)) {
144
+ const live = await getText(fileId, "content");
145
+ if (typeof live === "string") return live;
146
+ }
147
+ } catch {
148
+ // Collab reads are best-effort; SQL content remains the fallback.
149
+ }
150
+ return storedContent;
151
+ }
152
+
153
+ /**
154
+ * Resolve the target HTML design file with an access-scoped read, then assert
155
+ * editor access. Mirrors `resolveEditableDesignFile` in apply-visual-edit.ts so
156
+ * the shader-fill write goes through the exact same ownership gate.
157
+ */
158
+ async function resolveEditableDesignFile(source: {
159
+ designId?: string;
160
+ fileId?: string;
161
+ filename?: string;
162
+ revision?: string;
163
+ }): Promise<ResolvedDesignFile> {
164
+ if (!source.fileId && !source.designId) {
165
+ throw new Error(
166
+ "source.designId or source.fileId is required for design-file.",
167
+ );
168
+ }
169
+
170
+ const db = getDb();
171
+ const conditions = [
172
+ accessFilter(schema.designs, schema.designShares),
173
+ source.fileId
174
+ ? eq(schema.designFiles.id, source.fileId)
175
+ : eq(schema.designFiles.designId, source.designId ?? ""),
176
+ ];
177
+ if (!source.fileId) {
178
+ conditions.push(
179
+ eq(schema.designFiles.filename, source.filename ?? "index.html"),
180
+ );
181
+ }
182
+
183
+ const [file] = await db
184
+ .select({
185
+ id: schema.designFiles.id,
186
+ designId: schema.designFiles.designId,
187
+ filename: schema.designFiles.filename,
188
+ fileType: schema.designFiles.fileType,
189
+ content: schema.designFiles.content,
190
+ })
191
+ .from(schema.designFiles)
192
+ .innerJoin(
193
+ schema.designs,
194
+ eq(schema.designFiles.designId, schema.designs.id),
195
+ )
196
+ .where(and(...conditions))
197
+ .limit(1);
198
+
199
+ if (!file) {
200
+ throw new Error("Design HTML file not found.");
201
+ }
202
+ if (file.fileType !== "html") {
203
+ throw new Error(
204
+ "Shader fills can only be persisted onto HTML design files for now.",
205
+ );
206
+ }
207
+ if (source.designId && file.designId !== source.designId) {
208
+ throw new Error(
209
+ `source.designId "${source.designId}" does not match file "${file.id}"`,
210
+ );
211
+ }
212
+ if (!source.fileId && source.filename && file.filename !== source.filename) {
213
+ throw new Error(
214
+ `source.filename "${source.filename}" does not match file "${file.id}"`,
215
+ );
216
+ }
217
+
218
+ await assertAccess("design", file.designId, "editor");
219
+
220
+ return {
221
+ id: file.id,
222
+ designId: file.designId,
223
+ filename: file.filename,
224
+ content: await liveContent(file.id, file.content ?? ""),
225
+ codeLayerSource: {
226
+ kind: "design-file",
227
+ designId: file.designId,
228
+ fileId: file.id,
229
+ filename: file.filename,
230
+ revision: source.revision,
231
+ },
232
+ };
233
+ }
234
+
235
+ async function persistDesignFileEdit(file: {
236
+ id: string;
237
+ designId: string;
238
+ content: string;
239
+ }): Promise<void> {
240
+ await assertAccess("design", file.designId, "editor");
241
+
242
+ const db = getDb();
243
+ const now = new Date().toISOString();
244
+
245
+ agentEnterDocument(file.id);
246
+ try {
247
+ await db
248
+ .update(schema.designFiles)
249
+ .set({ content: file.content, updatedAt: now })
250
+ .where(eq(schema.designFiles.id, file.id));
251
+
252
+ if (await hasCollabState(file.id)) {
253
+ await applyText(file.id, file.content, "content", "agent");
254
+ } else {
255
+ await seedFromText(file.id, file.content);
256
+ }
257
+
258
+ // guard:allow-unscoped — editor access on this design is asserted above
259
+ // (and again at the top of this helper); this only bumps the addressed
260
+ // design row's updatedAt.
261
+ await db
262
+ .update(schema.designs)
263
+ .set({ updatedAt: now })
264
+ .where(eq(schema.designs.id, file.designId));
265
+ } finally {
266
+ agentLeaveDocument(file.id);
267
+ }
268
+ }
269
+
270
+ // ─── Action ──────────────────────────────────────────────────────────────────
271
+
272
+ export default defineAction({
273
+ description: `
274
+ Persist a shader fill onto a design element as a CSS \`background\`.
275
+
276
+ This is the commit step after preview-shader-fill. It writes the same gradient
277
+ the preview renders onto the target element's inline style.background, using the
278
+ deterministic HTML editor (the same persist path as apply-visual-edit).
279
+
280
+ Gating: editor access on the target design is asserted before any write. Only
281
+ HTML design-file sources are persisted; localhost / fusion / inline-html sources
282
+ return the preview CSS plus an explanation and write nothing. Every colour is
283
+ validated against the shared CSS-colour allowlist before it is written, so a
284
+ descriptor.colors payload can never inject CSS into the source.
285
+
286
+ Returns:
287
+ - persisted — true only when the source HTML was actually changed and saved.
288
+ - background — the CSS \`background\` value written (or that would be written).
289
+ - fallbackCss — a simpler static CSS background for export / PDF / SSR.
290
+ - descriptor — the resolved and validated ShaderDescriptor.
291
+ - result — the deterministic edit PatchResult (status, target, message).
292
+ - bytesBefore / bytesAfter — proof-of-change for the persisted file.
293
+
294
+ To preview without writing, call preview-shader-fill. To get a manual-edit code
295
+ snippet (WebGL canvas / JSX component), call apply-shader.
296
+ `.trim(),
297
+ schema: z.object({
298
+ descriptor: descriptorSchema.describe("Shader preset + params to persist."),
299
+ target: targetSchema,
300
+ source: sourceSchema,
301
+ }),
302
+ run: async ({ descriptor: rawDescriptor, target, source }) => {
303
+ const descriptor: ShaderDescriptor = {
304
+ preset: rawDescriptor.preset as ShaderPresetName,
305
+ params: rawDescriptor.params ?? {},
306
+ colors: rawDescriptor.colors,
307
+ speed: rawDescriptor.speed,
308
+ frame: rawDescriptor.frame,
309
+ fit: rawDescriptor.fit,
310
+ scale: rawDescriptor.scale,
311
+ rotation: rawDescriptor.rotation,
312
+ offsetX: rawDescriptor.offsetX,
313
+ offsetY: rawDescriptor.offsetY,
314
+ };
315
+
316
+ // Validate against the preset manifest before any write so the caller gets
317
+ // clear errors without a wasted DB / collab round-trip.
318
+ const validation = validateDescriptor(descriptor);
319
+ if (!validation.valid) {
320
+ return {
321
+ ok: false,
322
+ persisted: false,
323
+ errors: validation.errors,
324
+ descriptor,
325
+ hint: "Fix the descriptor errors and retry. Call get-shader to see the full preset catalog.",
326
+ };
327
+ }
328
+
329
+ // Resolve the CSS `background` to write. Every colour is run through the
330
+ // strict CSS-colour allowlist here (same path preview-shader-fill uses).
331
+ const { background, colors } = buildShaderFillBackground(descriptor);
332
+ const fallbackCss = generateShaderFillFallbackCss(descriptor);
333
+
334
+ // Only HTML design-file sources are persisted. Everything else gets the
335
+ // preview value plus an explanation and writes nothing — the deterministic
336
+ // HTML editor is the single safe mutation seam.
337
+ if (source.kind !== "design-file") {
338
+ return {
339
+ ok: true,
340
+ persisted: false,
341
+ descriptor,
342
+ background: generateShaderFillPreviewCss(descriptor),
343
+ fallbackCss,
344
+ colors,
345
+ note:
346
+ `Shader fills are only persisted onto HTML design-file sources. ` +
347
+ `Source kind "${source.kind}" was not written. Inject the returned ` +
348
+ `background via the bridge for a live preview, or call apply-shader ` +
349
+ `for a manual-edit code snippet.`,
350
+ };
351
+ }
352
+
353
+ const file = await resolveEditableDesignFile(source);
354
+
355
+ const intent: StyleEditIntent = {
356
+ kind: "style",
357
+ target: { nodeId: target.nodeId, selector: target.selector },
358
+ property: "background",
359
+ value: background,
360
+ };
361
+
362
+ const patch = applyVisualEdit(file.content, intent, {
363
+ source: file.codeLayerSource,
364
+ });
365
+
366
+ if (patch.result.target) {
367
+ agentUpdateSelection(file.id, {
368
+ selection: patch.result.target.selector,
369
+ nodeId: patch.result.target.nodeId,
370
+ editingFile: file.filename,
371
+ designId: file.designId,
372
+ });
373
+ }
374
+
375
+ const changed =
376
+ patch.result.status === "applied" && patch.result.changed === true;
377
+
378
+ if (changed) {
379
+ await persistDesignFileEdit({
380
+ id: file.id,
381
+ designId: file.designId,
382
+ content: patch.content,
383
+ });
384
+ }
385
+
386
+ return {
387
+ ok: patch.result.status === "applied",
388
+ persisted: changed,
389
+ descriptor,
390
+ background,
391
+ fallbackCss,
392
+ colors,
393
+ designId: file.designId,
394
+ fileId: file.id,
395
+ filename: file.filename,
396
+ result: patch.result,
397
+ bytesBefore: file.content.length,
398
+ bytesAfter: patch.content.length,
399
+ note: changed
400
+ ? "Shader fill persisted as the element's CSS background."
401
+ : "No change was written — the deterministic editor could not apply the edit. See result for details.",
402
+ };
403
+ },
404
+ });
@@ -15,12 +15,117 @@ import { z } from "zod";
15
15
  import { getDb, schema } from "../server/db/index.js";
16
16
  import {
17
17
  applyVisualEdit,
18
+ type AutoLayoutEditIntent,
19
+ type ClassEditIntent,
18
20
  type CodeLayerSource,
19
21
  type EditIntent,
22
+ type UnwrapEditIntent,
23
+ type WrapNodesEditIntent,
20
24
  } from "../shared/code-layer.js";
25
+ import type { TailwindBreakpointPrefix } from "../shared/design-state.js";
26
+ import { utilityStem, widthToPrefix } from "../shared/responsive-classes.js";
21
27
 
22
28
  type VisualEditActionSource = CodeLayerSource & { html?: string };
23
29
 
30
+ /** Tailwind responsive prefix values accepted by the action. */
31
+ const TAILWIND_PREFIXES = ["base", "sm", "md", "lg", "xl", "2xl"] as const;
32
+
33
+ /**
34
+ * Resolve the active breakpoint prefix for a class edit.
35
+ *
36
+ * - If `activeBreakpoint` is provided it is used directly.
37
+ * - If only `activeFrameWidthPx` is provided the prefix is derived via `widthToPrefix`.
38
+ * - If neither is provided the result is `null` (= no breakpoint scoping; global
39
+ * class edit, current backward-compatible behaviour).
40
+ */
41
+ function resolveActivePrefix(
42
+ activeBreakpoint?: TailwindBreakpointPrefix | null,
43
+ activeFrameWidthPx?: number | null,
44
+ ): TailwindBreakpointPrefix | null {
45
+ if (activeBreakpoint != null) return activeBreakpoint;
46
+ if (activeFrameWidthPx != null) return widthToPrefix(activeFrameWidthPx);
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Derive a CSS-property key from a Tailwind class token for use in
52
+ * `responsive-class` `"remove"` operations (e.g. `"text-lg"` → `"font-size"`).
53
+ *
54
+ * Delegates to the shared `utilityStem` so the key matches EXACTLY what
55
+ * `setPropertyClass`/`removePropertyClass` compute internally — a divergent
56
+ * local heuristic would make breakpoint-scoped removes silently miss (and, with
57
+ * the old first-segment heuristic, nuke unrelated utilities like `text-center`).
58
+ */
59
+ function stemFromToken(token: string): string {
60
+ // Strip any responsive prefix (e.g. "md:text-sm" → "text-sm").
61
+ const prefixMatch = /^(?:2xl|xl|lg|md|sm):/.exec(token);
62
+ const utility = prefixMatch ? token.slice(prefixMatch[0].length) : token;
63
+ return utilityStem(utility);
64
+ }
65
+
66
+ /**
67
+ * Convert a global `ClassEditIntent` into the equivalent `EditIntent` scoped to
68
+ * the given breakpoint prefix.
69
+ *
70
+ * - `"add"` and `"replace"` become `"responsive-class"` edits that write /
71
+ * replace the utility at the target prefix.
72
+ * - `"remove"` becomes a `"responsive-class"` remove that strips the utility
73
+ * stem at the target prefix.
74
+ * - `"set"` has no direct per-breakpoint analog (it replaces the whole class
75
+ * list) and is passed through unchanged so existing behaviour is preserved.
76
+ *
77
+ * When `prefix` is `"base"`, the intent is returned unchanged because
78
+ * `setPropertyClass(className, "base", utility)` is equivalent to a
79
+ * global unprefixed add/replace and the existing `"class"` path already
80
+ * handles it correctly.
81
+ */
82
+ function scopeClassIntentToBreakpoint(
83
+ intent: ClassEditIntent,
84
+ prefix: TailwindBreakpointPrefix,
85
+ ): EditIntent {
86
+ if (prefix === "base") return intent;
87
+
88
+ if (intent.operation === "add") {
89
+ const tokens =
90
+ intent.classNames ?? (intent.className ? [intent.className] : []);
91
+ if (tokens.length !== 1 || !tokens[0]) return intent;
92
+ return {
93
+ kind: "responsive-class",
94
+ target: intent.target,
95
+ prefix,
96
+ operation: "add",
97
+ utility: tokens[0],
98
+ };
99
+ }
100
+
101
+ if (intent.operation === "replace") {
102
+ if (!intent.to) return intent;
103
+ return {
104
+ kind: "responsive-class",
105
+ target: intent.target,
106
+ prefix,
107
+ operation: "replace",
108
+ utility: intent.to,
109
+ };
110
+ }
111
+
112
+ if (intent.operation === "remove") {
113
+ const tokens =
114
+ intent.classNames ?? (intent.className ? [intent.className] : []);
115
+ if (tokens.length !== 1 || !tokens[0]) return intent;
116
+ return {
117
+ kind: "responsive-class",
118
+ target: intent.target,
119
+ prefix,
120
+ operation: "remove",
121
+ stem: stemFromToken(tokens[0]),
122
+ };
123
+ }
124
+
125
+ // "set" — no per-breakpoint analog; fall back to global class edit.
126
+ return intent;
127
+ }
128
+
24
129
  function parseJsonString(value: unknown): unknown {
25
130
  if (typeof value !== "string") return value;
26
131
  try {
@@ -39,7 +144,7 @@ const sourceSchema = z.preprocess(
39
144
  .default("design-file"),
40
145
  designId: z.string().optional(),
41
146
  fileId: z.string().optional(),
42
- filename: z.string().optional().default("index.html"),
147
+ filename: z.string().optional(),
43
148
  path: z.string().optional(),
44
149
  url: z.string().optional(),
45
150
  revision: z.string().optional(),
@@ -110,6 +215,50 @@ const intentSchema = z.preprocess(
110
215
  anchor: targetSchema,
111
216
  placement: z.enum(["before", "after", "inside"]),
112
217
  }),
218
+ z.object({
219
+ kind: z.literal("wrapNodes"),
220
+ targetIds: z
221
+ .array(z.string())
222
+ .min(1)
223
+ .describe(
224
+ "data-agent-native-node-id values of sibling nodes to group. All must share a common parent.",
225
+ ),
226
+ autoLayout: z
227
+ .boolean()
228
+ .optional()
229
+ .describe(
230
+ "When true the wrapper gets display:flex; flex-direction:column; gap:8px and absolute positioning is stripped from each wrapped child.",
231
+ ),
232
+ }) satisfies z.ZodType<WrapNodesEditIntent>,
233
+ z.object({
234
+ kind: z.literal("unwrap"),
235
+ targetId: z
236
+ .string()
237
+ .describe(
238
+ "data-agent-native-node-id of the wrapper to remove, promoting its children to the wrapper's parent.",
239
+ ),
240
+ }) satisfies z.ZodType<UnwrapEditIntent>,
241
+ z.object({
242
+ kind: z.literal("autoLayout"),
243
+ targetId: z
244
+ .string()
245
+ .describe(
246
+ "data-agent-native-node-id of the container to convert to/from auto-layout.",
247
+ ),
248
+ enabled: z
249
+ .boolean()
250
+ .describe(
251
+ "true = enable auto-layout (display:flex + direction + gap, strip absolute positioning from direct children); false = set display:block.",
252
+ ),
253
+ direction: z
254
+ .enum(["row", "column"])
255
+ .optional()
256
+ .describe("Flex direction when enabling. Defaults to column."),
257
+ gap: z
258
+ .string()
259
+ .optional()
260
+ .describe("Gap value when enabling. Defaults to 8px."),
261
+ }) satisfies z.ZodType<AutoLayoutEditIntent>,
113
262
  ]),
114
263
  );
115
264
 
@@ -183,7 +332,7 @@ async function resolveEditableDesignFile(
183
332
  `source.designId "${source.designId}" does not match file "${file.id}"`,
184
333
  );
185
334
  }
186
- if (source.filename && file.filename !== source.filename) {
335
+ if (!source.fileId && source.filename && file.filename !== source.filename) {
187
336
  throw new Error(
188
337
  `source.filename "${source.filename}" does not match file "${file.id}"`,
189
338
  );
@@ -241,7 +390,8 @@ async function persistDesignFileEdit(file: {
241
390
  export default defineAction({
242
391
  description:
243
392
  "Apply one deterministic visual edit to a code-backed HTML design layer. " +
244
- "Supports safe inline style, class, and leaf textContent edits on inline/SQL HTML files; escalates ambiguous or structural edits with PatchResult statuses.",
393
+ "Supports safe inline style, class, and leaf textContent edits on inline/SQL HTML files; escalates ambiguous or structural edits with PatchResult statuses. " +
394
+ "Pass activeBreakpoint (or activeFrameWidthPx) to scope a class edit to a specific Tailwind responsive prefix; omit for global (backward-compatible) behaviour.",
245
395
  schema: z.object({
246
396
  source: sourceSchema.describe(
247
397
  "Edit source. Use kind=design-file with designId/filename or fileId to persist into SQL; kind=inline-html with html for a preview-only patch.",
@@ -254,10 +404,46 @@ export default defineAction({
254
404
  .optional()
255
405
  .default(false)
256
406
  .describe("Include patched HTML content in the response."),
407
+ activeBreakpoint: z
408
+ .enum(TAILWIND_PREFIXES)
409
+ .optional()
410
+ .nullable()
411
+ .describe(
412
+ "Active canvas breakpoint prefix. When set and the intent is a 'class' edit, the change is written as a breakpoint-scoped Tailwind class (e.g. 'md:text-lg') instead of a global class. 'base' writes an unprefixed class (same as omitting this field). Takes priority over activeFrameWidthPx.",
413
+ ),
414
+ activeFrameWidthPx: z
415
+ .number()
416
+ .int()
417
+ .positive()
418
+ .optional()
419
+ .nullable()
420
+ .describe(
421
+ "Canvas frame width in pixels. When activeBreakpoint is not set, this is used to derive the Tailwind responsive prefix (via widthToPrefix) and scope class edits accordingly. Ignored when activeBreakpoint is provided.",
422
+ ),
257
423
  }),
258
- run: async ({ source, intent, includeContent }) => {
424
+ run: async ({
425
+ source,
426
+ intent,
427
+ includeContent,
428
+ activeBreakpoint,
429
+ activeFrameWidthPx,
430
+ }) => {
259
431
  const actionSource = source as VisualEditActionSource;
260
- const editIntent = intent as EditIntent;
432
+ let editIntent = intent as EditIntent;
433
+
434
+ // Breakpoint scoping: when the caller specifies an active breakpoint (or
435
+ // frame width) and the intent is a plain `"class"` edit, convert it to a
436
+ // `"responsive-class"` edit scoped to the appropriate Tailwind prefix.
437
+ // All other intent kinds (style, textContent, moveNode, responsive-class)
438
+ // are passed through unchanged — the caller either already supplied the
439
+ // correct prefix (responsive-class) or does not need one (the others).
440
+ const activePrefix = resolveActivePrefix(
441
+ activeBreakpoint,
442
+ activeFrameWidthPx,
443
+ );
444
+ if (activePrefix !== null && editIntent.kind === "class") {
445
+ editIntent = scopeClassIntentToBreakpoint(editIntent, activePrefix);
446
+ }
261
447
 
262
448
  if (actionSource.kind === "inline-html") {
263
449
  const codeLayerSource: CodeLayerSource = {