@agent-native/core 0.131.2 → 0.131.4

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 (100) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +34 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/types.ts +6 -0
  5. package/corpus/core/src/action.ts +13 -0
  6. package/corpus/core/src/client/AgentPanel.tsx +58 -34
  7. package/corpus/core/src/localization/default-messages.ts +6 -0
  8. package/corpus/core/src/server/agent-capabilities.ts +43 -8
  9. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +63 -1
  10. package/corpus/templates/analytics/AGENTS.md +5 -0
  11. package/corpus/templates/analytics/actions/hubspot-deals.ts +1 -0
  12. package/corpus/templates/analytics/actions/hubspot-records.ts +1 -0
  13. package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +2 -0
  14. package/corpus/templates/calendar/actions/get-event.ts +1 -0
  15. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +1 -0
  16. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +7 -3
  17. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +14 -19
  18. package/corpus/templates/calendar/app/hooks/use-events.ts +7 -0
  19. package/corpus/templates/calendar/app/lib/event-form-utils.ts +13 -0
  20. package/corpus/templates/calendar/app/pages/CalendarView.tsx +21 -14
  21. package/corpus/templates/calendar/changelog/2026-07-29-unnamed-events-now-use-a-title-placeholder.md +6 -0
  22. package/corpus/templates/calendar/server/lib/google-calendar.ts +2 -0
  23. package/corpus/templates/calendar/server/lib/ical-fetcher.ts +1 -0
  24. package/corpus/templates/calendar/shared/api.ts +2 -0
  25. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +3 -1
  26. package/corpus/templates/design/actions/get-design.ts +11 -3
  27. package/corpus/templates/design/actions/present-design-variants.ts +62 -5
  28. package/corpus/templates/design/app/components/design/BreakpointBar.tsx +64 -1
  29. package/corpus/templates/design/app/components/design/EditPanel.tsx +199 -25
  30. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +160 -46
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +7 -1
  32. package/corpus/templates/design/app/components/design/edit-panel/effects-properties.tsx +59 -12
  33. package/corpus/templates/design/app/components/design/edit-panel/fill-properties.tsx +1 -1
  34. package/corpus/templates/design/app/components/design/edit-panel/inspector-controls.tsx +34 -29
  35. package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +2 -2
  36. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +28 -10
  37. package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +15 -28
  38. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +4 -3
  39. package/corpus/templates/design/app/components/design/multi-screen/chrome-transitions.ts +12 -8
  40. package/corpus/templates/design/app/components/design/multi-screen/culling.ts +34 -9
  41. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +13 -27
  42. package/corpus/templates/design/app/components/design/multi-screen/types.ts +8 -1
  43. package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +95 -0
  44. package/corpus/templates/design/app/i18n-data.ts +121 -10
  45. package/corpus/templates/design/app/pages/design-editor/data-operations.ts +45 -0
  46. package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +59 -0
  47. package/corpus/templates/design/app/pages/design-editor/text-edit-utils.ts +115 -40
  48. package/corpus/templates/design/changelog/2026-07-28-generated-design-variants-no-longer-overlap-each-other-on-th.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-29-auto-layout-direction-icons-show-an-arrow.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-29-breakpoints-no-longer-overlap-neighbouring-screens.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-29-canvas-is-more-responsive-when-selecting-and-adding-frames.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-29-canvas-zoom-is-smoother-and-labels-hold-their-size.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-29-double-click-a-frame-now-drills-into-its-layers.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-29-export-preview-shows-the-real-render.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-29-figma-parity-shortcuts.md +6 -0
  56. package/corpus/templates/design/changelog/2026-07-29-more-breakpoint-width-presets.md +6 -0
  57. package/corpus/templates/design/changelog/2026-07-29-resize-a-selected-frame-to-a-device-preset.md +6 -0
  58. package/corpus/templates/design/changelog/2026-07-29-text-tool-reaches-the-right-surface.md +6 -0
  59. package/corpus/templates/design/shared/canvas-frames.ts +23 -0
  60. package/corpus/templates/design/shared/code-layer.ts +54 -2
  61. package/corpus/templates/design/shared/responsive-frame-layout.ts +55 -0
  62. package/corpus/templates/slides/.agents/skills/analytics-data-for-decks/SKILL.md +48 -0
  63. package/corpus/templates/slides/AGENTS.md +3 -0
  64. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +72 -8
  65. package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
  66. package/corpus/templates/slides/changelog/2026-07-29-google-slides-exports-can-connect-your-google-account-direct.md +6 -0
  67. package/corpus/templates/slides/server/handlers/google-docs-auth.ts +4 -2
  68. package/dist/a2a/types.d.ts +6 -0
  69. package/dist/a2a/types.d.ts.map +1 -1
  70. package/dist/a2a/types.js.map +1 -1
  71. package/dist/action.d.ts +13 -0
  72. package/dist/action.d.ts.map +1 -1
  73. package/dist/action.js.map +1 -1
  74. package/dist/client/AgentPanel.d.ts.map +1 -1
  75. package/dist/client/AgentPanel.js +16 -5
  76. package/dist/client/AgentPanel.js.map +1 -1
  77. package/dist/collab/awareness.d.ts +2 -2
  78. package/dist/collab/awareness.d.ts.map +1 -1
  79. package/dist/collab/routes.d.ts +1 -1
  80. package/dist/localization/default-messages.d.ts +6 -0
  81. package/dist/localization/default-messages.d.ts.map +1 -1
  82. package/dist/localization/default-messages.js +6 -0
  83. package/dist/localization/default-messages.js.map +1 -1
  84. package/dist/notifications/routes.d.ts +2 -2
  85. package/dist/resources/handlers.d.ts +1 -1
  86. package/dist/server/agent-capabilities.d.ts.map +1 -1
  87. package/dist/server/agent-capabilities.js +32 -3
  88. package/dist/server/agent-capabilities.js.map +1 -1
  89. package/dist/server/agent-chat/action-filters-a2a.d.ts +17 -1
  90. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  91. package/dist/server/agent-chat/action-filters-a2a.js +50 -1
  92. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  93. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  94. package/package.json +1 -1
  95. package/src/a2a/types.ts +6 -0
  96. package/src/action.ts +13 -0
  97. package/src/client/AgentPanel.tsx +58 -34
  98. package/src/localization/default-messages.ts +6 -0
  99. package/src/server/agent-capabilities.ts +43 -8
  100. package/src/server/agent-chat/action-filters-a2a.ts +63 -1
@@ -86,8 +86,11 @@ import { useViewPreferences } from "@/hooks/use-view-preferences";
86
86
  import { resolveEventAccountEmail } from "@/lib/event-account-selection";
87
87
  import { getGoogleEventColorHex } from "@/lib/event-colors";
88
88
  import {
89
+ buildEventTitleUpdate,
89
90
  dateTimeInTimezoneToIso,
91
+ getEditableEventTitle,
90
92
  getLocalTimezone,
93
+ UNNAMED_EVENT_TITLE,
91
94
  } from "@/lib/event-form-utils";
92
95
  import { buildDeleteEventMutationInput } from "@/lib/event-mutation-inputs";
93
96
  import { getLocationSuggestions } from "@/lib/location-suggestions";
@@ -204,11 +207,15 @@ function draftToCalendarEvent(
204
207
  fallbackDate: Date,
205
208
  ): CalendarEvent {
206
209
  const { start, end } = draftRange(draft, fallbackDate);
210
+ const editableTitle = draft.title?.trim() ?? "";
207
211
  return {
208
212
  id: calendarDraftEventId(draft.id),
209
213
  title:
210
- draft.title?.trim() ||
211
- (draft.eventType === "outOfOffice" ? "Out of office" : "(No title)"),
214
+ editableTitle ||
215
+ (draft.eventType === "outOfOffice"
216
+ ? "Out of office"
217
+ : UNNAMED_EVENT_TITLE),
218
+ titleIsGenerated: !editableTitle,
212
219
  description: draft.description ?? "",
213
220
  start: start.toISOString(),
214
221
  end: end.toISOString(),
@@ -650,14 +657,10 @@ export default function CalendarView() {
650
657
  setEventDraft(draft);
651
658
  persistCalendarDraft(draft);
652
659
  }
653
- const trimmedTitle = draft.title?.trim();
660
+ const editableTitle = draft.title?.trim() ?? "";
654
661
  const eventType = draft.eventType ?? "default";
655
662
  const title =
656
- trimmedTitle && trimmedTitle !== "(No title)"
657
- ? trimmedTitle
658
- : eventType === "outOfOffice"
659
- ? "Out of office"
660
- : "";
663
+ editableTitle || (eventType === "outOfOffice" ? "Out of office" : "");
661
664
  if (!title && !isSlotDraftId(draftId)) {
662
665
  committingDraftIdsRef.current.delete(draftId);
663
666
  toast.error(t("calendarView.addTitleBeforeCreate"));
@@ -703,6 +706,7 @@ export default function CalendarView() {
703
706
  const payload: Parameters<typeof createEvent.mutate>[0] = {
704
707
  _tempId: eventId,
705
708
  title,
709
+ titleIsGenerated: !editableTitle,
706
710
  description:
707
711
  eventType === "outOfOffice" ? "" : (draft.description ?? ""),
708
712
  start: semanticFullDay ? draft.start! : start.toISOString(),
@@ -1359,8 +1363,9 @@ export default function CalendarView() {
1359
1363
  const handleQuickEditSave = useCallback(
1360
1364
  async (eventId: string, title: string, accountEmail?: string) => {
1361
1365
  setQuickEditEventId(null);
1366
+ const trimmedTitle = title.trim();
1362
1367
  if (calendarDraftIdFromEventId(eventId)) {
1363
- updateDraftEvent(eventId, { title: title.trim() });
1368
+ updateDraftEvent(eventId, { title: trimmedTitle });
1364
1369
  return;
1365
1370
  }
1366
1371
  setQuickEditTempIds((current) => {
@@ -1368,9 +1373,9 @@ export default function CalendarView() {
1368
1373
  const { [eventId]: _removed, ...next } = current;
1369
1374
  return next;
1370
1375
  });
1371
- if (title.trim() && title.trim() !== "(No title)") {
1376
+ if (trimmedTitle) {
1372
1377
  const event = events.find((e) => e.id === eventId);
1373
- const updates = { title: title.trim() };
1378
+ const updates = buildEventTitleUpdate(trimmedTitle);
1374
1379
  const guestNotification = event
1375
1380
  ? await promptGuestNotification({
1376
1381
  event,
@@ -1392,12 +1397,14 @@ export default function CalendarView() {
1392
1397
 
1393
1398
  const handleTitleSave = useCallback(
1394
1399
  async (eventId: string, title: string, accountEmail?: string) => {
1400
+ const trimmedTitle = title.trim();
1401
+ if (!trimmedTitle) return;
1395
1402
  if (calendarDraftIdFromEventId(eventId)) {
1396
- updateDraftEvent(eventId, { title });
1403
+ updateDraftEvent(eventId, { title: trimmedTitle });
1397
1404
  return;
1398
1405
  }
1399
1406
  const event = events.find((e) => e.id === eventId);
1400
- const updates = { title };
1407
+ const updates = buildEventTitleUpdate(trimmedTitle);
1401
1408
  const guestNotification = event
1402
1409
  ? await promptGuestNotification({
1403
1410
  event,
@@ -1430,7 +1437,7 @@ export default function CalendarView() {
1430
1437
  });
1431
1438
  // Delete the event if title was never set
1432
1439
  const ev = events.find((e) => e.id === eventId);
1433
- if (!ev || ev.title === "(No title)") {
1440
+ if (!ev || !getEditableEventTitle(ev).trim()) {
1434
1441
  deleteEvent.mutate(
1435
1442
  buildDeleteEventMutationInput(
1436
1443
  {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-29
4
+ ---
5
+
6
+ Unnamed events now show an empty title field with an “Add title” placeholder when you edit them.
@@ -881,6 +881,7 @@ export async function listEvents(
881
881
  return {
882
882
  id: `google-${event.id}`,
883
883
  title: event.summary || "Untitled",
884
+ titleIsGenerated: !event.summary,
884
885
  description: event.description || "",
885
886
  start: event.start?.dateTime || event.start?.date || "",
886
887
  end: event.end?.dateTime || event.end?.date || "",
@@ -1153,6 +1154,7 @@ export async function getEvent(
1153
1154
  return {
1154
1155
  id: `google-${event.id}`,
1155
1156
  title: event.summary || "Untitled",
1157
+ titleIsGenerated: !event.summary,
1156
1158
  description: event.description || "",
1157
1159
  start: event.start?.dateTime || event.start?.date || "",
1158
1160
  end: event.end?.dateTime || event.end?.date || "",
@@ -327,6 +327,7 @@ export async function fetchICalEvents(
327
327
  .map((e) => ({
328
328
  id: `ical-${feedId}-${e.uid}`,
329
329
  title: e.summary || "(No title)",
330
+ titleIsGenerated: !e.summary,
330
331
  description: e.description || "",
331
332
  start: e.start,
332
333
  end: e.end,
@@ -1,6 +1,8 @@
1
1
  export interface CalendarEvent {
2
2
  id: string;
3
3
  title: string;
4
+ /** Client-facing provenance for a display label synthesized from an absent title. */
5
+ titleIsGenerated?: boolean;
4
6
  description: string;
5
7
  start: string; // ISO 8601
6
8
  end: string; // ISO 8601
@@ -8199,7 +8199,7 @@ export const editorChromeBridgeScript: string = `"use strict";
8199
8199
  if (e.data.type === "agent-native:text-edit-status") {
8200
8200
  var textEditStatusCorrelationId = typeof e.data.correlationId === "string" ? e.data.correlationId : "";
8201
8201
  var textEditStatusNodeId = typeof e.data.nodeId === "string" ? e.data.nodeId : "";
8202
- var textEditStatus = false;
8202
+ var textEditStatus = "missing";
8203
8203
  if (textEditStatusNodeId) {
8204
8204
  var escapedTextEditStatusNodeId = textEditStatusNodeId.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"');
8205
8205
  var textEditStatusNode = document.querySelector(
@@ -8212,6 +8212,8 @@ export const editorChromeBridgeScript: string = `"use strict";
8212
8212
  textEditStatus = "active";
8213
8213
  } else if (textEditStatusNode && (textEditStatusNode.textContent ?? "").trim().length > 0) {
8214
8214
  textEditStatus = "done";
8215
+ } else if (textEditStatusNode) {
8216
+ textEditStatus = false;
8215
8217
  }
8216
8218
  }
8217
8219
  window.parent.postMessage(
@@ -1,6 +1,6 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { resolveAccess } from "@agent-native/core/sharing";
3
- import { eq } from "drizzle-orm";
3
+ import { asc, eq } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
@@ -26,11 +26,19 @@ export default defineAction({
26
26
  const row = access.resource;
27
27
  const db = getDb();
28
28
 
29
- // Fetch associated files
29
+ // Fetch associated files in a stable order. This array feeds the overview
30
+ // canvas's screen stack and each screen's index within its layout group, so
31
+ // unordered rows (Postgres returns heap order, which an UPDATE can change)
32
+ // meant the same design could lay itself out differently on two loads.
33
+ // Note this is deterministic, not creation-ordered: files written in one
34
+ // batch share a `createdAt` to the millisecond and fall back to the id
35
+ // tiebreak. Nothing may depend on the index matching the order a generator
36
+ // wrote in — see the order-independence case in variant-lineup.test.ts.
30
37
  const files = await db
31
38
  .select()
32
39
  .from(schema.designFiles)
33
- .where(eq(schema.designFiles.designId, id));
40
+ .where(eq(schema.designFiles.designId, id))
41
+ .orderBy(asc(schema.designFiles.createdAt), asc(schema.designFiles.id));
34
42
 
35
43
  return {
36
44
  id: row.id,
@@ -16,11 +16,16 @@ import { getDb, schema } from "../server/db/index.js";
16
16
  import { mutateDesignData } from "../server/lib/design-data-mutation.js";
17
17
  import {
18
18
  mergeCanvasFramePlacements,
19
+ nextFreeCanvasRowY,
19
20
  type CanvasFramePlacement,
20
21
  } from "../shared/canvas-frames.js";
21
22
  import { isUniqueConstraintViolation } from "../shared/db-conflict.js";
22
23
  import { assertDesignHtmlWellFormed } from "../shared/html-integrity.js";
23
24
  import { widthToPrefix } from "../shared/responsive-classes.js";
25
+ import {
26
+ getResponsiveGroupWidth,
27
+ visibleBreakpointWidths,
28
+ } from "../shared/responsive-frame-layout.js";
24
29
  import { annotateScreenHtmlForPersist } from "../shared/screen-annotation.js";
25
30
 
26
31
  const VARIANT_GAP = 96;
@@ -735,10 +740,24 @@ ${compact ? ".sidebar { padding: 16px; } .nav { grid-template-columns: repeat(2,
735
740
  </html>`;
736
741
  }
737
742
 
738
- function placeVariantScreens(screens: VariantScreen[]) {
743
+ /**
744
+ * Lays the generated directions out in rows of up to three.
745
+ *
746
+ * Each cell reserves the screen's WHOLE painted footprint, not just its
747
+ * primary frame: this action also installs a design-wide breakpoint set, and
748
+ * the overview paints one preview per breakpoint to the right of every primary
749
+ * frame. Spacing by `width + VARIANT_GAP` alone drops the next direction on top
750
+ * of the previous one's breakpoint row.
751
+ */
752
+ function placeVariantScreens(
753
+ screens: VariantScreen[],
754
+ breakpointWidths: readonly number[],
755
+ /** Y to start the lineup at, so an additional set clears the existing one. */
756
+ originY = 0,
757
+ ) {
739
758
  const placements: CanvasFramePlacement[] = [];
740
759
  const columns = Math.min(MAX_COLUMNS, Math.max(1, screens.length));
741
- let rowY = 0;
760
+ let rowY = originY;
742
761
 
743
762
  for (let rowStart = 0; rowStart < screens.length; rowStart += columns) {
744
763
  const row = screens.slice(rowStart, rowStart + columns);
@@ -755,7 +774,17 @@ function placeVariantScreens(screens: VariantScreen[]) {
755
774
  height: screen.height,
756
775
  z: rowStart + offset,
757
776
  });
758
- x += screen.width + VARIANT_GAP;
777
+ x +=
778
+ getResponsiveGroupWidth({
779
+ primaryWidth: screen.width,
780
+ // Frames are placed at their natural device width, so the breakpoint
781
+ // previews beside them are drawn unscaled.
782
+ scale: 1,
783
+ visibleWidths: visibleBreakpointWidths(
784
+ breakpointWidths,
785
+ screen.width,
786
+ ),
787
+ }) + VARIANT_GAP;
759
788
  rowHeight = Math.max(rowHeight, screen.height);
760
789
  }
761
790
 
@@ -765,6 +794,22 @@ function placeVariantScreens(screens: VariantScreen[]) {
765
794
  return placements;
766
795
  }
767
796
 
797
+ /** Widths the overview will paint beside each primary frame once this call
798
+ * settles: the design's own set when it has one, otherwise the set this action
799
+ * is about to install. */
800
+ function effectiveBreakpointWidths(currentBreakpointSet: unknown): number[] {
801
+ const breakpoints = hasBreakpointSet(currentBreakpointSet)
802
+ ? ((currentBreakpointSet as { breakpoints: Array<{ widthPx?: unknown }> })
803
+ .breakpoints ?? [])
804
+ : DEFAULT_RESPONSIVE_BREAKPOINTS;
805
+ return breakpoints
806
+ .map((breakpoint) => breakpoint.widthPx)
807
+ .filter(
808
+ (widthPx): widthPx is number =>
809
+ typeof widthPx === "number" && Number.isFinite(widthPx) && widthPx > 0,
810
+ );
811
+ }
812
+
768
813
  export default defineAction({
769
814
  description:
770
815
  "Present generated design directions as normal screens on the Design " +
@@ -933,13 +978,25 @@ export default defineAction({
933
978
  });
934
979
  }
935
980
 
936
- const placements = placeVariantScreens(screens);
937
981
  await mutateDesignData({
938
982
  designId,
939
983
  mutate: (current, { updatedAt }) => {
984
+ // Placement depends on the breakpoint set in effect after this call, so
985
+ // it is resolved here (inside the compare-and-set body) rather than
986
+ // against a design snapshot that a concurrent write may have moved on
987
+ // from.
940
988
  const mergedFrames = mergeCanvasFramePlacements({
941
989
  existing: current.canvasFrames,
942
- placements,
990
+ placements: placeVariantScreens(
991
+ screens,
992
+ effectiveBreakpointWidths(current.breakpointSet),
993
+ // Start below what is already on the board. The set being written is
994
+ // excluded so re-running the same set lands where it was instead of
995
+ // marching further down each time.
996
+ nextFreeCanvasRowY(current.canvasFrames, VARIANT_GAP, {
997
+ ignoreFileIds: screens.map((screen) => screen.id),
998
+ }),
999
+ ),
943
1000
  resolveFileId: (placement) => placement.fileId,
944
1001
  });
945
1002
  const previousMetadata = isRecord(current.screenMetadata)
@@ -26,6 +26,8 @@ import {
26
26
  import { Switch } from "@/components/ui/switch";
27
27
  import { cn } from "@/lib/utils";
28
28
 
29
+ import { FRAME_SIZE_PRESET_CATEGORIES } from "./inspector/frame-size-presets";
30
+
29
31
  /** Framer's default breakpoint widths, offered by the "+" affordance. */
30
32
  export const FRAMER_BREAKPOINT_PRESETS: ReadonlyArray<{
31
33
  labelKey: "desktop" | "tablet" | "phone";
@@ -45,6 +47,35 @@ export function availableBreakpointPresets(
45
47
  );
46
48
  }
47
49
 
50
+ /**
51
+ * Device widths offered beyond the three Framer defaults, drawn from the frame
52
+ * tool's own preset catalog so both surfaces agree on what a "Tablet" is.
53
+ *
54
+ * Without these the "+" popover empties out as soon as Desktop/Tablet/Phone are
55
+ * added — `availableBreakpointPresets` filters by exact width — leaving a bare
56
+ * number input and no way to pick a real device size. Only width matters for a
57
+ * breakpoint, so widths are de-duplicated (many devices share one) and the
58
+ * first device at each width names it. Device names are untranslated product
59
+ * literals, matching frame-size-presets' documented policy.
60
+ */
61
+ export function extraBreakpointWidthPresets(
62
+ existingWidths: readonly number[],
63
+ ): Array<{ name: string; widthPx: number }> {
64
+ const taken = new Set<number>(existingWidths);
65
+ FRAMER_BREAKPOINT_PRESETS.forEach((preset) => taken.add(preset.widthPx));
66
+ const seen = new Set<number>();
67
+ const out: Array<{ name: string; widthPx: number }> = [];
68
+ for (const category of FRAME_SIZE_PRESET_CATEGORIES) {
69
+ for (const preset of category.presets) {
70
+ if (taken.has(preset.width) || seen.has(preset.width)) continue;
71
+ if (preset.width < 320 || preset.width > 3840) continue;
72
+ seen.add(preset.width);
73
+ out.push({ name: preset.name, widthPx: preset.width });
74
+ }
75
+ }
76
+ return out.sort((a, b) => b.widthPx - a.widthPx);
77
+ }
78
+
48
79
  /** Default English label for a preset/custom width (used for add-breakpoint). */
49
80
  export function breakpointLabelForWidth(widthPx: number): string {
50
81
  if (widthPx >= 1024) return "Desktop";
@@ -128,6 +159,7 @@ export function BreakpointDeviceControl({
128
159
  const ordered = [...breakpoints].sort((a, b) => b.widthPx - a.widthPx);
129
160
  const existingWidths = ordered.map((bp) => bp.widthPx);
130
161
  const presets = availableBreakpointPresets(existingWidths);
162
+ const devicePresets = extraBreakpointWidthPresets(existingWidths);
131
163
  const baseActive = activeWidthPx === undefined;
132
164
 
133
165
  const submitCustomWidth = () => {
@@ -323,7 +355,38 @@ export function BreakpointDeviceControl({
323
355
  </span>
324
356
  </button>
325
357
  ))}
326
- {presets.length > 0 ? (
358
+ {devicePresets.length > 0 ? (
359
+ <>
360
+ {presets.length > 0 ? (
361
+ <div className="my-1 h-px bg-border" />
362
+ ) : null}
363
+ <div className="max-h-48 overflow-y-auto overscroll-contain">
364
+ {devicePresets.map((preset) => (
365
+ <button
366
+ key={preset.widthPx}
367
+ type="button"
368
+ className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left !text-[12px] hover:bg-muted"
369
+ onClick={() => {
370
+ onAdd(
371
+ preset.widthPx,
372
+ breakpointLabelForWidth(preset.widthPx),
373
+ );
374
+ setAddOpen(false);
375
+ }}
376
+ >
377
+ <span className="flex min-w-0 items-center gap-2">
378
+ <DeviceIcon widthPx={preset.widthPx} />
379
+ <span className="truncate">{preset.name}</span>
380
+ </span>
381
+ <span className="shrink-0 tabular-nums text-muted-foreground/60">
382
+ {preset.widthPx}
383
+ </span>
384
+ </button>
385
+ ))}
386
+ </div>
387
+ </>
388
+ ) : null}
389
+ {presets.length > 0 || devicePresets.length > 0 ? (
327
390
  <div className="my-1 h-px bg-border" />
328
391
  ) : null}
329
392
  <form