@agent-native/core 0.124.2 → 0.124.3

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 (27) 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/shared/streaming-text-smoothing.ts +15 -14
  5. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +34 -11
  6. package/corpus/templates/design/.agents/skills/responsive-breakpoints/SKILL.md +8 -0
  7. package/corpus/templates/design/actions/generate-design.ts +259 -36
  8. package/corpus/templates/design/actions/generate-screens.ts +51 -14
  9. package/corpus/templates/design/actions/present-design-variants.ts +6 -3
  10. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +8 -0
  11. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +158 -9
  12. package/corpus/templates/design/app/components/design/design-canvas/content-size-report.ts +124 -0
  13. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +61 -9
  14. package/corpus/templates/design/changelog/2026-07-24-ai-designs-now-default-to-a-full-size-desktop-mobile-pair-fr.md +6 -0
  15. package/corpus/templates/design/changelog/2026-07-24-design-frames-now-fill-the-viewport-height-and-grow-to-fit-t.md +6 -0
  16. package/corpus/templates/design/changelog/2026-07-24-generating-an-additional-screen-now-places-it-beside-the-exi.md +6 -0
  17. package/dist/collab/awareness.d.ts +2 -2
  18. package/dist/collab/awareness.d.ts.map +1 -1
  19. package/dist/collab/routes.d.ts +1 -1
  20. package/dist/collab/struct-routes.d.ts +1 -1
  21. package/dist/resources/handlers.d.ts +1 -1
  22. package/dist/secrets/routes.d.ts +3 -3
  23. package/dist/shared/streaming-text-smoothing.d.ts.map +1 -1
  24. package/dist/shared/streaming-text-smoothing.js +15 -8
  25. package/dist/shared/streaming-text-smoothing.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/shared/streaming-text-smoothing.ts +15 -14
@@ -34,7 +34,7 @@ const DEVICE_REGION_SIZE: Record<
34
34
  > = {
35
35
  mobile: { width: 390, height: 844 },
36
36
  tablet: { width: 768, height: 1024 },
37
- desktop: { width: 1440, height: 1024 },
37
+ desktop: { width: 1440, height: 900 },
38
38
  };
39
39
 
40
40
  function regionSizeForScreen(screen: {
@@ -135,10 +135,11 @@ const requestedScreenSchema = z
135
135
  .enum(["mobile", "tablet", "desktop"])
136
136
  .optional()
137
137
  .describe(
138
- "Intended viewport for this screen. Defaults to desktop-sized when " +
139
- "omitted pass 'mobile' for phone screens and 'tablet' for iPad-" +
140
- "width screens so the canvas region matches the content instead of " +
141
- "always sizing every screen as desktop.",
138
+ "Intended viewport for this DISTINCT screen. Defaults to desktop-sized " +
139
+ "when omitted. Use 'mobile' or 'tablet' only for a genuinely " +
140
+ "device-specific screen (e.g. a mobile-only page), NOT to create " +
141
+ "per-device copies of the same page — those are breakpoint frames of " +
142
+ "one document, chosen with generate-design's `devices` param.",
142
143
  ),
143
144
  width: z
144
145
  .number()
@@ -168,15 +169,20 @@ const requestedScreenSchema = z
168
169
 
169
170
  export default defineAction({
170
171
  description:
171
- "Start a multi-screen generation session on the Design canvas. " +
172
- "Use this before generating multiple screens or variations in parallel: it " +
173
- "assigns non-overlapping canvas regions and returns per-frame generation " +
174
- "instructions including canvasFrame placements. The session state is " +
175
- "agent-facing planning state consumed by generate-design and view-screen. " +
176
- "Pass each screen's deviceType ('mobile', 'tablet', or 'desktop') so its " +
177
- "canvas region matches the content a desktop dashboard should not be " +
178
- "boxed into a 390px mobile-width frame. Defaults to desktop when omitted; " +
179
- "pass explicit width/height instead for a non-standard viewport. " +
172
+ "Start a multi-screen generation session on the Design canvas for " +
173
+ "genuinely DISTINCT screens different pages or states such as Home, " +
174
+ "Dashboard, and Checkout each saved as its own screen file. " +
175
+ "Do NOT use this to model device responsiveness: mobile/tablet/desktop " +
176
+ "variants of the SAME page are breakpoint frames of one document, not " +
177
+ "separate screen files. Choose those device frames with generate-design's " +
178
+ "`devices` param instead of adding one screen per device here. " +
179
+ "It assigns non-overlapping canvas regions and returns per-frame " +
180
+ "generation instructions including canvasFrame placements. The session " +
181
+ "state is agent-facing planning state consumed by generate-design and " +
182
+ "view-screen. Set a screen's deviceType ('mobile', 'tablet', or 'desktop') " +
183
+ "only for a genuinely device-specific distinct screen (e.g. a mobile-only " +
184
+ "page) so its canvas region matches the content; it defaults to desktop, " +
185
+ "or pass explicit width/height for a non-standard viewport. " +
180
186
  "After this action, fan out calls to generate-design for each returned " +
181
187
  "frame, passing the returned canvasFrame values to generate-design so " +
182
188
  "screens appear in the infinite overview canvas.",
@@ -326,6 +332,37 @@ export default defineAction({
326
332
  .map((file) => file.filename)
327
333
  .filter((filename): filename is string => Boolean(filename)),
328
334
  );
335
+
336
+ // Region packing starts at x:0, so offset the whole batch past screens
337
+ // already on the board — otherwise a follow-up session stacks its screens
338
+ // on top of existing ones (generate-design's per-frame collision check is
339
+ // only a fallback; offsetting here keeps the batch's own layout intact).
340
+ const existingDesignRows = await db
341
+ .select({ data: schema.designs.data })
342
+ .from(schema.designs)
343
+ .where(eq(schema.designs.id, designId));
344
+ let boardRightEdge = 0;
345
+ try {
346
+ const raw = existingDesignRows[0]?.data;
347
+ const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
348
+ const frames = (parsed.canvasFrames ?? {}) as Record<
349
+ string,
350
+ { x?: number; width?: number }
351
+ >;
352
+ for (const frame of Object.values(frames)) {
353
+ const x = typeof frame?.x === "number" ? frame.x : 0;
354
+ const width = typeof frame?.width === "number" ? frame.width : 0;
355
+ boardRightEdge = Math.max(
356
+ boardRightEdge,
357
+ x + width + DEFAULT_ASSIGNED_REGION_GAP,
358
+ );
359
+ }
360
+ } catch {
361
+ // Malformed data — fall back to the origin.
362
+ }
363
+ if (boardRightEdge > 0) {
364
+ for (const region of regions) region.x += boardRightEdge;
365
+ }
329
366
  // Dedupe any filename (explicit or auto-generated) so two screens can
330
367
  // never resolve to the same target file, and so no target collides with
331
368
  // an existing screen — otherwise generate-design silently overwrites the
@@ -29,10 +29,13 @@ const MOBILE_HEIGHT = 844;
29
29
  const TABLET_WIDTH = 768;
30
30
  const TABLET_HEIGHT = 1024;
31
31
  const DESKTOP_WIDTH = 1440;
32
- const DESKTOP_HEIGHT = 1024;
33
- const DEFAULT_RESPONSIVE_BREAKPOINTS = [390, 768, 1440].map((widthPx) => ({
32
+ const DESKTOP_HEIGHT = 900;
33
+ // Desktop-base default: the primary/base frame is Desktop (1440), so the
34
+ // breakpoint set is Mobile only. The primary width is never included and no
35
+ // tablet is auto-added, matching generate-design's device derivation.
36
+ const DEFAULT_RESPONSIVE_BREAKPOINTS = [MOBILE_WIDTH].map((widthPx) => ({
34
37
  id: `generated-${widthPx}`,
35
- label: widthPx === 390 ? "Mobile" : widthPx === 768 ? "Tablet" : "Desktop",
38
+ label: "Mobile",
36
39
  widthPx,
37
40
  prefix: widthToPrefix(widthPx),
38
41
  }));
@@ -66,6 +66,7 @@ import { zoomBridgeScript } from "../../../.generated/bridge/zoom.generated";
66
66
  import { isTrustedCanvasBridgeMessage } from "./bridge-security";
67
67
  import { captureAnnotatedScreenshot } from "./design-canvas/annotation-snapshot";
68
68
  import { submitDesignAnnotations } from "./design-canvas/annotation-submit";
69
+ import { appendContentSizeReporter } from "./design-canvas/content-size-report";
69
70
  import {
70
71
  getScreenContentPointFromClient,
71
72
  getZoomToCursorScrollDelta,
@@ -2181,6 +2182,13 @@ export function DesignCanvas({
2181
2182
  ].join("");
2182
2183
  frameDocument = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${frameStyle}</head><body>${iframeRenderContent}${bridgeToInject}</body></html>`;
2183
2184
  }
2185
+ // Overview frames report their own content height so the canvas can
2186
+ // content-fit them (Framer-style). Embedded (overview) frames only — a
2187
+ // focused single-screen view fills the viewport and must keep native
2188
+ // 100vh/min-h-screen, which the reporter's guard would otherwise pin.
2189
+ if (isEmbeddedFrame) {
2190
+ frameDocument = appendContentSizeReporter(frameDocument);
2191
+ }
2184
2192
  return injectSessionReplayIframeBootstrap(frameDocument);
2185
2193
  // editorChromeScaleX/Y are intentionally NOT deps: they only seed the initial
2186
2194
  // baked chrome scale. Live zoom updates flow through the set-editor-chrome-scale
@@ -82,6 +82,10 @@ import {
82
82
  DEFAULT_LINE_STROKE,
83
83
  DEFAULT_LINE_STROKE_WIDTH_PX,
84
84
  } from "./canvas-primitive-style";
85
+ import {
86
+ CONTENT_SIZE_REPORT_MESSAGE_TYPE,
87
+ appendContentSizeReporter,
88
+ } from "./design-canvas/content-size-report";
85
89
  import { appendHitTestResponder } from "./design-canvas/hit-test";
86
90
  import { DesignCanvas } from "./DesignCanvas";
87
91
  import { dndHostLog } from "./dnd-debug";
@@ -297,6 +301,7 @@ import {
297
301
  import {
298
302
  angleBetween,
299
303
  cloneFrameGeometryById,
304
+ deviceViewportFloorForWidth,
300
305
  findTopFrameEntryAtPoint,
301
306
  frameGeometryWithOverrides,
302
307
  frameStyleLeftTop,
@@ -314,6 +319,7 @@ import {
314
319
  resolveFrameGeometrySync,
315
320
  rotatePointAroundCenter,
316
321
  sameFrameGeometry,
322
+ visibleBreakpointWidths,
317
323
  } from "./multi-screen/frame-geometry";
318
324
  import {
319
325
  angleFromDraggedEndpoint,
@@ -489,6 +495,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
489
495
  const [surfaceSize, setSurfaceSize] = useState({ width: 0, height: 0 });
490
496
  const [frameGeometry, setFrameGeometry] = useState<FrameGeometryById>({});
491
497
  const frameGeometryRef = useRef(frameGeometry);
498
+ // Measured content height per [data-screen-iframe-id] (primary = screen id,
499
+ // breakpoint = getBreakpointIframeId); drives content-fit auto-height.
500
+ const [measuredIframeHeights, setMeasuredIframeHeights] = useState<
501
+ Record<string, number>
502
+ >({});
492
503
  const liveFrameDragPositionsRef = useRef(
493
504
  new Map<string, { left: number; top: number }>(),
494
505
  );
@@ -7237,15 +7248,86 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7237
7248
  // entry object when a screen's own screen/metadata/geometry are unchanged
7238
7249
  // by value, so mapping over canvasFrames for the N-1 screens NOT being
7239
7250
  // dragged doesn't allocate anything new for them on this same tick.
7251
+ // Opaque-origin sandboxed iframes can't be read via contentDocument, so map a
7252
+ // content-size report to its frame by matching event.source to contentWindow.
7253
+ useEffect(() => {
7254
+ const handleContentSize = (event: MessageEvent) => {
7255
+ const data = event.data;
7256
+ if (
7257
+ !data ||
7258
+ typeof data !== "object" ||
7259
+ data.type !== CONTENT_SIZE_REPORT_MESSAGE_TYPE ||
7260
+ typeof data.height !== "number" ||
7261
+ !Number.isFinite(data.height) ||
7262
+ data.height <= 0 ||
7263
+ // Ignore an implausible height (matches the persist sanity ceiling) so a
7264
+ // bogus postMessage can't blow a frame up to an unusable size.
7265
+ data.height > 100_000
7266
+ ) {
7267
+ return;
7268
+ }
7269
+ const surface = surfaceRef.current;
7270
+ if (!surface || !event.source) return;
7271
+ const iframes = surface.querySelectorAll<HTMLIFrameElement>(
7272
+ "iframe[data-screen-iframe-id]",
7273
+ );
7274
+ let iframeId: string | null = null;
7275
+ for (const iframe of iframes) {
7276
+ if (iframe.contentWindow === event.source) {
7277
+ iframeId = iframe.getAttribute("data-screen-iframe-id");
7278
+ break;
7279
+ }
7280
+ }
7281
+ if (!iframeId) return;
7282
+ const key = iframeId;
7283
+ const height = Math.round(data.height);
7284
+ setMeasuredIframeHeights((prev) => {
7285
+ const current = prev[key];
7286
+ // Ignore sub-pixel churn so a report can't trigger a re-render that
7287
+ // triggers another report.
7288
+ if (current !== undefined && Math.abs(current - height) <= 1) {
7289
+ return prev;
7290
+ }
7291
+ return { ...prev, [key]: height };
7292
+ });
7293
+ };
7294
+ window.addEventListener("message", handleContentSize);
7295
+ return () => window.removeEventListener("message", handleContentSize);
7296
+ }, []);
7297
+
7240
7298
  const canvasFrames = useMemo(() => {
7241
7299
  const cache = canvasFrameEntryCacheRef.current;
7242
7300
  const nextIds = new Set<string>();
7243
7301
  const next = renderedScreens.map((screen) => {
7244
7302
  nextIds.add(screen.id);
7245
7303
  const metadata = getResolvedMetadata(screen);
7246
- const geometry =
7304
+ const rawGeometry =
7247
7305
  frameGeometry[screen.id] ??
7248
7306
  getInitialFrameGeometry(screenIndexById.get(screen.id) ?? 0, metadata);
7307
+ // Content-fit height, applied ONLY once the frame's own content has been
7308
+ // measured, and only for inline (srcdoc) screens — URL-backed
7309
+ // localhost/fusion screens render src= and never report. Until a
7310
+ // measurement arrives the persisted geometry is kept untouched, so the
7311
+ // canvas scale and resize gestures are unaffected. Floors by the natural
7312
+ // metadata width (not the resizable box width, which would flip the
7313
+ // responsive scale on resize) and only grows; never shrinks a larger
7314
+ // user-set height.
7315
+ const measuredPrimaryHeight = measuredIframeHeights[screen.id];
7316
+ const isInlineScreen = !(
7317
+ metadata.previewUrl ?? getPreviewUrl(screen.content)
7318
+ );
7319
+ const autoHeight =
7320
+ isInlineScreen && measuredPrimaryHeight
7321
+ ? Math.max(
7322
+ deviceViewportFloorForWidth(metadata.width),
7323
+ rawGeometry.height ?? 0,
7324
+ measuredPrimaryHeight,
7325
+ )
7326
+ : (rawGeometry.height ?? 0);
7327
+ const geometry =
7328
+ !autoHeight || autoHeight === rawGeometry.height
7329
+ ? rawGeometry
7330
+ : { ...rawGeometry, height: autoHeight };
7249
7331
  const prior = cache.get(screen.id);
7250
7332
  if (
7251
7333
  prior &&
@@ -7269,7 +7351,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7269
7351
  }
7270
7352
  pruneResolvedMetadataCache(resolvedMetadataCacheRef.current, nextIds);
7271
7353
  return next;
7272
- }, [frameGeometry, getResolvedMetadata, renderedScreens, screenIndexById]);
7354
+ }, [
7355
+ frameGeometry,
7356
+ getResolvedMetadata,
7357
+ measuredIframeHeights,
7358
+ renderedScreens,
7359
+ screenIndexById,
7360
+ ]);
7273
7361
  // Interaction-protected screens are never eligible for LRU eviction. Active
7274
7362
  // screen/frame selection are the primary signals; layer selection, native
7275
7363
  // file-drop targeting, gradient editing, and in-flight frame transforms are
@@ -7321,10 +7409,21 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7321
7409
  canvasZoom,
7322
7410
  );
7323
7411
  const next = computeBoundedScreenCullState({
7324
- candidates: canvasFrames.map(({ screen, geometry }) => ({
7412
+ candidates: canvasFrames.map(({ screen, metadata, geometry }) => ({
7325
7413
  id: screen.id,
7326
- geometry: getResponsiveScreenCullGeometry(screen, geometry),
7327
- iframeCount: 1 + (screen.breakpointWidths?.length ?? 0),
7414
+ geometry: getResponsiveScreenCullGeometry(
7415
+ screen,
7416
+ geometry,
7417
+ (widthPx) =>
7418
+ measuredIframeHeights[getBreakpointIframeId(screen.id, widthPx)],
7419
+ ),
7420
+ // Count only the breakpoint frames actually mounted (the row filters
7421
+ // duplicates of the device width) so the iframe budget isn't
7422
+ // over-consumed, prematurely evicting visible frames.
7423
+ iframeCount:
7424
+ 1 +
7425
+ visibleBreakpointWidths(screen.breakpointWidths, metadata.width)
7426
+ .length,
7328
7427
  })),
7329
7428
  viewport,
7330
7429
  protectedScreenIds: protectedLiveScreenIds,
@@ -7337,7 +7436,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7337
7436
  hasBeenVisibleScreenIdsRef.current = next.everVisibleScreenIds;
7338
7437
  lastVisibleEpochByScreenIdRef.current = next.lastVisibleEpochByScreenId;
7339
7438
  return next.tierByScreenId;
7340
- }, [canvasFrames, canvasZoom, pan, protectedLiveScreenIds, surfaceSize]);
7439
+ }, [
7440
+ canvasFrames,
7441
+ canvasZoom,
7442
+ measuredIframeHeights,
7443
+ pan,
7444
+ protectedLiveScreenIds,
7445
+ surfaceSize,
7446
+ ]);
7341
7447
  const topScreenId = useMemo(
7342
7448
  () =>
7343
7449
  selectedIds.find((id) =>
@@ -7692,6 +7798,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7692
7798
  screen={screen}
7693
7799
  metadata={metadata}
7694
7800
  geometry={geometry}
7801
+ measuredIframeHeights={measuredIframeHeights}
7695
7802
  locked={lockedScreenIdSet.has(screen.id)}
7696
7803
  screenContent={screenContentById.get(screen.id)}
7697
7804
  renderBreakpointContent={renderBreakpointContent}
@@ -9008,6 +9115,9 @@ interface ScreenProps {
9008
9115
  screen: ScreenFile;
9009
9116
  metadata: ResolvedScreenMetadata;
9010
9117
  geometry: FrameGeometry;
9118
+ /** Measured content heights by [data-screen-iframe-id]; drives breakpoint
9119
+ * frame auto-height. */
9120
+ measuredIframeHeights: Record<string, number>;
9011
9121
  locked: boolean;
9012
9122
  isActive: boolean;
9013
9123
  isSelected: boolean;
@@ -9079,6 +9189,7 @@ const Screen = memo(function Screen({
9079
9189
  screen,
9080
9190
  metadata,
9081
9191
  geometry,
9192
+ measuredIframeHeights,
9082
9193
  locked,
9083
9194
  isActive,
9084
9195
  isSelected,
@@ -9173,8 +9284,12 @@ const Screen = memo(function Screen({
9173
9284
  // rebuild the string every render (that would reload the iframe).
9174
9285
  // Keyed only on screen.content; the hit-test script itself is constant.
9175
9286
  const srcdocWithHitTest = useMemo(() => {
9287
+ // Also carries the content-size reporter so breakpoint/primary frames
9288
+ // rendered via this fallback iframe (read-only/thumbnail overview, when no
9289
+ // editable DesignCanvas is supplied) still report their own height and
9290
+ // content-fit. The editable DesignCanvas path injects its own reporter.
9176
9291
  return injectSessionReplayIframeBootstrap(
9177
- appendHitTestResponder(screen.content),
9292
+ appendContentSizeReporter(appendHitTestResponder(screen.content)),
9178
9293
  );
9179
9294
  // eslint-disable-next-line react-hooks/exhaustive-deps
9180
9295
  }, [screen.content]);
@@ -9584,6 +9699,7 @@ const Screen = memo(function Screen({
9584
9699
  <BreakpointPreviewRow
9585
9700
  screen={screen}
9586
9701
  primaryGeometry={geometry}
9702
+ measuredIframeHeights={measuredIframeHeights}
9587
9703
  // See getBreakpointFrameGeometry's doc comment: reusing the
9588
9704
  // primary frame's OWN previewViewport.scale (how far the user has
9589
9705
  // resized this screen's box away from its natural/metadata width)
@@ -9641,9 +9757,29 @@ const Screen = memo(function Screen({
9641
9757
  );
9642
9758
  }, areScreenPropsEqual);
9643
9759
 
9760
+ /** Compares only this screen's breakpoint heights so one frame's report
9761
+ * re-renders only its owning screen (the primary rides on `geometry`). */
9762
+ function sameBreakpointMeasuredHeights(
9763
+ screen: ScreenFile,
9764
+ a: Record<string, number>,
9765
+ b: Record<string, number>,
9766
+ ): boolean {
9767
+ if (a === b) return true;
9768
+ for (const width of screen.breakpointWidths ?? []) {
9769
+ const key = getBreakpointIframeId(screen.id, width);
9770
+ if (a[key] !== b[key]) return false;
9771
+ }
9772
+ return true;
9773
+ }
9774
+
9644
9775
  function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
9645
9776
  return (
9646
9777
  prev.screen === next.screen &&
9778
+ sameBreakpointMeasuredHeights(
9779
+ prev.screen,
9780
+ prev.measuredIframeHeights,
9781
+ next.measuredIframeHeights,
9782
+ ) &&
9647
9783
  prev.screenContent === next.screenContent &&
9648
9784
  prev.renderBreakpointContent === next.renderBreakpointContent &&
9649
9785
  prev.cullTier === next.cullTier &&
@@ -9717,6 +9853,7 @@ const BREAKPOINT_LABEL_WITH_WIDTH_MIN_FRAME_WIDTH = 128;
9717
9853
  function BreakpointPreviewRow({
9718
9854
  screen,
9719
9855
  primaryGeometry,
9856
+ measuredIframeHeights,
9720
9857
  primaryScale,
9721
9858
  naturalAspect,
9722
9859
  previewUrl,
@@ -9742,6 +9879,8 @@ function BreakpointPreviewRow({
9742
9879
  }: {
9743
9880
  screen: ScreenFile;
9744
9881
  primaryGeometry: FrameGeometry;
9882
+ /** Measured content heights by [data-screen-iframe-id]. */
9883
+ measuredIframeHeights: Record<string, number>;
9745
9884
  /** The primary frame's own resize scale (`getScreenPreviewViewport(...).
9746
9885
  * scale`) — see getBreakpointFrameGeometry's doc comment for why this,
9747
9886
  * not primaryGeometry alone, drives each breakpoint frame's on-canvas
@@ -9807,7 +9946,11 @@ function BreakpointPreviewRow({
9807
9946
  const frameActionLabel = interactMode
9808
9947
  ? t("multiScreenCanvas.fullView")
9809
9948
  : t("designEditor.modes.interact");
9810
- const breakpointWidths = screen.breakpointWidths ?? [];
9949
+ const breakpointWidths = visibleBreakpointWidths(
9950
+ screen.breakpointWidths,
9951
+ // Immutable device width, not the resizable box width (see frame-geometry).
9952
+ metadata.width ?? primaryGeometry.width,
9953
+ );
9811
9954
  // Place additional frames to the right of the primary, starting after the gap
9812
9955
  let offsetX = primaryGeometry.width + BREAKPOINT_FRAME_GAP;
9813
9956
 
@@ -9826,7 +9969,13 @@ function BreakpointPreviewRow({
9826
9969
  <>
9827
9970
  {breakpointWidths.map((widthPx) => {
9828
9971
  const { frameWidth, frameHeight, naturalHeight, scale } =
9829
- getBreakpointFrameGeometry({ widthPx, naturalAspect, primaryScale });
9972
+ getBreakpointFrameGeometry({
9973
+ widthPx,
9974
+ naturalAspect,
9975
+ primaryScale,
9976
+ contentHeightPx:
9977
+ measuredIframeHeights[getBreakpointIframeId(screen.id, widthPx)],
9978
+ });
9830
9979
  const isActive = activeBreakpointWidth === widthPx;
9831
9980
  const editableContent = renderBreakpointContent?.(screen, metadata, {
9832
9981
  widthPx,
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Content-size reporter injected into every canvas iframe (primary + each
3
+ * breakpoint) so a frame can grow to fit its own content instead of inheriting
4
+ * the primary frame's aspect ratio. The iframes are sandbox="allow-scripts"
5
+ * (opaque origin), so the parent can't read contentDocument — this measures
6
+ * scrollHeight inside the frame and posts { type, width, height } out, keyed by
7
+ * event.source. It first pins full-height utilities to a fixed per-frame
8
+ * --agent-native-device-vh so a min-h-screen hero can't chase the growing frame
9
+ * (runaway); raw inline 100vh is not remapped.
10
+ */
11
+
12
+ const CONTENT_SIZE_REPORT_BRIDGE = `
13
+ <style data-agent-native-content-size-guard>
14
+ .min-h-screen { min-height: var(--agent-native-device-vh, 100vh) !important; }
15
+ .h-screen { height: var(--agent-native-device-vh, 100vh) !important; }
16
+ .min-h-dvh { min-height: var(--agent-native-device-vh, 100dvh) !important; }
17
+ .h-dvh { height: var(--agent-native-device-vh, 100dvh) !important; }
18
+ .min-h-svh { min-height: var(--agent-native-device-vh, 100svh) !important; }
19
+ .h-svh { height: var(--agent-native-device-vh, 100svh) !important; }
20
+ </style>
21
+ <script data-agent-native-content-size-bridge>
22
+ (function () {
23
+ if (window.__agentNativeContentSizeReport) return;
24
+ window.__agentNativeContentSizeReport = true;
25
+
26
+ // Must match deviceViewportFloorForWidth in frame-geometry.ts.
27
+ function deviceViewportHeight(widthPx) {
28
+ if (widthPx <= 640) return 844; // phone
29
+ if (widthPx <= 1024) return 1024; // tablet
30
+ return 900; // desktop
31
+ }
32
+
33
+ // Idempotent: only writes when the value changes. The MutationObserver below
34
+ // watches documentElement attributes, so an unconditional re-write here would
35
+ // observe its own style change and loop forever (~60fps) on static content.
36
+ var lastVh = "";
37
+ function applyDeviceVh() {
38
+ var vh = deviceViewportHeight(window.innerWidth || 0) + "px";
39
+ if (vh === lastVh) return;
40
+ lastVh = vh;
41
+ document.documentElement.style.setProperty("--agent-native-device-vh", vh);
42
+ }
43
+
44
+ function measure() {
45
+ var doc = document.documentElement;
46
+ var body = document.body;
47
+ return Math.max(
48
+ doc ? doc.scrollHeight : 0,
49
+ body ? body.scrollHeight : 0,
50
+ body ? body.offsetHeight : 0,
51
+ );
52
+ }
53
+
54
+ var lastHeight = -1;
55
+ var lastWidth = -1;
56
+ var frame = 0;
57
+ function report() {
58
+ frame = 0;
59
+ applyDeviceVh();
60
+ var height = measure();
61
+ var width = window.innerWidth || 0;
62
+ if (height === lastHeight && width === lastWidth) return;
63
+ lastHeight = height;
64
+ lastWidth = width;
65
+ try {
66
+ window.parent.postMessage(
67
+ { type: "agent-native:content-size", width: width, height: height },
68
+ "*",
69
+ );
70
+ } catch (err) {
71
+ /* cross-origin parent — ignore */
72
+ }
73
+ }
74
+ function scheduleReport() {
75
+ if (frame) return;
76
+ frame = window.requestAnimationFrame(report);
77
+ }
78
+
79
+ applyDeviceVh();
80
+
81
+ if (typeof ResizeObserver === "function") {
82
+ var ro = new ResizeObserver(scheduleReport);
83
+ if (document.documentElement) ro.observe(document.documentElement);
84
+ }
85
+ if (typeof MutationObserver === "function") {
86
+ var mo = new MutationObserver(scheduleReport);
87
+ mo.observe(document.documentElement, {
88
+ childList: true,
89
+ subtree: true,
90
+ attributes: true,
91
+ characterData: true,
92
+ });
93
+ }
94
+ window.addEventListener("resize", scheduleReport);
95
+ window.addEventListener("load", scheduleReport);
96
+ // Late webfont / Tailwind-JIT reflows: a few settle passes catch height
97
+ // changes that land after the initial paint without a permanent timer.
98
+ [0, 120, 400, 1000].forEach(function (delay) {
99
+ window.setTimeout(scheduleReport, delay);
100
+ });
101
+ scheduleReport();
102
+ })();
103
+ </script>
104
+ `;
105
+
106
+ export const CONTENT_SIZE_REPORT_MESSAGE_TYPE = "agent-native:content-size";
107
+
108
+ /** Appends the reporter + full-height guard, mirroring appendHitTestResponder's
109
+ * marker handling so it runs regardless of document structure. */
110
+ export function appendContentSizeReporter(html: string): string {
111
+ if (html.includes("</body>")) {
112
+ return html.replace(
113
+ "</body>", // i18n-ignore generated iframe HTML marker
114
+ CONTENT_SIZE_REPORT_BRIDGE + "</body>", // i18n-ignore generated iframe HTML injection
115
+ );
116
+ }
117
+ if (html.includes("</html>")) {
118
+ return html.replace(
119
+ "</html>", // i18n-ignore generated iframe HTML marker
120
+ CONTENT_SIZE_REPORT_BRIDGE + "</html>", // i18n-ignore generated iframe HTML injection
121
+ );
122
+ }
123
+ return html + CONTENT_SIZE_REPORT_BRIDGE;
124
+ }
@@ -26,9 +26,39 @@ type ResponsiveLayoutScreen = {
26
26
  layoutGroupId?: string;
27
27
  };
28
28
 
29
+ /** Drops any breakpoint whose width equals the primary frame's own width — a
30
+ * redundant duplicate of the base — also cleaning up designs authored before
31
+ * the default set excluded the primary width. */
32
+ export function visibleBreakpointWidths(
33
+ breakpointWidths: readonly number[] | undefined,
34
+ primaryWidthPx: number | undefined,
35
+ ): number[] {
36
+ const deduped = Array.from(
37
+ new Set(
38
+ (breakpointWidths ?? []).filter(
39
+ (width) => Number.isFinite(width) && width > 0,
40
+ ),
41
+ ),
42
+ );
43
+ if (primaryWidthPx === undefined || !Number.isFinite(primaryWidthPx)) {
44
+ return deduped;
45
+ }
46
+ return deduped.filter((width) => Math.abs(width - primaryWidthPx) > 1);
47
+ }
48
+
49
+ /** Minimum height for a frame of the given width — one device viewport tall
50
+ * before it grows to content. Keep in sync with deviceViewportHeight in
51
+ * content-size-report.ts. */
52
+ export function deviceViewportFloorForWidth(widthPx: number): number {
53
+ if (!Number.isFinite(widthPx) || widthPx <= 640) return 844;
54
+ if (widthPx <= 1024) return 1024;
55
+ return 900;
56
+ }
57
+
29
58
  export function getResponsiveScreenGroupSize(
30
59
  screen: ResponsiveLayoutScreen,
31
60
  primaryGeometry?: Partial<FrameGeometry>,
61
+ resolveBreakpointHeightPx?: (widthPx: number) => number | undefined,
32
62
  ): {
33
63
  width: number;
34
64
  height: number;
@@ -42,9 +72,18 @@ export function getResponsiveScreenGroupSize(
42
72
  const sourceWidth = Math.max(1, screen.metadata?.width ?? 1280);
43
73
  const sourceHeight = Math.max(1, screen.metadata?.height ?? 2560);
44
74
  const scale = baseWidth / sourceWidth;
45
- const breakpoints = (screen.breakpointWidths ?? []).filter(
46
- (width) => Number.isFinite(width) && width > 0,
75
+ const breakpoints = visibleBreakpointWidths(
76
+ screen.breakpointWidths,
77
+ // The immutable device width, not the resizable on-canvas box width — a
78
+ // primary resized to a breakpoint width must not hide that breakpoint.
79
+ screen.metadata?.width ?? primaryGeometry?.width,
47
80
  );
81
+ const breakpointNaturalHeight = (width: number) => {
82
+ const measured = resolveBreakpointHeightPx?.(width);
83
+ return measured && measured > 0
84
+ ? Math.max(deviceViewportFloorForWidth(width), measured)
85
+ : (width * sourceHeight) / sourceWidth;
86
+ };
48
87
  return {
49
88
  width:
50
89
  baseWidth +
@@ -54,9 +93,7 @@ export function getResponsiveScreenGroupSize(
54
93
  ),
55
94
  height: Math.max(
56
95
  baseHeight,
57
- ...breakpoints.map(
58
- (width) => width * (sourceHeight / sourceWidth) * scale,
59
- ),
96
+ ...breakpoints.map((width) => breakpointNaturalHeight(width) * scale),
60
97
  ),
61
98
  };
62
99
  }
@@ -73,8 +110,13 @@ export function getResponsiveScreenGroupSize(
73
110
  export function getResponsiveScreenCullGeometry(
74
111
  screen: ResponsiveLayoutScreen,
75
112
  primaryGeometry: FrameGeometry,
113
+ resolveBreakpointHeightPx?: (widthPx: number) => number | undefined,
76
114
  ): FrameGeometry {
77
- const size = getResponsiveScreenGroupSize(screen, primaryGeometry);
115
+ const size = getResponsiveScreenGroupSize(
116
+ screen,
117
+ primaryGeometry,
118
+ resolveBreakpointHeightPx,
119
+ );
78
120
  const rotation = primaryGeometry.rotation ?? 0;
79
121
  if (!rotation) {
80
122
  return {
@@ -306,6 +348,9 @@ export function getBreakpointFrameGeometry(args: {
306
348
  widthPx: number;
307
349
  naturalAspect: number;
308
350
  primaryScale: number;
351
+ /** Measured content height at this width; wins over the primary-aspect
352
+ * projection, which clipped narrower frames (they reflow taller). */
353
+ contentHeightPx?: number;
309
354
  }): {
310
355
  frameWidth: number;
311
356
  frameHeight: number;
@@ -316,9 +361,16 @@ export function getBreakpointFrameGeometry(args: {
316
361
  Number.isFinite(args.primaryScale) && args.primaryScale > 0
317
362
  ? args.primaryScale
318
363
  : 1;
319
- const naturalHeight = Math.round(
320
- args.widthPx * Math.max(0.01, args.naturalAspect),
321
- );
364
+ const measured =
365
+ args.contentHeightPx && args.contentHeightPx > 0
366
+ ? Math.round(args.contentHeightPx)
367
+ : undefined;
368
+ // Until the frame's own content is measured, use the pure aspect projection;
369
+ // the device-viewport floor only applies once a real height is known.
370
+ const naturalHeight =
371
+ measured !== undefined
372
+ ? Math.max(deviceViewportFloorForWidth(args.widthPx), measured)
373
+ : Math.round(args.widthPx * Math.max(0.01, args.naturalAspect));
322
374
  const frameWidth = Math.round(args.widthPx * scale);
323
375
  const frameHeight = Math.round(naturalHeight * scale);
324
376
  return { frameWidth, frameHeight, naturalHeight, scale };