@agent-native/core 0.85.5 → 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 (86) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +1 -1
  5. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  6. package/corpus/core/src/collab/presence.ts +63 -3
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  8. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  9. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  10. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  11. package/corpus/templates/design/AGENTS.md +40 -4
  12. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  14. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  15. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  16. package/corpus/templates/design/actions/edit-design.ts +5 -3
  17. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  18. package/corpus/templates/design/actions/export-zip.ts +10 -1
  19. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  20. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  21. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  22. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  23. package/corpus/templates/design/actions/present-design-variants.ts +12 -8
  24. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  25. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  26. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  28. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  29. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  30. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
  32. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  33. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  34. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  35. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  36. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  37. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  38. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  39. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  40. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  41. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  42. package/corpus/templates/design/app/components/design/types.ts +16 -0
  43. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  44. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  45. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  46. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  47. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  48. package/corpus/templates/design/app/i18n-data.ts +18 -0
  49. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  50. package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
  51. package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  56. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  57. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  58. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  59. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  60. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  61. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  62. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  63. package/corpus/templates/design/shared/board-file.ts +25 -5
  64. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  65. package/corpus/templates/design/shared/code-layer.ts +304 -26
  66. package/corpus/templates/design/shared/color-utils.ts +84 -0
  67. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  68. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  69. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  70. package/corpus/templates/design/shared/pen-path.ts +200 -23
  71. package/dist/client/AssistantChat.js +1 -1
  72. package/dist/client/AssistantChat.js.map +1 -1
  73. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  74. package/dist/client/use-pinch-zoom.js +76 -11
  75. package/dist/client/use-pinch-zoom.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/presence.d.ts.map +1 -1
  79. package/dist/collab/presence.js +54 -3
  80. package/dist/collab/presence.js.map +1 -1
  81. package/dist/collab/routes.d.ts +1 -1
  82. package/dist/collab/struct-routes.d.ts +1 -1
  83. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  84. package/dist/observability/routes.d.ts +2 -2
  85. package/dist/secrets/routes.d.ts +3 -3
  86. package/package.json +1 -1
@@ -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
  */
@@ -8,8 +8,14 @@
8
8
  * must exist. The agent CANNOT bypass this check.
9
9
  * 4. Path confinement: assertPathInside ensures the target stays inside
10
10
  * rootPath (pre-bridge check; bridge also validates with realpath).
11
- * 5. Bridge token: the X-Bridge-Token header is set to the per-grant token
12
- * minted at consent time so the bridge can reject unauthorized calls.
11
+ * 5. Bridge token: the X-Bridge-Token header is set to the connection's
12
+ * CURRENT bridge token (falling back to the token snapshotted on the
13
+ * grant). The CLI mints a fresh token on every bridge start, so a bridge
14
+ * restart + reconnect rotates the connection token while the user's
15
+ * time-boxed consent grant stays valid; preferring the connection token
16
+ * keeps writes working across restarts. A bridge 401/403 is surfaced as a
17
+ * specific stale-token error telling the user to re-run design connect
18
+ * and re-grant write consent.
13
19
  */
14
20
 
15
21
  import path from "node:path";
@@ -53,6 +59,30 @@ function isLoopbackHostname(hostname: string): boolean {
53
59
  );
54
60
  }
55
61
 
62
+ /**
63
+ * Build the error for a failed bridge call. 401/403 means the bridge rejected
64
+ * the token — after a bridge restart the CLI mints a fresh token, so a token
65
+ * snapshotted at consent time goes stale even though the grant itself is
66
+ * still valid. Surface that as a specific, actionable message instead of a
67
+ * generic failure.
68
+ */
69
+ function bridgeRequestError(
70
+ operation: string,
71
+ status: number,
72
+ errText: string,
73
+ ): Error {
74
+ if (status === 401 || status === 403) {
75
+ return new Error(
76
+ `Bridge ${operation} rejected authentication (${status}). ` +
77
+ "The stored bridge token is stale — the design bridge was likely restarted " +
78
+ "since write consent was granted (each bridge start mints a fresh token). " +
79
+ "Re-run `npx @agent-native/core@latest design connect` and re-grant write " +
80
+ "consent, then retry.",
81
+ );
82
+ }
83
+ return new Error(`Bridge ${operation} failed (${status}): ${errText}`);
84
+ }
85
+
56
86
  function normalizeBridgeUrl(value: string): string {
57
87
  const parsed = new URL(value.trim());
58
88
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
@@ -140,10 +170,13 @@ export default defineAction({
140
170
  );
141
171
  }
142
172
 
143
- // --- Resolve bridge URL ---
173
+ // --- Resolve bridge URL + current token ---
144
174
  const db = getDb();
145
175
  const [connection] = await db
146
- .select({ bridgeUrl: schema.designLocalhostConnections.bridgeUrl })
176
+ .select({
177
+ bridgeUrl: schema.designLocalhostConnections.bridgeUrl,
178
+ bridgeToken: schema.designLocalhostConnections.bridgeToken,
179
+ })
147
180
  .from(schema.designLocalhostConnections)
148
181
  .where(
149
182
  and(
@@ -160,10 +193,15 @@ export default defineAction({
160
193
  );
161
194
  }
162
195
 
196
+ // Prefer the connection's CURRENT bridge token over the one snapshotted on
197
+ // the grant: the CLI mints a fresh token on every bridge start, and a
198
+ // later connect-localhost by the same authenticated user refreshes the
199
+ // connection row. The user's time-boxed consent grant is unchanged — only
200
+ // the transport token rotated — so writes keep working across restarts.
163
201
  const bridgeUrl = normalizeBridgeUrl(connection.bridgeUrl);
164
202
  const headers: Record<string, string> = {
165
203
  "Content-Type": "application/json",
166
- "X-Bridge-Token": grant.bridgeToken,
204
+ "X-Bridge-Token": connection.bridgeToken || grant.bridgeToken,
167
205
  };
168
206
 
169
207
  if (content !== undefined) {
@@ -175,23 +213,12 @@ export default defineAction({
175
213
  });
176
214
  if (!res.ok) {
177
215
  const errText = await res.text().catch(() => res.statusText);
178
- throw new Error(`Bridge write-file failed (${res.status}): ${errText}`);
216
+ throw bridgeRequestError("write-file", res.status, errText);
179
217
  }
180
218
  return { designId, relPath, operation: "write" as const, written: true };
181
219
  } else {
182
- // Search-and-replace patch first read, then apply-edit
183
- const readRes = await fetch(`${bridgeUrl}/read-file`, {
184
- method: "POST",
185
- headers,
186
- body: JSON.stringify({ relPath }),
187
- });
188
- if (!readRes.ok) {
189
- const errText = await readRes.text().catch(() => readRes.statusText);
190
- throw new Error(
191
- `Bridge read-file failed (${readRes.status}): ${errText}`,
192
- );
193
- }
194
-
220
+ // Search-and-replace patch. The bridge's /apply-edit validates the file
221
+ // itself (404s on a missing file), so no pre-read round-trip is needed.
195
222
  const applyRes = await fetch(`${bridgeUrl}/apply-edit`, {
196
223
  method: "POST",
197
224
  headers,
@@ -203,9 +230,7 @@ export default defineAction({
203
230
  });
204
231
  if (!applyRes.ok) {
205
232
  const errText = await applyRes.text().catch(() => applyRes.statusText);
206
- throw new Error(
207
- `Bridge apply-edit failed (${applyRes.status}): ${errText}`,
208
- );
233
+ throw bridgeRequestError("apply-edit", applyRes.status, errText);
209
234
  }
210
235
  return { designId, relPath, operation: "patch" as const, written: true };
211
236
  }
@@ -447,6 +447,14 @@ interface DesignCanvasProps {
447
447
  * needs the tracks so the bridge can interpolate values at each tick.
448
448
  */
449
449
  motionTracks?: MotionTrackWire[];
450
+ /**
451
+ * Timeline-level default easing applied to any keyframe that omits its own
452
+ * `ease`. Threaded to the motion-preview bridge alongside the tracks so the
453
+ * scrub/playback preview matches the persisted CSS (the compiler uses the
454
+ * same `defaultEase` fallback when emitting `animation-timing-function`).
455
+ * When omitted the bridge falls back to "ease".
456
+ */
457
+ motionDefaultEase?: string;
450
458
  /**
451
459
  * Explicit iframe width in pixels. When provided it overrides the width
452
460
  * derived from `deviceFrame`, enabling per-breakpoint preview (e.g. Mobile
@@ -683,6 +691,7 @@ export function DesignCanvas({
683
691
  commentContextLabel,
684
692
  onPrototypeNavigate,
685
693
  motionTracks,
694
+ motionDefaultEase,
686
695
  previewWidthPx,
687
696
  onComponentSourceJump,
688
697
  shaderFillPreview,
@@ -695,6 +704,40 @@ export function DesignCanvas({
695
704
  const runtimeReplacementContentRef = useRef(runtimeReplacementContent);
696
705
  const runtimeReplacementKeyRef = useRef(runtimeReplacementKey);
697
706
  const lastRuntimeReplacementKeyRef = useRef(runtimeReplacementKey);
707
+ // Bridge-ready handshake (see EDITOR_CHROME_BRIDGE_SCRIPT's
708
+ // agent-native:editor-chrome-ready post on install). One-shot commands —
709
+ // begin-text-edit, set-editor-chrome-scale, style-change, delete-element,
710
+ // replace-document-content — are fire-and-forget postMessages with no retry:
711
+ // if the iframe document is still loading (fresh srcdoc, screen switch, or a
712
+ // mid-flight reload) the bridge script hasn't attached its message listener
713
+ // yet and the command is silently dropped. replayIframeEditorState only
714
+ // re-sends steady-state selection/hover/tweak/motion values, never these
715
+ // one-shot commands, so a dropped one-shot never recovers on its own.
716
+ // Queue them here until ready fires (or the iframe finishes loading, as a
717
+ // fallback for older/interact-mode documents that never inject the chrome
718
+ // bridge and thus never post ready) and flush in order.
719
+ const bridgeReadyRef = useRef(false);
720
+ const pendingOneShotMessagesRef = useRef<unknown[]>([]);
721
+ const flushPendingOneShotMessages = useCallback(() => {
722
+ const iframe = iframeRef.current;
723
+ const win = iframe?.contentWindow;
724
+ if (!win) return;
725
+ const queued = pendingOneShotMessagesRef.current;
726
+ if (queued.length === 0) return;
727
+ pendingOneShotMessagesRef.current = [];
728
+ queued.forEach((message) => win.postMessage(message, "*"));
729
+ }, []);
730
+ const postOneShotBridgeMessage = useCallback((message: unknown) => {
731
+ const iframe = iframeRef.current;
732
+ const win = iframe?.contentWindow;
733
+ if (!win) return false;
734
+ if (!bridgeReadyRef.current) {
735
+ pendingOneShotMessagesRef.current.push(message);
736
+ return true;
737
+ }
738
+ win.postMessage(message, "*");
739
+ return true;
740
+ }, []);
698
741
  const [renderedContent, setRenderedContent] = useState(content);
699
742
  const [annotationPins, setAnnotationPins] = useState<CanvasPin[]>([]);
700
743
  const [pinSubmitSignal, setPinSubmitSignal] = useState(0);
@@ -865,6 +908,15 @@ export function DesignCanvas({
865
908
  if (previousContentKeyRef.current !== contentKey) {
866
909
  previousContentKeyRef.current = contentKey;
867
910
  lastRuntimeReplacementKeyRef.current = runtimeReplacementKey;
911
+ // A content-key change rebuilds srcdoc, which reloads the iframe with a
912
+ // brand-new document. The previous document's ready handshake (and any
913
+ // one-shot commands still queued against it) no longer apply — reset
914
+ // synchronously here rather than on the iframe's `load` event, since
915
+ // `load` fires AFTER the freshly (re)injected editor-chrome bridge script
916
+ // has already run and posted its own new ready message; resetting on
917
+ // `load` would incorrectly clobber that just-arrived ready signal.
918
+ bridgeReadyRef.current = false;
919
+ pendingOneShotMessagesRef.current = [];
868
920
  setRenderedContent(content);
869
921
  }
870
922
  // Same-screen visual edits are already applied optimistically inside the
@@ -888,11 +940,14 @@ export function DesignCanvas({
888
940
  // outside Edit mode. The editor chrome bridge is omitted for Interact mode
889
941
  // so preview/app users can interact with the app normally.
890
942
  //
891
- // readOnly is intentionally NOT a dep here: the bridge is injected whenever
892
- // !interactMode and the initial __READ_ONLY__ placeholder is baked from the
893
- // *first* render only. After that, live readOnly changes flow through the
894
- // set-read-only postMessage (see the useEffect below) so switching the active
895
- // surface (board screen) never rebuilds srcdoc / reloads the iframe.
943
+ // readOnly and editMode are intentionally NOT deps here: the bridge is
944
+ // injected whenever !interactMode and the initial __READ_ONLY__ /
945
+ // __TEXT_EDITING_ENABLED__ placeholders are baked from the *first* render
946
+ // only (so first paint never flashes the wrong mode). After that, live
947
+ // readOnly / editMode changes flow through the set-read-only and
948
+ // set-text-editing-enabled postMessages (see the useEffects below) so
949
+ // switching the active surface (board ↔ screen) or toggling Edit ⇄ Preview
950
+ // never rebuilds srcdoc / reloads every screen iframe.
896
951
  const srcdoc = useMemo(() => {
897
952
  if (externalPreviewUrl) return undefined;
898
953
  const editorChromeBridge = interactMode
@@ -917,12 +972,20 @@ export function DesignCanvas({
917
972
  "__EMBEDDED_WHEEL_FORWARDING_ENABLED__",
918
973
  isEmbeddedFrame ? "true" : "false",
919
974
  );
975
+ // ALWAYS injected (like the other always-on bridges above) so
976
+ // MultiScreenCanvas's cross-screen drag hit-testing
977
+ // (agent-native:hit-test / agent-native:hit-test-result) resolves an
978
+ // anchor+placement against this screen's live DOM too, not only the
979
+ // static-fallback board thumbnails that call appendHitTestResponder.
980
+ // Without this, a drop onto a live/active screen has no responder and
981
+ // always falls through to the 50ms request timeout.
920
982
  const bridgeToInject =
921
983
  MOTION_PREVIEW_BRIDGE_SCRIPT +
922
984
  SHADER_FILL_PREVIEW_BRIDGE_SCRIPT +
923
985
  TWEAK_BRIDGE_SCRIPT +
924
986
  ZOOM_BRIDGE_SCRIPT +
925
987
  NAV_BRIDGE_SCRIPT +
988
+ LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT +
926
989
  embeddedWheelBridge +
927
990
  editorChromeBridge;
928
991
  const frameContent = getEmbeddedFrameDocumentContent({
@@ -954,10 +1017,10 @@ export function DesignCanvas({
954
1017
  // baked chrome scale. Live zoom updates flow through the set-editor-chrome-scale
955
1018
  // postMessage above. Including them here rebuilds srcdoc on every zoom commit,
956
1019
  // which reloads the iframe and flashes the screen content white.
957
- // readOnly is intentionally NOT a dep: live changes flow through set-read-only.
1020
+ // readOnly and editMode are intentionally NOT deps: live changes flow
1021
+ // through set-read-only / set-text-editing-enabled.
958
1022
  // eslint-disable-next-line react-hooks/exhaustive-deps
959
1023
  }, [
960
- editMode,
961
1024
  boardSurface,
962
1025
  externalPreviewUrl,
963
1026
  interactMode,
@@ -996,6 +1059,11 @@ export function DesignCanvas({
996
1059
  return;
997
1060
  }
998
1061
  if (!e.data || !e.data.type) return;
1062
+ if (e.data.type === "agent-native:editor-chrome-ready") {
1063
+ bridgeReadyRef.current = true;
1064
+ flushPendingOneShotMessages();
1065
+ return;
1066
+ }
999
1067
  if (e.data.type === "clear-selection") {
1000
1068
  onClearSelection?.();
1001
1069
  return;
@@ -1256,17 +1324,30 @@ export function DesignCanvas({
1256
1324
  const iframe = iframeRef.current;
1257
1325
  const scroll = scrollContainerRef.current;
1258
1326
  if (!iframe || !scroll) return;
1327
+ // Guard against non-finite values (NaN/undefined/string) before they
1328
+ // reach Math.max/Math.min/Math.exp — an unguarded NaN here would
1329
+ // propagate through nextZoom and silently break zoom (mirrors the
1330
+ // Number.isFinite-style validation the embedded-canvas-wheel sibling
1331
+ // handler above applies to its own wheel deltas/coordinates).
1332
+ const rawDeltaY = Number(e.data.deltaY);
1333
+ if (!Number.isFinite(rawDeltaY)) return;
1259
1334
  // Mirror usePinchZoom's algorithm here. We can't reliably re-dispatch
1260
1335
  // a synthetic WheelEvent to trigger the hook's listener — untrusted
1261
1336
  // events are inconsistent across browsers — so just compute the
1262
1337
  // next zoom directly using the same exponential factor + cursor-anchor
1263
1338
  // math. Clamp range matches the usePinchZoom call above (10–500).
1264
1339
  const currentZoom = zoomRef.current;
1265
- const clampedDelta = Math.max(-50, Math.min(50, e.data.deltaY));
1340
+ const clampedDelta = Math.max(-50, Math.min(50, rawDeltaY));
1266
1341
  const factor = Math.exp(-clampedDelta * 0.01);
1267
1342
  const nextZoom = Math.max(10, Math.min(500, currentZoom * factor));
1268
- if (nextZoom === currentZoom) return;
1343
+ if (!Number.isFinite(nextZoom) || nextZoom === currentZoom) return;
1269
1344
  if (deviceFrame === "none") {
1345
+ const rawClientX = Number(e.data.clientX);
1346
+ const rawClientY = Number(e.data.clientY);
1347
+ if (!Number.isFinite(rawClientX) || !Number.isFinite(rawClientY)) {
1348
+ onZoomChange(nextZoom);
1349
+ return;
1350
+ }
1270
1351
  // The iframe lives inside a `transform: scale(zoom/100)` wrapper, so
1271
1352
  // its visual scale relative to viewport is currentZoom / 100. Convert
1272
1353
  // the iframe-document point under the cursor → viewport point →
@@ -1274,8 +1355,8 @@ export function DesignCanvas({
1274
1355
  const iframeRect = iframe.getBoundingClientRect();
1275
1356
  const scrollRect = scroll.getBoundingClientRect();
1276
1357
  const scale = currentZoom / 100;
1277
- const viewportX = iframeRect.left + e.data.clientX * scale;
1278
- const viewportY = iframeRect.top + e.data.clientY * scale;
1358
+ const viewportX = iframeRect.left + rawClientX * scale;
1359
+ const viewportY = iframeRect.top + rawClientY * scale;
1279
1360
  const cx = viewportX - scrollRect.left + scroll.scrollLeft;
1280
1361
  const cy = viewportY - scrollRect.top + scroll.scrollTop;
1281
1362
  const ratio = nextZoom / currentZoom;
@@ -1315,6 +1396,7 @@ export function DesignCanvas({
1315
1396
  isEmbeddedFrame,
1316
1397
  sourceType,
1317
1398
  fusionUrl,
1399
+ flushPendingOneShotMessages,
1318
1400
  ]);
1319
1401
 
1320
1402
  const replayIframeEditorState = useCallback(() => {
@@ -1362,7 +1444,11 @@ export function DesignCanvas({
1362
1444
  // Re-send motion tracks so the preview bridge is ready after a reload.
1363
1445
  if (motionTracks && motionTracks.length > 0) {
1364
1446
  iframe.contentWindow?.postMessage(
1365
- { type: "motion-load-tracks", tracks: motionTracks },
1447
+ {
1448
+ type: "motion-load-tracks",
1449
+ tracks: motionTracks,
1450
+ defaultEase: motionDefaultEase,
1451
+ },
1366
1452
  "*",
1367
1453
  );
1368
1454
  } else {
@@ -1392,6 +1478,7 @@ export function DesignCanvas({
1392
1478
  hiddenSelectors,
1393
1479
  lockedSelectors,
1394
1480
  motionTracks,
1481
+ motionDefaultEase,
1395
1482
  scaleMode,
1396
1483
  selectedSelector,
1397
1484
  selectedSelectorCandidates,
@@ -1411,6 +1498,27 @@ export function DesignCanvas({
1411
1498
  return () => iframe.removeEventListener("load", replayIframeEditorState);
1412
1499
  }, [replayIframeEditorState]);
1413
1500
 
1501
+ // Fallback for documents that never inject the editor-chrome bridge (e.g.
1502
+ // interactMode, or an external-preview iframe where srcdoc is undefined):
1503
+ // those never post agent-native:editor-chrome-ready, so nothing would ever
1504
+ // flush a queued one-shot command. Treat the iframe's `load` event as an
1505
+ // implicit ready in that case — the queued commands (begin-text-edit,
1506
+ // style-change, delete-element, replace-document-content) target the
1507
+ // editor-chrome bridge's own message handlers, so with no chrome bridge
1508
+ // present there is nothing that would have handled them regardless; this
1509
+ // just prevents the queue from growing unbounded across repeated reloads.
1510
+ useEffect(() => {
1511
+ const iframe = iframeRef.current;
1512
+ if (!iframe) return;
1513
+ function handleLoadReadyFallback() {
1514
+ if (bridgeReadyRef.current) return;
1515
+ bridgeReadyRef.current = true;
1516
+ flushPendingOneShotMessages();
1517
+ }
1518
+ iframe.addEventListener("load", handleLoadReadyFallback);
1519
+ return () => iframe.removeEventListener("load", handleLoadReadyFallback);
1520
+ }, [flushPendingOneShotMessages]);
1521
+
1414
1522
  useEffect(() => {
1415
1523
  if (clearSelectionRequest === undefined) return;
1416
1524
  iframeRef.current?.contentWindow?.postMessage(
@@ -1429,11 +1537,15 @@ export function DesignCanvas({
1429
1537
  win.postMessage({ type: "motion-preview-clear" }, "*");
1430
1538
  } else {
1431
1539
  win.postMessage(
1432
- { type: "motion-load-tracks", tracks: motionTracks },
1540
+ {
1541
+ type: "motion-load-tracks",
1542
+ tracks: motionTracks,
1543
+ defaultEase: motionDefaultEase,
1544
+ },
1433
1545
  "*",
1434
1546
  );
1435
1547
  }
1436
- }, [motionTracks]);
1548
+ }, [motionTracks, motionDefaultEase]);
1437
1549
 
1438
1550
  // Sync shader-fill preview to the iframe whenever the prop changes.
1439
1551
  // When cleared (null / undefined) send a clear message so the bridge
@@ -1461,16 +1573,16 @@ export function DesignCanvas({
1461
1573
  // overview zoom settles. This is intentionally separate from the srcdoc build so
1462
1574
  // a scale change never rebuilds srcdoc / reloads the iframe (which flashes the
1463
1575
  // content white). The baked __EDITOR_CHROME_SCALE__ values cover first paint.
1576
+ // Routed through the one-shot queue too: a zoom settle that lands while the
1577
+ // iframe is mid-reload would otherwise be silently dropped, leaving the
1578
+ // chrome at a stale scale until the next zoom change.
1464
1579
  useEffect(() => {
1465
- iframeRef.current?.contentWindow?.postMessage(
1466
- {
1467
- type: "set-editor-chrome-scale",
1468
- scaleX: editorChromeScaleX,
1469
- scaleY: editorChromeScaleY,
1470
- },
1471
- "*",
1472
- );
1473
- }, [editorChromeScaleX, editorChromeScaleY]);
1580
+ postOneShotBridgeMessage({
1581
+ type: "set-editor-chrome-scale",
1582
+ scaleX: editorChromeScaleX,
1583
+ scaleY: editorChromeScaleY,
1584
+ });
1585
+ }, [editorChromeScaleX, editorChromeScaleY, postOneShotBridgeMessage]);
1474
1586
 
1475
1587
  // Sync readOnly to the bridge IN-PLACE via postMessage so switching the active
1476
1588
  // surface (board ↔ screen) does not rebuild srcdoc / reload the iframe.
@@ -1495,6 +1607,34 @@ export function DesignCanvas({
1495
1607
  // eslint-disable-next-line react-hooks/exhaustive-deps
1496
1608
  }, [readOnly]);
1497
1609
 
1610
+ // Sync editMode to the bridge IN-PLACE via postMessage so toggling Edit ⇄
1611
+ // Preview does not rebuild srcdoc / reload every screen iframe (which was
1612
+ // PF21: __TEXT_EDITING_ENABLED__ used to be baked into srcdoc, so flipping
1613
+ // edit/preview mode reloaded every iframe — white flash + lost in-iframe
1614
+ // Alpine state). The initial baked __TEXT_EDITING_ENABLED__ placeholder
1615
+ // covers first paint; subsequent changes arrive here. Also re-send on
1616
+ // iframe load so the bridge is in sync after any content-key-driven reload.
1617
+ const editModeRef = useRef(editMode);
1618
+ editModeRef.current = editMode;
1619
+ useEffect(() => {
1620
+ const iframe = iframeRef.current;
1621
+ if (!iframe) return;
1622
+ function sendTextEditingEnabled() {
1623
+ iframe!.contentWindow?.postMessage(
1624
+ {
1625
+ type: "set-text-editing-enabled",
1626
+ enabled: editModeRef.current,
1627
+ },
1628
+ "*",
1629
+ );
1630
+ }
1631
+ sendTextEditingEnabled();
1632
+ iframe.addEventListener("load", sendTextEditingEnabled);
1633
+ return () => iframe.removeEventListener("load", sendTextEditingEnabled);
1634
+ // Only re-run when editMode changes; iframe identity is stable.
1635
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1636
+ }, [editMode]);
1637
+
1498
1638
  /**
1499
1639
  * Trigger immediate text-editing mode for a specific node inside the iframe,
1500
1640
  * identified by its data-agent-native-node-id value. Call this after
@@ -1504,14 +1644,18 @@ export function DesignCanvas({
1504
1644
  * Posts { type: 'begin-text-edit', nodeId } to the iframe bridge.
1505
1645
  * The bridge enters the same contenteditable text-editing path used on dblclick.
1506
1646
  */
1507
- const beginTextEdit = useCallback((nodeId: string) => {
1508
- const iframe = iframeRef.current;
1509
- if (!iframe?.contentWindow || !nodeId) return;
1510
- iframe.contentWindow.postMessage(
1511
- { type: "begin-text-edit", nodeId, force: true },
1512
- "*",
1513
- );
1514
- }, []);
1647
+ const beginTextEdit = useCallback(
1648
+ (nodeId: string) => {
1649
+ const iframe = iframeRef.current;
1650
+ if (!iframe?.contentWindow || !nodeId) return;
1651
+ postOneShotBridgeMessage({
1652
+ type: "begin-text-edit",
1653
+ nodeId,
1654
+ force: true,
1655
+ });
1656
+ },
1657
+ [postOneShotBridgeMessage],
1658
+ );
1515
1659
 
1516
1660
  const sendStyleChange = useCallback(
1517
1661
  (
@@ -1522,19 +1666,16 @@ export function DesignCanvas({
1522
1666
  ) => {
1523
1667
  const iframe = iframeRef.current;
1524
1668
  if (!iframe?.contentWindow) return;
1525
- iframe.contentWindow.postMessage(
1526
- {
1527
- type: "style-change",
1528
- selector,
1529
- property,
1530
- value,
1531
- selectorCandidates: options?.selectorCandidates ?? [],
1532
- nodeId: options?.nodeId ?? "",
1533
- },
1534
- "*",
1535
- );
1669
+ postOneShotBridgeMessage({
1670
+ type: "style-change",
1671
+ selector,
1672
+ property,
1673
+ value,
1674
+ selectorCandidates: options?.selectorCandidates ?? [],
1675
+ nodeId: options?.nodeId ?? "",
1676
+ });
1536
1677
  },
1537
- [],
1678
+ [postOneShotBridgeMessage],
1538
1679
  );
1539
1680
 
1540
1681
  /**
@@ -1601,19 +1742,15 @@ export function DesignCanvas({
1601
1742
  ) => {
1602
1743
  const iframe = iframeRef.current;
1603
1744
  if (!iframe?.contentWindow) return false;
1604
- iframe.contentWindow.postMessage(
1605
- {
1606
- type: "replace-document-content",
1607
- content: nextContent,
1608
- selectedSelector: selector ?? "",
1609
- selectorCandidates: candidates ?? [],
1610
- forceFullDocument: options?.forceFullDocument === true,
1611
- },
1612
- "*",
1613
- );
1614
- return true;
1745
+ return postOneShotBridgeMessage({
1746
+ type: "replace-document-content",
1747
+ content: nextContent,
1748
+ selectedSelector: selector ?? "",
1749
+ selectorCandidates: candidates ?? [],
1750
+ forceFullDocument: options?.forceFullDocument === true,
1751
+ });
1615
1752
  },
1616
- [],
1753
+ [postOneShotBridgeMessage],
1617
1754
  );
1618
1755
 
1619
1756
  const replaceRuntimeContentInPlace = useCallback(
@@ -1680,17 +1817,13 @@ export function DesignCanvas({
1680
1817
  (selector?: string | null, candidates?: string[]) => {
1681
1818
  const iframe = iframeRef.current;
1682
1819
  if (!iframe?.contentWindow) return false;
1683
- iframe.contentWindow.postMessage(
1684
- {
1685
- type: "delete-element",
1686
- selector: selector ?? "",
1687
- selectorCandidates: candidates ?? [],
1688
- },
1689
- "*",
1690
- );
1691
- return true;
1820
+ return postOneShotBridgeMessage({
1821
+ type: "delete-element",
1822
+ selector: selector ?? "",
1823
+ selectorCandidates: candidates ?? [],
1824
+ });
1692
1825
  },
1693
- [],
1826
+ [postOneShotBridgeMessage],
1694
1827
  );
1695
1828
 
1696
1829
  // Expose iframe runtime mutations for the editor orchestrator.
@@ -1826,7 +1959,29 @@ export function DesignCanvas({
1826
1959
  sandbox={
1827
1960
  externalPreviewUrl
1828
1961
  ? "allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin"
1829
- : "allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"
1962
+ : // NOTE(security): allow-same-origin is still present on this
1963
+ // branch (the inline srcdoc iframe, where agent/user-generated
1964
+ // design HTML executes) even though allow-scripts +
1965
+ // allow-same-origin together defeat the sandbox in principle.
1966
+ // This is a deliberate, currently-blocked tradeoff, not an
1967
+ // oversight — see the long comment above DesignCanvasProps /
1968
+ // sourceType for the security model, and the PNG/SVG export
1969
+ // handlers in DesignEditor.tsx (handleDownloadPng /
1970
+ // handleDownloadSvg). Those handlers read this same iframe's
1971
+ // `contentDocument` directly and hand the LIVE document element
1972
+ // to html2canvas, and read live CSSOM (getComputedStyle,
1973
+ // doc.styleSheets) for SVG serialization. That cannot be
1974
+ // expressed over the postMessage bridge without moving
1975
+ // html2canvas execution and CSSOM-dependent serialization logic
1976
+ // INTO the sandboxed iframe itself — a separate, larger rewrite,
1977
+ // not a bridge-handler addition. All other former
1978
+ // `contentDocument`/`contentWindow.document` reads on this
1979
+ // iframe (e.g. text-edit-status) now go through bridge
1980
+ // request/response postMessage handlers in
1981
+ // editor-chrome.bridge.ts and no longer need allow-same-origin.
1982
+ // Do not remove this flag until PNG/SVG export is rebuilt to run
1983
+ // inside the iframe.
1984
+ "allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"
1830
1985
  }
1831
1986
  data-design-preview-iframe
1832
1987
  data-screen-iframe-id={