@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
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1736
32
32
  - toolkit files: 160
33
- - template files: 6787
33
+ - template files: 6791
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.124.3
4
+
5
+ ### Patch Changes
6
+
7
+ - c385fed: Keep emoji and other multi-code-point grapheme clusters intact while streaming text. The incremental segmentation cache re-segmented from a fixed character offset, so a cluster straddling that offset was cut in half and characters were silently dropped from the smoothed output. The re-segmentation window now starts on a grapheme boundary.
8
+
3
9
  ## 0.124.2
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.124.2",
3
+ "version": "0.124.3",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -70,25 +70,26 @@ export function splitStreamingTextGraphemes(text: string): string[] {
70
70
  // Incremental path: new text extends the cached text
71
71
  if (_segCache && text.startsWith(_segCache.text)) {
72
72
  const prevLen = _segCache.text.length;
73
- // Re-segment from (prevLen - OVERLAP) to include the overlap so any
74
- // grapheme that was split across the boundary is correctly re-assembled.
75
- const overlapStart = Math.max(0, prevLen - SEGMENTER_OVERLAP);
73
+ const cached = _segCache.graphemes;
74
+ // Walk back over cached graphemes until at least OVERLAP characters have
75
+ // been released, so the re-segmented suffix starts on a known grapheme
76
+ // boundary. Slicing at a fixed character offset instead would cut a
77
+ // cluster (surrogate pair, ZWJ sequence) in half, and segmenting that
78
+ // partial cluster in isolation yields a different grapheme count than the
79
+ // cache holds for the same region — which silently drops characters.
80
+ let stableCount = cached.length;
81
+ let releasedChars = 0;
82
+ while (stableCount > 0 && releasedChars < SEGMENTER_OVERLAP) {
83
+ stableCount--;
84
+ releasedChars += cached[stableCount]!.length;
85
+ }
86
+ const overlapStart = prevLen - releasedChars;
76
87
  const suffix = text.slice(overlapStart);
77
88
  const newGraphemes = Array.from(
78
89
  segmenter.segment(suffix),
79
90
  (entry) => entry.segment,
80
91
  );
81
- // Drop graphemes that correspond to the overlap region from the cache so
82
- // we don't double-count them.
83
- const overlapGraphemes = Array.from(
84
- segmenter.segment(text.slice(overlapStart, prevLen)),
85
- (entry) => entry.segment,
86
- );
87
- const stableGraphemes = _segCache.graphemes.slice(
88
- 0,
89
- _segCache.graphemes.length - overlapGraphemes.length,
90
- );
91
- const merged = stableGraphemes.concat(newGraphemes);
92
+ const merged = cached.slice(0, stableCount).concat(newGraphemes);
92
93
  _segCache = { text, graphemes: merged };
93
94
  return merged;
94
95
  }
@@ -249,7 +249,12 @@ Favor choice-first questions (2-5 concrete options, `allowOther: true`, and a
249
249
  combining multiple answers — stacking multi-select questions multiplies
250
250
  follow-up ambiguity instead of resolving it.
251
251
 
252
- **Carry the form-factor answer through to generation — do not just ask and discard it.** A "Desktop web app" answer means the generated screen's canvas frame must be desktop-sized (~1440×1024), not left at whatever a screen with no placement falls back to. Map the answer to real frame geometry: pass `deviceType` (`"mobile"` / `"tablet"` / `"desktop"`) per screen to `generate-screens`, explicit `width`/`height` per variant to `present-design-variants`, or an explicit `canvasFrames` entry to `generate-design` see Phase 2 and Phase 3 below. For "Both / responsive," generate at desktop width and rely on the responsive breakpoint system (see `responsive-breakpoints` skill) rather than guessing a size.
252
+ **Carry the form-factor answer through to generation — do not just ask and discard it.** Map the answer to the design's device SET, not to separate per-device screen files. Device widths of the SAME page are breakpoint frames of one document (see the `responsive-breakpoints` skill), never a `mobile.html` + `desktop.html` pair. Pass the answer through `generate-design`'s `devices` param `("mobile"|"tablet"|"desktop")[]`, default `["desktop","mobile"]`:
253
+
254
+ - If the prompt/answer names specific devices, generate EXACTLY those, deduped ("mobile" only → one mobile frame; "mobile, tablet, desktop" → all three).
255
+ - If nothing about form factor is specified — or the answer is "Both / responsive" or "Decide for me" — default to `["desktop","mobile"]`: a Desktop base + a Mobile frame only. Never auto-add a tablet, a redundant desktop, or a stray duplicate frame.
256
+
257
+ The WIDEST requested device is the base/primary frame; narrower devices become breakpoint frames (never at the primary width). Device frame sizes: mobile 390×844, tablet 768×1024, desktop 1440×900. `present-design-variants` still takes explicit `width`/`height` per variant to size its exploration screens (see Phase 2).
253
258
 
254
259
  ### Phase 2 — Generate side-by-side variations (2-5, three by default)
255
260
 
@@ -264,16 +269,16 @@ screen name.
264
269
  "designId": "<the design id>",
265
270
  "prompt": "Pick a direction",
266
271
  "variants": [
267
- { "id": "a", "label": "Editorial Serif", "width": 1440, "height": 1024, "content": "<!DOCTYPE html>...full self-contained HTML..." },
268
- { "id": "b", "label": "Bold Brutalist", "width": 1440, "height": 1024, "content": "<!DOCTYPE html>..." },
269
- { "id": "c", "label": "Soft & Spacious", "width": 1440, "height": 1024, "content": "<!DOCTYPE html>..." }
272
+ { "id": "a", "label": "Editorial Serif", "width": 1440, "height": 900, "content": "<!DOCTYPE html>...full self-contained HTML..." },
273
+ { "id": "b", "label": "Bold Brutalist", "width": 1440, "height": 900, "content": "<!DOCTYPE html>..." },
274
+ { "id": "c", "label": "Soft & Spacious", "width": 1440, "height": 900, "content": "<!DOCTYPE html>..." }
270
275
  ]
271
276
  }
272
277
  ```
273
278
 
274
279
  Each `content` is a complete, self-contained document (Alpine.js + Tailwind via CDN, full `<head>`, CSS variables in `:root`). Variations should be **stylistically/structurally distinct** — different typography schools, layout grammars, color moods — never just color swaps. Label them with concrete style names ("Editorial Serif", not "Variant A").
275
280
 
276
- Pass `width`/`height` on every variant to match the form-factor answer (mobile ≈ 390×844, tablet ≈ 768×1024, desktop ≈ 1440×1024) — the example above is desktop-sized. When `content` is omitted, `present-design-variants` infers a size from the prompt/label/description text and the width/height you pass still wins when given.
281
+ Pass `width`/`height` on every variant to match the form-factor answer (mobile ≈ 390×844, tablet ≈ 768×1024, desktop ≈ 1440×900) — the example above is desktop-sized. When `content` is omitted, `present-design-variants` infers a size from the prompt/label/description text and the width/height you pass still wins when given.
277
282
 
278
283
  Wait for the user's pick before refining. Once they choose, keep the selected
279
284
  screen, delete the unchosen variant screens with `delete-file`, and continue
@@ -299,10 +304,11 @@ pnpm action generate-design \
299
304
  --prompt "Description of the design" \
300
305
  --files '[{"filename":"index.html","content":"<full HTML>","fileType":"html"}]' \
301
306
  --tweaks '[{"id":"accent","label":"Accent","type":"color-swatch","options":[...],"defaultValue":"#0EA5E9","cssVar":"--color-accent"}]' \
302
- --canvasFrames '[{"filename":"index.html","x":0,"y":0,"width":1440,"height":1024}]'
307
+ --devices '["desktop","mobile"]' \
308
+ --canvasFrames '[{"filename":"index.html","x":0,"y":0,"width":1440,"height":900}]'
303
309
  ```
304
310
 
305
- Always pass `canvasFrames` with an explicit `width`/`height` matching the form-factor answer (mobile ≈ 390×844, tablet ≈ 768×1024, desktop ≈ 1440×1024 as above) — a screen saved without a placement falls back to a generic default that won't match a desktop-intended design. For multiple screens generated together, call `generate-screens` first and pass `deviceType` (`"mobile"` / `"tablet"` / `"desktop"`) per screen; it returns the matching `canvasFrame` to forward to each `generate-design` call.
311
+ Pass the `devices` param (`("mobile"|"tablet"|"desktop")[]`, default `["desktop","mobile"]`) so a new design renders the right device frames: the widest device becomes the primary `canvasFrames` placement and each narrower device is added as a breakpoint frame of the SAME document — not an extra file. Default to Desktop + Mobile when the form factor is unspecified; never auto-add a tablet or a redundant desktop. When you also pass `canvasFrames` explicitly, size the primary to the widest device (mobile ≈ 390×844, tablet ≈ 768×1024, desktop ≈ 1440×900) — a screen saved without a placement falls back to a generic default that won't match the intended device. For genuinely distinct screens generated together (Home / Dashboard / Checkout NOT per-device copies of one page), call `generate-screens` first; it returns the matching `canvasFrame` to forward to each `generate-design` call.
306
312
 
307
313
  #### Non-web sizes — ad units, print one-pagers, social sizes
308
314
 
@@ -411,7 +417,11 @@ Every `index.html` must include:
411
417
  }
412
418
 
413
419
  /* Base styles */
414
- body { font-family: var(--font-body); }
420
+ body {
421
+ font-family: var(--font-body);
422
+ min-height: 100vh;
423
+ min-height: 100dvh; /* fill the frame — see Full-height frames below */
424
+ }
415
425
  h1, h2, h3, h4, h5, h6 {
416
426
  font-family: var(--font-heading);
417
427
  text-wrap: balance;
@@ -430,14 +440,24 @@ Every `index.html` must include:
430
440
  </style>
431
441
  </head>
432
442
  <body class="bg-[var(--color-primary)] text-[var(--color-text)]">
433
- <!-- All interactive state goes on a root x-data -->
434
- <div x-data="{ /* component state */ }">
443
+ <!-- Root wrapper fills the device viewport: prefer min-h-screen over inline height:100vh -->
444
+ <div x-data="{ /* component state */ }" class="min-h-screen">
435
445
  <!-- Content -->
436
446
  </div>
437
447
  </body>
438
448
  </html>
439
449
  ```
440
450
 
451
+ **Full-height frames.** Overview frames now default to at least the device
452
+ viewport height and grow to fit their own content at their own width
453
+ (Framer-style), so a short page must still fill its frame instead of leaving a
454
+ stubby box. The mandatory `body` rule above sets `min-height: 100dvh` (with a
455
+ `100vh` fallback) for exactly this. Put full-height intent on the top-level
456
+ wrapper with Tailwind's `min-h-screen` rather than raw inline `height: 100vh` —
457
+ the editor keeps device-anchored full-height utilities pinned to the device
458
+ viewport, so a full-height hero fills the frame without triggering runaway
459
+ growth.
460
+
441
461
  ### Alpine.js Patterns
442
462
 
443
463
  **State management** — use `x-data` on the highest-level container:
@@ -661,7 +681,10 @@ Fontshare `<link>`/`@import` and confirm it renders.
661
681
 
662
682
  A prototype with more than one screen is **multiple files** in the same design
663
683
  (e.g. `index.html`, `dashboard.html`, `checkout.html`). The editor shows them
664
- all in the artboard/overview and as screen tabs.
684
+ all in the artboard/overview and as screen tabs. This is only for genuinely
685
+ distinct screens — the mobile and desktop views of the *same* page are
686
+ breakpoint frames of ONE document (the `devices` param + the
687
+ `responsive-breakpoints` model), never a second file.
665
688
 
666
689
  The preview renders each file in a sandboxed `srcdoc` iframe. A real
667
690
  `<a href="/pricing">` or `<a href="page.html">` resolves against the *app* URL
@@ -27,6 +27,14 @@ scopes to `≤ 809px`). `breakpointUpperBoundPx` in
27
27
 
28
28
  ## Managing breakpoints
29
29
 
30
+ A newly generated design's frame set comes from `generate-design`'s `devices`
31
+ param (`("mobile"|"tablet"|"desktop")[]`, default `["desktop","mobile"]`). The
32
+ cascade is **desktop-base**: the widest requested device is the primary/base
33
+ frame and each narrower device is a breakpoint frame. The default injected set
34
+ is therefore a Desktop base plus a single Mobile (390) breakpoint frame — the
35
+ narrower frame(s) only, never a frame at the primary desktop width and never an
36
+ auto-added tablet.
37
+
30
38
  - `add-breakpoint` — adds a device-width frame to `designs.data.breakpointSet`
31
39
  (Framer defaults: Desktop 1200 / Tablet 810 / Phone 390, or custom 320-3840).
32
40
  Duplicate widths are ignored.
@@ -77,15 +77,66 @@ const GENERATION_VIEWPORT_SIZES: Record<
77
77
  > = {
78
78
  mobile: { width: 390, height: 844 },
79
79
  tablet: { width: 768, height: 1024 },
80
- desktop: { width: 1440, height: 1024 },
80
+ desktop: { width: 1440, height: 900 },
81
81
  };
82
+ const GENERATION_VIEWPORT_LABELS: Record<GenerationViewport, string> = {
83
+ mobile: "Mobile",
84
+ tablet: "Tablet",
85
+ desktop: "Desktop",
86
+ };
87
+ // Widest → narrowest. The widest requested device seeds the primary/base
88
+ // frame; only the narrower devices become breakpoint frames.
89
+ const DEVICE_WIDTH_ORDER: readonly GenerationViewport[] = [
90
+ "desktop",
91
+ "tablet",
92
+ "mobile",
93
+ ];
82
94
  const GENERATED_FRAME_GAP = 96;
83
- const DEFAULT_RESPONSIVE_BREAKPOINTS = [390, 768, 1440].map((widthPx) => ({
84
- id: `generated-${widthPx}`,
85
- label: widthPx === 390 ? "Mobile" : widthPx === 768 ? "Tablet" : "Desktop",
86
- widthPx,
87
- prefix: widthToPrefix(widthPx),
88
- }));
95
+
96
+ function widestGenerationDevice(
97
+ devices: readonly GenerationViewport[],
98
+ ): GenerationViewport {
99
+ return (
100
+ DEVICE_WIDTH_ORDER.find((device) => devices.includes(device)) ??
101
+ DEFAULT_GENERATION_VIEWPORT
102
+ );
103
+ }
104
+
105
+ // Devices used when the caller omits `devices`: the requested primary form
106
+ // factor as the base plus Mobile, unless the primary already is Mobile. The
107
+ // default (desktop primary) therefore yields a Desktop base + Mobile
108
+ // breakpoint, matching the two-screen default.
109
+ function devicesForPrimaryViewport(
110
+ primaryViewport: GenerationViewport,
111
+ ): GenerationViewport[] {
112
+ return primaryViewport === "mobile"
113
+ ? ["mobile"]
114
+ : [primaryViewport, "mobile"];
115
+ }
116
+
117
+ // Breakpoint frames = every requested device NARROWER than the primary
118
+ // (widest) one, ascending by width. The primary/widest width is never emitted,
119
+ // so a single-device request produces an empty set (one frame, no sub-frames)
120
+ // and no redundant frame ever lands at the base canvas width.
121
+ function breakpointSetForDevices(devices: readonly GenerationViewport[]) {
122
+ const primaryWidth =
123
+ GENERATION_VIEWPORT_SIZES[widestGenerationDevice(devices)].width;
124
+ return Array.from(new Set(devices))
125
+ .filter((device) => GENERATION_VIEWPORT_SIZES[device].width < primaryWidth)
126
+ .sort(
127
+ (a, b) =>
128
+ GENERATION_VIEWPORT_SIZES[a].width - GENERATION_VIEWPORT_SIZES[b].width,
129
+ )
130
+ .map((device) => {
131
+ const widthPx = GENERATION_VIEWPORT_SIZES[device].width;
132
+ return {
133
+ id: `generated-${widthPx}`,
134
+ label: GENERATION_VIEWPORT_LABELS[device],
135
+ widthPx,
136
+ prefix: widthToPrefix(widthPx),
137
+ };
138
+ });
139
+ }
89
140
 
90
141
  const reuseLabelSchema = z
91
142
  .object({
@@ -123,16 +174,6 @@ function hasBreakpointSet(value: unknown): boolean {
123
174
  return Array.isArray(breakpoints) && breakpoints.length > 0;
124
175
  }
125
176
 
126
- function nextGeneratedFrameX(
127
- frames: ReturnType<typeof parseCanvasFrameGeometryById>,
128
- ): number {
129
- return Object.values(frames).reduce((furthestRight, frame) => {
130
- const x = frame.x ?? 0;
131
- const width = frame.width ?? 0;
132
- return Math.max(furthestRight, x + width + GENERATED_FRAME_GAP);
133
- }, 0);
134
- }
135
-
136
177
  function jsonValuesEqual(left: unknown, right: unknown): boolean {
137
178
  return JSON.stringify(left) === JSON.stringify(right);
138
179
  }
@@ -393,7 +434,7 @@ const generateDesignAgentParameters = {
393
434
  type: "string",
394
435
  description:
395
436
  "Optional JSON array of overview-canvas placements keyed by filename or fileId. " +
396
- "Pass explicit x/y/width/height for every generated screen; desktop is 1440x1024.",
437
+ "Pass explicit x/y/width/height for every generated screen; desktop is 1440x900.",
397
438
  },
398
439
  contextPackId: {
399
440
  type: "string",
@@ -415,8 +456,20 @@ const generateDesignAgentParameters = {
415
456
  type: "string",
416
457
  enum: ["mobile", "tablet", "desktop"],
417
458
  description:
418
- "The requested primary form factor. Defaults to desktop (1440x1024). " +
419
- "Set this from the intake answer when no explicit canvasFrames placement is supplied.",
459
+ "The requested primary form factor. Defaults to desktop (1440x900). " +
460
+ "Set this from the intake answer when no explicit canvasFrames placement is supplied. " +
461
+ "Ignored when `devices` is provided (the widest device becomes primary).",
462
+ },
463
+ devices: {
464
+ type: "array",
465
+ items: { type: "string", enum: ["mobile", "tablet", "desktop"] },
466
+ description:
467
+ "Device set for responsive frames. Honor the devices the prompt " +
468
+ 'explicitly names; omit to default to ["desktop","mobile"]. The widest ' +
469
+ "device becomes the primary/base frame and the narrower devices become " +
470
+ "breakpoint frames — never a duplicate of the base width and never an " +
471
+ "auto-added tablet. A single device yields one frame with no breakpoints. " +
472
+ "When provided, this overrides primaryViewport.",
420
473
  },
421
474
  },
422
475
  required: ["designId", "prompt", "files"],
@@ -438,9 +491,12 @@ const generateDesignAction = defineAction({
438
491
  "When `designSystemId` is provided, first use `get-design-system` and apply " +
439
492
  "its `agentContext` tokens/docs before writing the file content; do not " +
440
493
  "treat the id alone as enough design-system context. " +
441
- "Every web design must be responsive. Use a desktop 1440x1024 primary " +
442
- "canvas by default, or set `primaryViewport` for an explicitly mobile- or " +
443
- "tablet-primary design; this action adds responsive editor breakpoints. " +
494
+ "Every web design must be responsive. This action adds responsive editor " +
495
+ "breakpoints: by default a Desktop 1440x900 base frame plus a Mobile " +
496
+ "breakpoint (no auto tablet, no duplicate desktop). Pass `devices` to honor " +
497
+ "the form factors the prompt explicitly names — the widest becomes the base " +
498
+ "frame and the narrower ones become breakpoint frames. Set `primaryViewport` " +
499
+ "for a mobile- or tablet-primary design when not passing `devices`. " +
444
500
  "Do not report a design as ready until this action succeeds. " +
445
501
  "When adding multiple screens or states, pass canvasFrames with filenames " +
446
502
  "and x/y/width/height so the new screens appear placed on the overview canvas.",
@@ -599,8 +655,20 @@ const generateDesignAction = defineAction({
599
655
  .optional()
600
656
  .default(DEFAULT_GENERATION_VIEWPORT)
601
657
  .describe(
602
- "Primary generated viewport. Defaults to desktop (1440x1024); set " +
603
- "mobile or tablet only when that is the requested form factor.",
658
+ "Primary generated viewport. Defaults to desktop (1440x900); set " +
659
+ "mobile or tablet only when that is the requested form factor. " +
660
+ "Ignored when `devices` is provided (the widest device wins).",
661
+ ),
662
+ devices: z
663
+ .array(z.enum(["mobile", "tablet", "desktop"]))
664
+ .optional()
665
+ .describe(
666
+ "Explicit device set for responsive frames. Honor the devices the " +
667
+ 'prompt names; omit to default to ["desktop","mobile"]. Widest ' +
668
+ "device = primary/base frame; narrower devices = breakpoint frames " +
669
+ "(never the base width, never an auto tablet). One device = a single " +
670
+ "frame with no breakpoints. When provided, this overrides " +
671
+ "primaryViewport.",
604
672
  ),
605
673
  }),
606
674
  mcpApp: {
@@ -622,6 +690,7 @@ const generateDesignAction = defineAction({
622
690
  tweaks,
623
691
  canvasFrames,
624
692
  primaryViewport,
693
+ devices,
625
694
  contextPackId,
626
695
  contextModeOverride,
627
696
  reuseLabels,
@@ -827,6 +896,15 @@ const generateDesignAction = defineAction({
827
896
  ...tweak,
828
897
  type: tweak.type === "color-swatches" ? "color-swatch" : tweak.type,
829
898
  }));
899
+ // An explicit `devices` list wins over primaryViewport; otherwise derive
900
+ // the device set from primaryViewport so the default stays Desktop base +
901
+ // Mobile breakpoint. The widest resolved device seeds the primary frame.
902
+ const resolvedDevices =
903
+ devices && devices.length > 0
904
+ ? devices
905
+ : devicesForPrimaryViewport(primaryViewport);
906
+ const resolvedPrimaryViewport = widestGenerationDevice(resolvedDevices);
907
+ const generatedBreakpointSet = breakpointSetForDevices(resolvedDevices);
830
908
  await mutateDesignData({
831
909
  designId,
832
910
  mutate: (prevData, { updatedAt }) => {
@@ -865,9 +943,77 @@ const generateDesignAction = defineAction({
865
943
  : undefined;
866
944
  },
867
945
  });
868
- const viewport = GENERATION_VIEWPORT_SIZES[primaryViewport];
869
- let nextX = nextGeneratedFrameX(merged.canvasFrames);
870
- const generationFrames = [...merged.placedFrames];
946
+ const viewport = GENERATION_VIEWPORT_SIZES[resolvedPrimaryViewport];
947
+ // Frames placed by an earlier call: never moved, and counted as
948
+ // occupied so a new screen is never dropped on top of one.
949
+ const preExistingFrameIds = new Set(
950
+ prevData.canvasFrames && typeof prevData.canvasFrames === "object"
951
+ ? Object.keys(prevData.canvasFrames as Record<string, unknown>)
952
+ : [],
953
+ );
954
+ const rectOf = (frame: {
955
+ x?: number;
956
+ y?: number;
957
+ width?: number;
958
+ height?: number;
959
+ rotation?: number;
960
+ }) => {
961
+ const x = frame.x ?? 0;
962
+ const y = frame.y ?? 0;
963
+ const width = frame.width ?? 0;
964
+ const height = frame.height ?? 0;
965
+ const rotation = frame.rotation ?? 0;
966
+ if (!rotation || width <= 0 || height <= 0) {
967
+ return { x, y, width, height };
968
+ }
969
+ // Existing frames render rotated about their center; use the rotated
970
+ // rect's axis-aligned bounding box so a new screen isn't dropped over
971
+ // a rotated frame's real footprint.
972
+ const radians = (rotation * Math.PI) / 180;
973
+ const cos = Math.abs(Math.cos(radians));
974
+ const sin = Math.abs(Math.sin(radians));
975
+ const aabbWidth = width * cos + height * sin;
976
+ const aabbHeight = width * sin + height * cos;
977
+ return {
978
+ x: x + width / 2 - aabbWidth / 2,
979
+ y: y + height / 2 - aabbHeight / 2,
980
+ width: aabbWidth,
981
+ height: aabbHeight,
982
+ };
983
+ };
984
+ const framesOverlap = (
985
+ a: ReturnType<typeof rectOf>,
986
+ b: ReturnType<typeof rectOf>,
987
+ ) =>
988
+ a.width > 0 &&
989
+ a.height > 0 &&
990
+ b.width > 0 &&
991
+ b.height > 0 &&
992
+ a.x < b.x + b.width &&
993
+ a.x + a.width > b.x &&
994
+ a.y < b.y + b.height &&
995
+ a.y + a.height > b.y;
996
+ const occupiedRects: Array<ReturnType<typeof rectOf>> = [];
997
+ for (const id of preExistingFrameIds) {
998
+ const frame = merged.canvasFrames[id];
999
+ if (frame) occupiedRects.push(rectOf(frame));
1000
+ }
1001
+ // Keep arg placements for files we're not regenerating; the regenerated
1002
+ // ones are (re)placed below, so rebuild their entries here rather than
1003
+ // carry a pre-relocation position that would fail the isApplied check.
1004
+ const regeneratedFileIds = new Set(savedFiles.map((file) => file.id));
1005
+ const generationFrames = merged.placedFrames.filter(
1006
+ (placed) => !regeneratedFileIds.has(placed.fileId),
1007
+ );
1008
+ for (const placed of generationFrames) {
1009
+ occupiedRects.push(rectOf(placed.frame));
1010
+ }
1011
+ // Relocate target: right of every occupied (rotation-aware) rect.
1012
+ let nextX = occupiedRects.reduce(
1013
+ (right, rect) =>
1014
+ Math.max(right, rect.x + rect.width + GENERATED_FRAME_GAP),
1015
+ 0,
1016
+ );
871
1017
  for (const file of savedFiles) {
872
1018
  const source = files.find(
873
1019
  (candidate) => candidate.filename === file.filename,
@@ -875,18 +1021,50 @@ const generateDesignAction = defineAction({
875
1021
  if (!source || !isRenderableDesignFile(source)) continue;
876
1022
  const current = merged.canvasFrames[file.id] ?? {};
877
1023
  if (
1024
+ preExistingFrameIds.has(file.id) &&
878
1025
  current.x !== undefined &&
879
1026
  current.y !== undefined &&
880
1027
  current.width !== undefined &&
881
1028
  current.height !== undefined
882
1029
  ) {
1030
+ // An explicit device request resizes the primary frame to the
1031
+ // requested viewport (preserving position/rotation); otherwise a
1032
+ // regenerated screen keeps its exact stored geometry.
1033
+ if (devices && devices.length > 0) {
1034
+ merged.canvasFrames[file.id] = {
1035
+ ...current,
1036
+ width: viewport.width,
1037
+ height: viewport.height,
1038
+ };
1039
+ }
883
1040
  continue;
884
1041
  }
1042
+ const width = current.width ?? viewport.width;
1043
+ const height = current.height ?? viewport.height;
1044
+ let x = current.x ?? nextX;
1045
+ let y = current.y ?? 0;
1046
+ // Bump a new screen clear of everything if its requested/default spot
1047
+ // overlaps (e.g. a second screen defaulting to the same origin). The
1048
+ // candidate carries its own rotation so a rotated placement is tested
1049
+ // by its real footprint, not its unrotated rectangle.
1050
+ const candidateRect = rectOf({
1051
+ x,
1052
+ y,
1053
+ width,
1054
+ height,
1055
+ rotation: current.rotation,
1056
+ });
1057
+ if (
1058
+ occupiedRects.some((rect) => framesOverlap(candidateRect, rect))
1059
+ ) {
1060
+ x = nextX;
1061
+ y = 0;
1062
+ }
885
1063
  const frame = {
886
- x: current.x ?? nextX,
887
- y: current.y ?? 0,
888
- width: current.width ?? viewport.width,
889
- height: current.height ?? viewport.height,
1064
+ x,
1065
+ y,
1066
+ width,
1067
+ height,
890
1068
  z: current.z ?? generationFrames.length,
891
1069
  ...(current.rotation === undefined
892
1070
  ? {}
@@ -898,14 +1076,34 @@ const generateDesignAction = defineAction({
898
1076
  filename: file.filename,
899
1077
  frame,
900
1078
  });
901
- nextX = frame.x + frame.width + GENERATED_FRAME_GAP;
1079
+ occupiedRects.push(rectOf(frame));
1080
+ nextX = Math.max(nextX, frame.x + frame.width + GENERATED_FRAME_GAP);
902
1081
  }
903
1082
  mergedData.canvasFrames = merged.canvasFrames;
904
1083
  placedFrames = generationFrames;
905
- if (!hasBreakpointSet(mergedData.breakpointSet)) {
1084
+ // An explicit `devices` request is authoritative: replace the design's
1085
+ // breakpoint set with the derived one (or drop it for a single device),
1086
+ // so regenerating e.g. as [mobile,tablet,desktop] can't silently retain
1087
+ // a stale narrower set. Without explicit devices, only seed a default
1088
+ // set when none exists — a plain content regen never clobbers the
1089
+ // user's own breakpoints.
1090
+ if (devices && devices.length > 0) {
1091
+ if (generatedBreakpointSet.length > 0) {
1092
+ mergedData.breakpointSet = {
1093
+ id: "generated-responsive",
1094
+ breakpoints: generatedBreakpointSet,
1095
+ };
1096
+ } else {
1097
+ delete mergedData.breakpointSet;
1098
+ }
1099
+ mergedData.breakpointSetUpdatedAt = updatedAt;
1100
+ } else if (
1101
+ generatedBreakpointSet.length > 0 &&
1102
+ !hasBreakpointSet(mergedData.breakpointSet)
1103
+ ) {
906
1104
  mergedData.breakpointSet = {
907
1105
  id: "generated-responsive",
908
- breakpoints: DEFAULT_RESPONSIVE_BREAKPOINTS,
1106
+ breakpoints: generatedBreakpointSet,
909
1107
  };
910
1108
  mergedData.breakpointSetUpdatedAt = updatedAt;
911
1109
  }
@@ -940,7 +1138,32 @@ const generateDesignAction = defineAction({
940
1138
  );
941
1139
  }),
942
1140
  );
943
- return framesApplied && hasBreakpointSet(current.breakpointSet);
1141
+ // For an explicit `devices` request, verify the persisted breakpoint
1142
+ // widths actually match the requested set (not merely that some set
1143
+ // exists), so a partial/stale write is retried rather than accepted.
1144
+ const currentBreakpointWidths = (
1145
+ Array.isArray(
1146
+ (current.breakpointSet as { breakpoints?: unknown })?.breakpoints,
1147
+ )
1148
+ ? (
1149
+ current.breakpointSet as {
1150
+ breakpoints: Array<{ widthPx?: number }>;
1151
+ }
1152
+ ).breakpoints
1153
+ : []
1154
+ )
1155
+ .map((bp) => bp.widthPx)
1156
+ .filter((w): w is number => typeof w === "number")
1157
+ .sort((a, b) => a - b);
1158
+ const expectedBreakpointWidths = generatedBreakpointSet
1159
+ .map((bp) => bp.widthPx)
1160
+ .sort((a, b) => a - b);
1161
+ const breakpointSetApplied =
1162
+ devices && devices.length > 0
1163
+ ? jsonValuesEqual(currentBreakpointWidths, expectedBreakpointWidths)
1164
+ : generatedBreakpointSet.length === 0 ||
1165
+ hasBreakpointSet(current.breakpointSet);
1166
+ return framesApplied && breakpointSetApplied;
944
1167
  },
945
1168
  });
946
1169