@agent-native/core 0.85.6 → 0.85.7

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 (80) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  5. package/corpus/core/src/collab/presence.ts +63 -3
  6. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  7. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  8. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  9. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  10. package/corpus/templates/design/AGENTS.md +33 -1
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  12. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  13. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  14. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  15. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  16. package/corpus/templates/design/actions/export-zip.ts +10 -1
  17. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  18. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  19. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  20. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  21. package/corpus/templates/design/actions/present-design-variants.ts +2 -2
  22. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  23. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  24. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  25. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  26. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  27. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  28. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  29. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
  30. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  32. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  33. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  34. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  35. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  36. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  37. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  38. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  39. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  40. package/corpus/templates/design/app/components/design/types.ts +16 -0
  41. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  42. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  43. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  44. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/design/app/i18n-data.ts +18 -0
  46. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  47. package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
  48. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  56. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  57. package/corpus/templates/design/shared/board-file.ts +25 -5
  58. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  59. package/corpus/templates/design/shared/code-layer.ts +304 -26
  60. package/corpus/templates/design/shared/color-utils.ts +84 -0
  61. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  62. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  63. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  64. package/corpus/templates/design/shared/pen-path.ts +200 -23
  65. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  66. package/dist/client/use-pinch-zoom.js +76 -11
  67. package/dist/client/use-pinch-zoom.js.map +1 -1
  68. package/dist/collab/awareness.d.ts +2 -2
  69. package/dist/collab/awareness.d.ts.map +1 -1
  70. package/dist/collab/presence.d.ts.map +1 -1
  71. package/dist/collab/presence.js +54 -3
  72. package/dist/collab/presence.js.map +1 -1
  73. package/dist/collab/routes.d.ts +2 -2
  74. package/dist/collab/struct-routes.d.ts +1 -1
  75. package/dist/observability/routes.d.ts +3 -3
  76. package/dist/progress/routes.d.ts +1 -1
  77. package/dist/resources/handlers.d.ts +2 -2
  78. package/dist/secrets/routes.d.ts +3 -3
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. package/package.json +1 -1
@@ -3,7 +3,7 @@ import {
3
3
  getRequestOrgId,
4
4
  getRequestUserEmail,
5
5
  } from "@agent-native/core/server/request-context";
6
- import { and, eq } from "drizzle-orm";
6
+ import { eq } from "drizzle-orm";
7
7
  import { nanoid } from "nanoid";
8
8
  import { z } from "zod";
9
9
 
@@ -159,20 +159,26 @@ export default defineAction({
159
159
  status: "available" as const,
160
160
  }));
161
161
 
162
+ // The id may already be taken by a DIFFERENT user (legacy ids were derived
163
+ // from devServerUrl + rootPath without user scoping, so two users on the
164
+ // same devcontainer image collide). Detect that up front and fail with a
165
+ // clear error instead of crashing on the primary-key insert below.
162
166
  const existing = await db
163
167
  .select({
164
- id: schema.designLocalhostConnections.id,
168
+ ownerEmail: schema.designLocalhostConnections.ownerEmail,
165
169
  bridgeToken: schema.designLocalhostConnections.bridgeToken,
166
170
  })
167
171
  .from(schema.designLocalhostConnections)
168
- .where(
169
- and(
170
- eq(schema.designLocalhostConnections.id, id),
171
- eq(schema.designLocalhostConnections.ownerEmail, ownerEmail),
172
- ),
173
- )
172
+ .where(eq(schema.designLocalhostConnections.id, id))
174
173
  .limit(1);
175
174
 
175
+ if (existing[0] && existing[0].ownerEmail !== ownerEmail) {
176
+ throw new Error(
177
+ `Connection id "${id}" already belongs to another user. ` +
178
+ "Omit id so a per-user connection id is derived instead.",
179
+ );
180
+ }
181
+
176
182
  const nextBridgeToken = args.bridgeToken?.trim() || undefined;
177
183
  const values = {
178
184
  id,
@@ -191,17 +197,18 @@ export default defineAction({
191
197
  updatedAt: now,
192
198
  };
193
199
 
194
- if (existing[0]) {
195
- await db
196
- .update(schema.designLocalhostConnections)
197
- .set(values)
198
- .where(eq(schema.designLocalhostConnections.id, id));
199
- } else {
200
- await db.insert(schema.designLocalhostConnections).values({
201
- ...values,
202
- createdAt: now,
200
+ // Atomic upsert keyed on the id primary key — no check-then-insert race.
201
+ // setWhere keeps a concurrent insert by another user (TOCTOU on the
202
+ // ownership check above) from being overwritten: on a cross-user conflict
203
+ // the update filters to a no-op instead of hijacking the other row.
204
+ await db
205
+ .insert(schema.designLocalhostConnections)
206
+ .values({ ...values, createdAt: now })
207
+ .onConflictDoUpdate({
208
+ target: schema.designLocalhostConnections.id,
209
+ set: values,
210
+ setWhere: eq(schema.designLocalhostConnections.ownerEmail, ownerEmail),
203
211
  });
204
- }
205
212
 
206
213
  return {
207
214
  id,
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import { injectHiddenLayerExportStyle } from "../server/lib/design-export.js";
7
8
  import { isBoardFile } from "../shared/board-file.js";
8
9
  import "../server/db/index.js"; // ensure registerShareableResource runs
9
10
 
@@ -40,7 +41,13 @@ export default defineAction({
40
41
  id: f.id,
41
42
  filename: f.filename,
42
43
  fileType: f.fileType,
43
- content: f.content,
44
+ // Layers toggled hidden in the editor are only suppressed by the live
45
+ // editor bridge; inject the same display:none rule so the client-side
46
+ // PDF render (html2canvas over this HTML) doesn't reveal them.
47
+ content:
48
+ f.fileType === "html" && f.content
49
+ ? injectHiddenLayerExportStyle(f.content)
50
+ : f.content,
44
51
  })),
45
52
  exportInfo: {
46
53
  format: "pdf",
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { getDb, schema } from "../server/db/index.js";
7
7
  import {
8
8
  exportFilename,
9
+ injectHiddenLayerExportStyle,
9
10
  trySaveExportFile,
10
11
  } from "../server/lib/design-export.js";
11
12
  import { isBoardFile } from "../shared/board-file.js";
@@ -71,7 +72,15 @@ export default defineAction({
71
72
  file.filename,
72
73
  `design-file-${index + 1}.txt`,
73
74
  );
74
- zip.file(filename, file.content ?? "");
75
+ // Layers toggled hidden in the editor are only suppressed by the live
76
+ // editor bridge; inject the same display:none rule into exported HTML
77
+ // files so opening them directly from the zip doesn't reveal layers
78
+ // the user hid in the editor.
79
+ const content =
80
+ file.fileType === "html"
81
+ ? injectHiddenLayerExportStyle(file.content ?? "")
82
+ : (file.content ?? "");
83
+ zip.file(filename, content);
75
84
  }
76
85
 
77
86
  // Add design data if present
@@ -1,5 +1,4 @@
1
1
  import { defineAction } from "@agent-native/core";
2
- import { getText, hasCollabState } from "@agent-native/core/collab";
3
2
  import { assertAccess } from "@agent-native/core/sharing";
4
3
  import { and, desc, eq } from "drizzle-orm";
5
4
  import { z } from "zod";
@@ -10,6 +9,7 @@ import {
10
9
  extractManagedMotionCss,
11
10
  hashCss,
12
11
  parse,
12
+ parseFirstAnimationDurationMs,
13
13
  } from "../shared/motion-compiler.js";
14
14
  import type { MotionTrack } from "../shared/motion-timeline.js";
15
15
 
@@ -30,21 +30,6 @@ interface TimelineResult {
30
30
  updatedAt: string | null;
31
31
  }
32
32
 
33
- async function liveFileContent(
34
- fileId: string,
35
- storedContent: string,
36
- ): Promise<string> {
37
- try {
38
- if (await hasCollabState(fileId)) {
39
- const live = await getText(fileId, "content");
40
- if (typeof live === "string") return live;
41
- }
42
- } catch {
43
- // Best-effort; SQL is the fallback.
44
- }
45
- return storedContent;
46
- }
47
-
48
33
  function parseTrackJson(raw: string | null): MotionTrack[] {
49
34
  try {
50
35
  const parsed: unknown = JSON.parse(raw ?? "[]");
@@ -57,7 +42,12 @@ function parseTrackJson(raw: string | null): MotionTrack[] {
57
42
  async function readManagedCssForSource(args: {
58
43
  designId: string;
59
44
  sourceRef?: string;
60
- }): Promise<{ css: string; hash: string; tracks: MotionTrack[] } | null> {
45
+ }): Promise<{
46
+ css: string;
47
+ hash: string;
48
+ tracks: MotionTrack[];
49
+ durationMs: number | null;
50
+ } | null> {
61
51
  if (!args.sourceRef) return null;
62
52
 
63
53
  const db = getDb();
@@ -77,7 +67,13 @@ async function readManagedCssForSource(args: {
77
67
 
78
68
  if (!file) return null;
79
69
 
80
- const content = await liveFileContent(file.id, file.content ?? "");
70
+ // Read the managed block from the SQL content, NOT a live collab snapshot:
71
+ // apply-motion-edit persists the managed <style> block to SQL only, so a
72
+ // live collab session's text lags the freshest motion CSS. Comparing the
73
+ // stored compiledHash against stale collab CSS would flag phantom
74
+ // "stored-css-drift" right after every save and replace fresh tracks with
75
+ // stale CSS-parsed ones. SQL is the motion block's source of truth.
76
+ const content = file.content ?? "";
81
77
  const css = extractManagedMotionCss(content);
82
78
  if (!css) return null;
83
79
 
@@ -88,7 +84,12 @@ async function readManagedCssForSource(args: {
88
84
  );
89
85
  if (tracks.length === 0) return null;
90
86
 
91
- return { css, hash: hashCss(css), tracks };
87
+ return {
88
+ css,
89
+ hash: hashCss(css),
90
+ tracks,
91
+ durationMs: parseFirstAnimationDurationMs(css),
92
+ };
92
93
  }
93
94
 
94
95
  export default defineAction({
@@ -179,7 +180,9 @@ export default defineAction({
179
180
  sourceRef: sourceRef ?? null,
180
181
  filePath: null,
181
182
  tracks: managedCss.tracks,
182
- durationMs: 1000,
183
+ // Recover the compiled animation-duration instead of inventing a
184
+ // default the next save would silently persist.
185
+ durationMs: managedCss.durationMs ?? 1000,
183
186
  defaultEase: "ease",
184
187
  compiledHash: managedCss.hash,
185
188
  cssHash: managedCss.hash,
@@ -191,6 +194,9 @@ export default defineAction({
191
194
  timelines[0] = {
192
195
  ...first,
193
196
  tracks: managedCss.tracks,
197
+ // In the drift case the CSS is the runtime truth — surface its
198
+ // compiled duration alongside its recovered tracks.
199
+ durationMs: managedCss.durationMs ?? first.durationMs,
194
200
  cssHash: managedCss.hash,
195
201
  source: "stored-css-drift",
196
202
  };
@@ -19,6 +19,16 @@ export default defineAction({
19
19
  "for a specific design + localhost connection. The grant scopes writes to the " +
20
20
  "connection's rootPath and expires after 8 hours. Requires editor access on the design. " +
21
21
  "The LocalhostWriteConsentDialog calls this after the user clicks 'Allow writes'.",
22
+ // This action mints/returns the real bridgeToken that unlocks the local
23
+ // bridge's unrestricted /read-file, /write-file, and /apply-edit. It must
24
+ // only ever be triggered by a human clicking "Allow writes" in
25
+ // LocalhostWriteConsentDialog — never by the agent itself, or an agent
26
+ // could self-grant local filesystem write/read access and bypass the
27
+ // human consent model entirely. `agentTool: false` hides it from every
28
+ // agent tool surface (in-app assistant, MCP, A2A) while keeping it
29
+ // callable from the frontend via `callAction` / `useActionMutation` and
30
+ // the raw `/_agent-native/actions/grant-localhost-write-consent` route.
31
+ agentTool: false,
22
32
  schema: z.object({
23
33
  designId: z.string().describe("Design ID."),
24
34
  connectionId: z
@@ -41,6 +41,27 @@ const schemaInput = z.object({
41
41
  .string()
42
42
  .optional()
43
43
  .describe("Design editor selection owner token from current screen state."),
44
+ targetNodeId: z
45
+ .string()
46
+ .optional()
47
+ .describe(
48
+ "data-agent-native-node-id of an existing element to target. Required " +
49
+ 'for mode "replace-src" and "background-fill". Ignored for the ' +
50
+ 'default "figure" mode.',
51
+ ),
52
+ mode: z
53
+ .enum(["figure", "replace-src", "background-fill"])
54
+ .optional()
55
+ .default("figure")
56
+ .describe(
57
+ 'How to place the asset. "figure" (default) appends a new figure/section ' +
58
+ "at the end of the file, styled with the design's own tokens — use " +
59
+ 'this when there is no existing element to target. "replace-src" sets ' +
60
+ "the src of the <img>/<video> at targetNodeId (e.g. filling a hero " +
61
+ 'image placeholder). "background-fill" sets a background-image style ' +
62
+ "on the element at targetNodeId (e.g. filling a hero/section background). " +
63
+ 'Both "replace-src" and "background-fill" require targetNodeId.',
64
+ ),
44
65
  });
45
66
 
46
67
  function stringFromState(state: unknown, key: string): string | undefined {
@@ -57,6 +78,26 @@ function escapeHtml(value: string): string {
57
78
  .replace(/"/g, "&quot;");
58
79
  }
59
80
 
81
+ /**
82
+ * Prepare a URL for embedding inside a single-quoted CSS `url('...')` value
83
+ * that itself lives inside an HTML attribute. A literal `'` in the URL would
84
+ * otherwise close the CSS string early — and depending on whether the
85
+ * surrounding HTML attribute is single- or double-quoted, everything after it
86
+ * becomes live CSS or, worse, live HTML/JS in the script-enabled preview
87
+ * iframe. Percent-encoding the quote/backslash characters that are meaningful
88
+ * to the CSS string-literal grammar keeps the URL functionally identical
89
+ * (browsers resolve %27/%22/%5C the same as the raw characters) while making
90
+ * it impossible to break out of the `url('...')` string. HTML-escape on top
91
+ * so the value is also safe as an HTML attribute (covers the "..." case).
92
+ */
93
+ function cssUrlValue(value: string): string {
94
+ const cssSafe = value
95
+ .replace(/\\/g, "%5C")
96
+ .replace(/'/g, "%27")
97
+ .replace(/"/g, "%22");
98
+ return escapeHtml(cssSafe);
99
+ }
100
+
60
101
  function createInsertedNodeId(prefix: string): string {
61
102
  const random =
62
103
  typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
@@ -88,13 +129,13 @@ function appendAssetMarkup(
88
129
  : "";
89
130
  const media =
90
131
  args.mediaType === "video"
91
- ? `<video src="${escapedUrl}" controls class="w-full rounded-xl object-cover"></video>`
92
- : `<img src="${escapedUrl}" alt="${escapeHtml(args.altText?.trim() || label)}" class="w-full rounded-xl object-cover" />`;
132
+ ? `<video src="${escapedUrl}" controls class="w-full rounded-[var(--radius,0.75rem)] object-cover"></video>`
133
+ : `<img src="${escapedUrl}" alt="${escapeHtml(args.altText?.trim() || label)}" class="w-full rounded-[var(--radius,0.75rem)] object-cover" />`;
93
134
  const snippet = `
94
135
  <section class="mx-auto my-8 max-w-5xl px-4" data-agent-native-asset data-agent-native-node-id="${escapeHtml(nodeId)}" data-agent-native-layer-name="${escapedLabel}"${assetIdAttr}>
95
- <figure class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
136
+ <figure class="overflow-hidden rounded-[var(--radius,1rem)] border border-[var(--color-border,rgba(0,0,0,0.1))] bg-[var(--color-surface,#fff)] shadow-sm">
96
137
  ${media}
97
- <figcaption class="px-4 py-3 text-sm text-slate-600">${escapedLabel}</figcaption>
138
+ <figcaption class="px-4 py-3 text-sm text-[var(--color-text-muted,#64748b)]">${escapedLabel}</figcaption>
98
139
  </figure>
99
140
  </section>`;
100
141
 
@@ -105,6 +146,119 @@ function appendAssetMarkup(
105
146
  );
106
147
  }
107
148
 
149
+ /** Find the raw tag string for the element carrying the given node id. */
150
+ function findTagByNodeId(html: string, nodeId: string): string | null {
151
+ const escapedId = nodeId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
152
+ const pattern = new RegExp(
153
+ `<[a-zA-Z][a-zA-Z0-9:-]*\\b[^>]*data-agent-native-node-id\\s*=\\s*(?:"${escapedId}"|'${escapedId}')[^>]*>`,
154
+ );
155
+ const match = html.match(pattern);
156
+ return match ? match[0] : null;
157
+ }
158
+
159
+ /** Replace the src attribute of the <img>/<video> tag at targetNodeId. */
160
+ function replaceSrcAtNode(
161
+ html: string,
162
+ args: z.infer<typeof schemaInput>,
163
+ ): string {
164
+ const nodeId = args.targetNodeId!;
165
+ const tag = findTagByNodeId(html, nodeId);
166
+ if (!tag) {
167
+ throw new Error(
168
+ `No element found with data-agent-native-node-id="${nodeId}".`,
169
+ );
170
+ }
171
+ const escapedUrl = escapeHtml(args.assetUrl);
172
+ let nextTag: string;
173
+ if (/\bsrc\s*=\s*(?:"[^"]*"|'[^']*')/i.test(tag)) {
174
+ nextTag = tag.replace(
175
+ /\bsrc\s*=\s*(?:"[^"]*"|'[^']*')/i,
176
+ `src="${escapedUrl}"`,
177
+ );
178
+ } else {
179
+ nextTag = tag.replace(/\/?>\s*$/, ` src="${escapedUrl}">`);
180
+ }
181
+ if (args.altText?.trim()) {
182
+ const escapedAlt = escapeHtml(args.altText.trim());
183
+ nextTag = /\balt\s*=\s*(?:"[^"]*"|'[^']*')/i.test(nextTag)
184
+ ? nextTag.replace(
185
+ /\balt\s*=\s*(?:"[^"]*"|'[^']*')/i,
186
+ `alt="${escapedAlt}"`,
187
+ )
188
+ : nextTag.replace(/\/?>\s*$/, ` alt="${escapedAlt}">`);
189
+ }
190
+ if (args.assetId?.trim()) {
191
+ const escapedAssetId = escapeHtml(args.assetId.trim());
192
+ nextTag = /\bdata-asset-id\s*=/.test(nextTag)
193
+ ? nextTag.replace(
194
+ /\bdata-asset-id\s*=\s*(?:"[^"]*"|'[^']*')/i,
195
+ `data-asset-id="${escapedAssetId}"`,
196
+ )
197
+ : nextTag.replace(/\/?>\s*$/, ` data-asset-id="${escapedAssetId}">`);
198
+ }
199
+ return html.replace(tag, nextTag);
200
+ }
201
+
202
+ /** Set a background-image inline style on the element at targetNodeId. */
203
+ function backgroundFillAtNode(
204
+ html: string,
205
+ args: z.infer<typeof schemaInput>,
206
+ ): string {
207
+ const nodeId = args.targetNodeId!;
208
+ const tag = findTagByNodeId(html, nodeId);
209
+ if (!tag) {
210
+ throw new Error(
211
+ `No element found with data-agent-native-node-id="${nodeId}".`,
212
+ );
213
+ }
214
+ const cssUrl = cssUrlValue(args.assetUrl);
215
+ const bgDeclaration = `background-image: url('${cssUrl}'); background-size: cover; background-position: center;`;
216
+ let nextTag: string;
217
+ if (/\bstyle\s*=\s*"/.test(tag)) {
218
+ nextTag = tag.replace(
219
+ /\bstyle\s*=\s*"([^"]*)"/i,
220
+ (_match, existing: string) => {
221
+ const trimmed = existing.trim();
222
+ const withoutBgImage = trimmed.replace(
223
+ /background-image\s*:[^;]*;?\s*/gi,
224
+ "",
225
+ );
226
+ const joined = withoutBgImage
227
+ ? `${withoutBgImage.replace(/;\s*$/, "")}; ${bgDeclaration}`
228
+ : bgDeclaration;
229
+ return `style="${joined}"`;
230
+ },
231
+ );
232
+ } else if (/\bstyle\s*=\s*'/.test(tag)) {
233
+ nextTag = tag.replace(
234
+ /\bstyle\s*=\s*'([^']*)'/i,
235
+ (_match, existing: string) => {
236
+ const trimmed = existing.trim();
237
+ const withoutBgImage = trimmed.replace(
238
+ /background-image\s*:[^;]*;?\s*/gi,
239
+ "",
240
+ );
241
+ const joined = withoutBgImage
242
+ ? `${withoutBgImage.replace(/;\s*$/, "")}; ${bgDeclaration}`
243
+ : bgDeclaration;
244
+ return `style='${joined}'`;
245
+ },
246
+ );
247
+ } else {
248
+ nextTag = tag.replace(/\/?>\s*$/, ` style="${bgDeclaration}">`);
249
+ }
250
+ if (args.assetId?.trim()) {
251
+ const escapedAssetId = escapeHtml(args.assetId.trim());
252
+ nextTag = /\bdata-asset-id\s*=/.test(nextTag)
253
+ ? nextTag.replace(
254
+ /\bdata-asset-id\s*=\s*(?:"[^"]*"|'[^']*')/i,
255
+ `data-asset-id="${escapedAssetId}"`,
256
+ )
257
+ : nextTag.replace(/\/?>\s*$/, ` data-asset-id="${escapedAssetId}">`);
258
+ }
259
+ return html.replace(tag, nextTag);
260
+ }
261
+
108
262
  async function resolveTarget(args: z.infer<typeof schemaInput>) {
109
263
  const [navigation, selection] = await Promise.all([
110
264
  readAppStateForCurrentTab("navigation").catch(() => null),
@@ -149,7 +303,18 @@ function isHtmlFile(file: {
149
303
 
150
304
  export default defineAction({
151
305
  description:
152
- "Insert a chosen Assets image or video URL into a Design file. Use this after the Assets picker returns chooseAsset/chooseImage context; pass designId/fileId directly when known, or pass ownerId from view-screen.designSelection when targeting the current editor selection.",
306
+ "Insert a chosen Assets image or video URL into a Design file. Use this " +
307
+ "after the Assets picker returns chooseAsset/chooseImage context; pass " +
308
+ "designId/fileId directly when known, or pass ownerId from " +
309
+ "view-screen.designSelection when targeting the current editor selection. " +
310
+ 'Default `mode: "figure"` appends a new tokened figure/section at the end ' +
311
+ "of the file — use this when there is no existing element to fill. Use " +
312
+ '`mode: "replace-src"` with `targetNodeId` to set the src of an existing ' +
313
+ "<img>/<video> (e.g. filling a hero image placeholder), or " +
314
+ '`mode: "background-fill"` with `targetNodeId` to set a background-image ' +
315
+ "style on an existing element (e.g. filling a hero/section background). " +
316
+ "Both of those require targetNodeId (a data-agent-native-node-id) from " +
317
+ "get-code-layer-projection or the current selection.",
153
318
  schema: schemaInput,
154
319
  publicAgent: { expose: true, readOnly: false, requiresAuth: true },
155
320
  run: async (args) => {
@@ -200,8 +365,25 @@ export default defineAction({
200
365
  // Collab read is best-effort; fall back to stored content.
201
366
  }
202
367
 
203
- const insertedNodeId = createInsertedNodeId("asset");
204
- const content = appendAssetMarkup(base, args, insertedNodeId);
368
+ const mode = args.mode ?? "figure";
369
+ let content: string;
370
+ let insertedNodeId: string;
371
+ if (mode === "replace-src") {
372
+ if (!args.targetNodeId) {
373
+ throw new Error('targetNodeId is required for mode "replace-src".');
374
+ }
375
+ insertedNodeId = args.targetNodeId;
376
+ content = replaceSrcAtNode(base, args);
377
+ } else if (mode === "background-fill") {
378
+ if (!args.targetNodeId) {
379
+ throw new Error('targetNodeId is required for mode "background-fill".');
380
+ }
381
+ insertedNodeId = args.targetNodeId;
382
+ content = backgroundFillAtNode(base, args);
383
+ } else {
384
+ insertedNodeId = createInsertedNodeId("asset");
385
+ content = appendAssetMarkup(base, args, insertedNodeId);
386
+ }
205
387
  const now = new Date().toISOString();
206
388
  await db
207
389
  .update(schema.designFiles)
@@ -223,6 +405,7 @@ export default defineAction({
223
405
  fileId: file.id,
224
406
  filename: file.filename,
225
407
  inserted: true,
408
+ mode,
226
409
  insertedNodeId,
227
410
  insertedSelector: `[data-agent-native-node-id="${insertedNodeId}"]`,
228
411
  assetId: args.assetId ?? null,
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import { defineAction, embedApp } from "@agent-native/core";
4
4
  import { writeAppState } from "@agent-native/core/application-state";
5
5
  import { buildDeepLink } from "@agent-native/core/server";
6
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
6
7
  import { eq } from "drizzle-orm";
7
8
  import { z } from "zod";
8
9
 
@@ -88,10 +89,23 @@ function isLoopbackUrl(value: string): boolean {
88
89
  );
89
90
  }
90
91
 
91
- function stableConnectionId(devServerUrl: string, rootPath?: string) {
92
+ /**
93
+ * Derive a stable per-user connection id for a devServerUrl + rootPath pair.
94
+ *
95
+ * The owner email is embedded in the hash so two users with an identical
96
+ * devServerUrl + rootPath (common with devcontainers/codespaces images) derive
97
+ * DIFFERENT ids and never collide on the shared primary key. Connections
98
+ * created before user scoping keep their old ids; those users simply
99
+ * reconnect fresh under the new per-user id.
100
+ */
101
+ function stableConnectionId(
102
+ devServerUrl: string,
103
+ rootPath: string | undefined,
104
+ ownerEmail: string,
105
+ ) {
92
106
  const hash = crypto
93
107
  .createHash("sha256")
94
- .update(`${devServerUrl}\n${rootPath ?? ""}`)
108
+ .update(`${ownerEmail}\n${devServerUrl}\n${rootPath ?? ""}`)
95
109
  .digest("base64url")
96
110
  .slice(0, 16);
97
111
  return `localhost_${hash}`;
@@ -147,7 +161,7 @@ export default defineAction({
147
161
  .string()
148
162
  .optional()
149
163
  .describe(
150
- "Existing localhost connection. Omit to reuse a stable connection for devServerUrl + rootPath.",
164
+ "Existing localhost connection. Omit to reuse a stable per-user connection for devServerUrl + rootPath.",
151
165
  ),
152
166
  title: z
153
167
  .string()
@@ -240,8 +254,16 @@ export default defineAction({
240
254
  }) ?? [],
241
255
  generatedAt: new Date().toISOString(),
242
256
  };
243
- const connectionId =
244
- args.connectionId ?? stableConnectionId(devServerUrl, args.rootPath);
257
+ let connectionId = args.connectionId;
258
+ if (!connectionId) {
259
+ const ownerEmail = getRequestUserEmail();
260
+ if (!ownerEmail) throw new Error("no authenticated user");
261
+ connectionId = stableConnectionId(
262
+ devServerUrl,
263
+ args.rootPath,
264
+ ownerEmail,
265
+ );
266
+ }
245
267
 
246
268
  const connection = await connectLocalhostAction.run({
247
269
  id: connectionId,
@@ -250,7 +250,7 @@ function colorForVariant(
250
250
  ) {
251
251
  const provided = variant.accentColor?.trim();
252
252
  if (provided) return provided;
253
- return ["#8b5cf6", "#06b6d4", "#10b981", "#f43f5e", "#f59e0b"][index % 5]!;
253
+ return ["#f59e0b", "#06b6d4", "#10b981", "#f43f5e", "#d97706"][index % 5]!;
254
254
  }
255
255
 
256
256
  function fallbackVariantContent(
@@ -307,7 +307,7 @@ function fallbackVariantContent(
307
307
  <style>
308
308
  :root { color-scheme: dark; --accent: ${accent}; --bg: #080a0f; --panel: rgba(18, 22, 33, 0.82); --line: rgba(255,255,255,.11); --muted: #94a3b8; }
309
309
  * { box-sizing: border-box; }
310
- body { margin: 0; width: ${screenWidth}px; min-height: ${screenHeight}px; overflow: hidden; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #f8fafc; background:
310
+ body { margin: 0; width: ${screenWidth}px; min-height: ${screenHeight}px; overflow: hidden; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #f8fafc; background:
311
311
  radial-gradient(circle at 18% 8%, color-mix(in srgb, var(--accent) 42%, transparent), transparent 30%),
312
312
  linear-gradient(140deg, #05070b 0%, #111827 48%, #05070b 100%); }
313
313
  .shell { width: ${screenWidth}px; min-height: ${screenHeight}px; padding: ${compact ? "18" : "34"}px; display: grid; grid-template-columns: ${compact ? "1fr" : tablet ? "220px 1fr" : "258px 1fr 304px"}; gap: ${compact ? "14" : "22"}px; }
@@ -25,24 +25,28 @@ export default defineAction({
25
25
 
26
26
  const db = getDb();
27
27
 
28
- const deleted = await db
29
- .delete(schema.designLocalhostWriteGrants)
30
- .where(
31
- and(
32
- eq(schema.designLocalhostWriteGrants.designId, designId),
33
- eq(schema.designLocalhostWriteGrants.connectionId, connectionId),
34
- eq(schema.designLocalhostWriteGrants.ownerEmail, ownerEmail),
35
- ),
36
- );
37
-
38
- // `deleted` shape varies by dialect; check rows affected via the object.
39
- const rowsAffected =
40
- (deleted as unknown as { rowsAffected?: number })?.rowsAffected ?? 0;
28
+ // The delete result's rows-affected shape varies by driver (and some
29
+ // drivers do not report it at all), so SELECT the scoped grant first and
30
+ // derive `revoked` from its existence — portable across dialects without
31
+ // assuming RETURNING support.
32
+ const scope = and(
33
+ eq(schema.designLocalhostWriteGrants.designId, designId),
34
+ eq(schema.designLocalhostWriteGrants.connectionId, connectionId),
35
+ eq(schema.designLocalhostWriteGrants.ownerEmail, ownerEmail),
36
+ );
37
+
38
+ const [grant] = await db
39
+ .select({ id: schema.designLocalhostWriteGrants.id })
40
+ .from(schema.designLocalhostWriteGrants)
41
+ .where(scope)
42
+ .limit(1);
43
+
44
+ await db.delete(schema.designLocalhostWriteGrants).where(scope);
41
45
 
42
46
  return {
43
47
  designId,
44
48
  connectionId,
45
- revoked: rowsAffected > 0,
49
+ revoked: Boolean(grant),
46
50
  };
47
51
  },
48
52
  });
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * run-design-audit — read-only a11y audit over a design's rendered HTML/DOM.
3
3
  *
4
- * Checks contrast ratios, tap-target sizes, missing alt/labels, focus
5
- * visibility, and reduced-motion concerns by static analysis of the stored
6
- * HTML. Does NOT perform writes. Results are returned as `A11yFinding[]`
7
- * and may be persisted by the caller via `create-design-review-snapshot`.
4
+ * Flags low-opacity text color classes as a contrast hint (real contrast
5
+ * ratios require a DOM/CSS cascade resolver that isn't available server-side,
6
+ * so this is not a computed ratio check), plus tap-target sizes, missing
7
+ * alt/labels, focus visibility, and reduced-motion concerns, all by static
8
+ * analysis of the stored HTML. Does NOT perform writes. Results are returned
9
+ * as `A11yFinding[]` and may be persisted by the caller via
10
+ * `create-design-review-snapshot`.
8
11
  *
9
12
  * See DESIGN-STUDIO-PLAN.md §6.5 + §7 (Review surface).
10
13
  */