@agent-native/core 0.131.3 → 0.131.5

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 (60) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/docs-components.mdx +491 -0
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/client/AgentPanel.tsx +58 -34
  6. package/corpus/core/src/localization/default-messages.ts +6 -0
  7. package/corpus/templates/content/.agents/skills/content-product-development/SKILL.md +47 -0
  8. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +3 -1
  9. package/corpus/templates/design/actions/get-design.ts +11 -3
  10. package/corpus/templates/design/actions/present-design-variants.ts +62 -5
  11. package/corpus/templates/design/app/components/design/BreakpointBar.tsx +64 -1
  12. package/corpus/templates/design/app/components/design/EditPanel.tsx +199 -25
  13. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +160 -46
  14. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +7 -1
  15. package/corpus/templates/design/app/components/design/edit-panel/effects-properties.tsx +59 -12
  16. package/corpus/templates/design/app/components/design/edit-panel/fill-properties.tsx +1 -1
  17. package/corpus/templates/design/app/components/design/edit-panel/inspector-controls.tsx +34 -29
  18. package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +2 -2
  19. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +28 -10
  20. package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +15 -28
  21. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +4 -3
  22. package/corpus/templates/design/app/components/design/multi-screen/chrome-transitions.ts +12 -8
  23. package/corpus/templates/design/app/components/design/multi-screen/culling.ts +34 -9
  24. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +13 -27
  25. package/corpus/templates/design/app/components/design/multi-screen/types.ts +8 -1
  26. package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +95 -0
  27. package/corpus/templates/design/app/i18n-data.ts +121 -10
  28. package/corpus/templates/design/app/pages/design-editor/data-operations.ts +45 -0
  29. package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +59 -0
  30. package/corpus/templates/design/app/pages/design-editor/text-edit-utils.ts +115 -40
  31. package/corpus/templates/design/changelog/2026-07-28-generated-design-variants-no-longer-overlap-each-other-on-th.md +6 -0
  32. package/corpus/templates/design/changelog/2026-07-29-auto-layout-direction-icons-show-an-arrow.md +6 -0
  33. package/corpus/templates/design/changelog/2026-07-29-breakpoints-no-longer-overlap-neighbouring-screens.md +6 -0
  34. package/corpus/templates/design/changelog/2026-07-29-canvas-is-more-responsive-when-selecting-and-adding-frames.md +6 -0
  35. package/corpus/templates/design/changelog/2026-07-29-canvas-zoom-is-smoother-and-labels-hold-their-size.md +6 -0
  36. package/corpus/templates/design/changelog/2026-07-29-double-click-a-frame-now-drills-into-its-layers.md +6 -0
  37. package/corpus/templates/design/changelog/2026-07-29-export-preview-shows-the-real-render.md +6 -0
  38. package/corpus/templates/design/changelog/2026-07-29-figma-parity-shortcuts.md +6 -0
  39. package/corpus/templates/design/changelog/2026-07-29-more-breakpoint-width-presets.md +6 -0
  40. package/corpus/templates/design/changelog/2026-07-29-resize-a-selected-frame-to-a-device-preset.md +6 -0
  41. package/corpus/templates/design/changelog/2026-07-29-text-tool-reaches-the-right-surface.md +6 -0
  42. package/corpus/templates/design/shared/canvas-frames.ts +23 -0
  43. package/corpus/templates/design/shared/code-layer.ts +54 -2
  44. package/corpus/templates/design/shared/responsive-frame-layout.ts +55 -0
  45. package/dist/client/AgentPanel.d.ts.map +1 -1
  46. package/dist/client/AgentPanel.js +16 -5
  47. package/dist/client/AgentPanel.js.map +1 -1
  48. package/dist/localization/default-messages.d.ts +6 -0
  49. package/dist/localization/default-messages.d.ts.map +1 -1
  50. package/dist/localization/default-messages.js +6 -0
  51. package/dist/localization/default-messages.js.map +1 -1
  52. package/dist/notifications/routes.d.ts +1 -1
  53. package/dist/observability/routes.d.ts +3 -3
  54. package/dist/progress/routes.d.ts +1 -1
  55. package/dist/resources/handlers.d.ts +1 -1
  56. package/dist/server/transcribe-voice.d.ts +1 -1
  57. package/docs/content/docs-components.mdx +491 -0
  58. package/package.json +1 -1
  59. package/src/client/AgentPanel.tsx +58 -34
  60. package/src/localization/default-messages.ts +6 -0
@@ -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
@@ -48,6 +48,7 @@ import {
48
48
  IconChevronRight,
49
49
  IconCode,
50
50
  IconComponents,
51
+ IconDeviceMobile,
51
52
  IconExternalLink,
52
53
  IconDroplet,
53
54
  IconEye,
@@ -399,10 +400,31 @@ export function mergeOptimisticInteractionStateStyles(
399
400
 
400
401
  export type InspectorTab = "design" | "tweaks" | "comments" | "code";
401
402
 
403
+ /** Floor for a typed/preset frame size. Matches the frame tool's own minimum
404
+ * so a frame cannot be typed smaller than it can be drawn. */
405
+ const MIN_SCREEN_FRAME_SIZE_PX = 24;
406
+
402
407
  interface EditPanelProps {
403
408
  selectedElement: ElementInfo | null;
404
409
  selectedElements?: ElementInfo[];
405
410
  selectedScreenGeometry?: ScreenGeometrySelection | null;
411
+ /**
412
+ * Resizes/moves the selected screen frame. When omitted the geometry fields
413
+ * stay read-only, which is what read-only viewers and non-editable designs
414
+ * get. Supplying it also reveals the size-preset picker: the frame tool's
415
+ * full-panel preset list is only reachable *before* a frame exists, so
416
+ * without this an existing frame could never be set to a device size.
417
+ */
418
+ /** Design-level canvas background — the surround, not a screen's document.
419
+ * Omit onCanvasBackgroundChange to hide the Canvas section (read-only). */
420
+ canvasBackground?: string | null;
421
+ onCanvasBackgroundChange?: (value: string, meta?: StyleChangeMeta) => void;
422
+ onScreenGeometryChange?: (
423
+ screenId: string,
424
+ next: Partial<
425
+ Pick<ScreenGeometrySelection, "x" | "y" | "width" | "height">
426
+ >,
427
+ ) => void;
406
428
  pageStyles?: Record<string, string>;
407
429
  zoom?: number;
408
430
  headerTrailing?: ReactNode;
@@ -418,6 +440,9 @@ interface EditPanelProps {
418
440
  onStyleChange: StyleChangeHandler;
419
441
  onStylesChange?: StylesChangeHandler;
420
442
  onExport?: (settings: ExportSettingsValue[]) => void;
443
+ /** Rasterizes the current selection for the export preview. Must go through
444
+ * the same renderer as `onExport`, or the preview lies about the output. */
445
+ onRenderExportPreview?: () => Promise<Blob>;
421
446
  exporting?: boolean;
422
447
  /** Active file id — used for component prop editing context. */
423
448
  fileId?: string;
@@ -1234,13 +1259,72 @@ function ScreenSelectionHeader({
1234
1259
  );
1235
1260
  }
1236
1261
 
1262
+ /** Preset picker for a selected frame's size. Kept behind a popover on the
1263
+ * Size label rather than an always-visible list: the frame tool's full-panel
1264
+ * preset list only exists before a frame is drawn, so a selected frame had no
1265
+ * way to reach the same sizes. */
1266
+ function ScreenSizePresetPicker({
1267
+ onPick,
1268
+ }: {
1269
+ onPick: (preset: FrameSizePreset) => void;
1270
+ }) {
1271
+ const t = useT();
1272
+ const [open, setOpen] = useState(false);
1273
+ const label = t("editPanel.framePresets.applyToFrame");
1274
+
1275
+ return (
1276
+ <Popover open={open} onOpenChange={setOpen}>
1277
+ <Tooltip>
1278
+ <TooltipTrigger asChild>
1279
+ <PopoverTrigger asChild>
1280
+ <button
1281
+ type="button"
1282
+ className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-[var(--design-editor-control-hover-bg)] hover:text-foreground"
1283
+ aria-label={label}
1284
+ >
1285
+ <IconDeviceMobile className="size-3.5" />
1286
+ </button>
1287
+ </PopoverTrigger>
1288
+ </TooltipTrigger>
1289
+ <TooltipContent side="left">{label}</TooltipContent>
1290
+ </Tooltip>
1291
+ <PopoverContent align="end" side="left" className="w-56 p-0">
1292
+ <div className="flex max-h-80 flex-col overflow-hidden">
1293
+ <FramePresetsPanel
1294
+ onPick={(preset) => {
1295
+ onPick(preset);
1296
+ setOpen(false);
1297
+ }}
1298
+ />
1299
+ </div>
1300
+ </PopoverContent>
1301
+ </Popover>
1302
+ );
1303
+ }
1304
+
1237
1305
  function ScreenGeometryProperties({
1238
1306
  screen,
1307
+ onGeometryChange,
1239
1308
  }: {
1240
1309
  screen: ScreenGeometrySelection;
1310
+ onGeometryChange?: (
1311
+ screenId: string,
1312
+ next: Partial<
1313
+ Pick<ScreenGeometrySelection, "x" | "y" | "width" | "height">
1314
+ >,
1315
+ ) => void;
1241
1316
  }) {
1242
1317
  const t = useT();
1243
1318
  const noop = useCallback(() => {}, []);
1319
+ const editable = Boolean(onGeometryChange);
1320
+ const commit = useCallback(
1321
+ (
1322
+ next: Partial<
1323
+ Pick<ScreenGeometrySelection, "x" | "y" | "width" | "height">
1324
+ >,
1325
+ ) => onGeometryChange?.(screen.id, next),
1326
+ [onGeometryChange, screen.id],
1327
+ );
1244
1328
 
1245
1329
  return (
1246
1330
  <PanelSection title={t("editPanel.sections.positionLayout")}>
@@ -1250,40 +1334,51 @@ function ScreenGeometryProperties({
1250
1334
  <ScrubInput
1251
1335
  label="X"
1252
1336
  value={Math.round(screen.x)}
1253
- onChange={noop}
1337
+ onChange={editable ? (value) => commit({ x: value }) : noop}
1254
1338
  unit="px"
1255
- disabled
1339
+ disabled={!editable}
1256
1340
  inputClassName="h-6"
1257
1341
  />
1258
1342
  <ScrubInput
1259
1343
  label="Y"
1260
1344
  value={Math.round(screen.y)}
1261
- onChange={noop}
1345
+ onChange={editable ? (value) => commit({ y: value }) : noop}
1262
1346
  unit="px"
1263
- disabled
1347
+ disabled={!editable}
1264
1348
  inputClassName="h-6"
1265
1349
  />
1266
1350
  </div>
1267
1351
  </div>
1268
1352
  <div className="space-y-1.5">
1269
- <SubsectionLabel>
1270
- {"Size" /* i18n-ignore design inspector label */}
1271
- </SubsectionLabel>
1353
+ <div className="flex items-center justify-between gap-2">
1354
+ <SubsectionLabel>
1355
+ {"Size" /* i18n-ignore design inspector label */}
1356
+ </SubsectionLabel>
1357
+ {editable ? (
1358
+ <ScreenSizePresetPicker
1359
+ onPick={(preset) =>
1360
+ commit({ width: preset.width, height: preset.height })
1361
+ }
1362
+ />
1363
+ ) : null}
1364
+ </div>
1272
1365
  <div className="grid grid-cols-2 gap-2">
1273
1366
  <ScrubInput
1274
1367
  label="W"
1275
1368
  value={Math.round(screen.width)}
1276
- onChange={noop}
1369
+ onChange={editable ? (value) => commit({ width: value }) : noop}
1277
1370
  unit="px"
1278
- disabled
1371
+ min={MIN_SCREEN_FRAME_SIZE_PX}
1372
+ disabled={!editable}
1279
1373
  inputClassName="h-6"
1280
1374
  />
1281
1375
  <ScrubInput
1282
1376
  label="H"
1283
1377
  value={Math.round(screen.height)}
1284
- onChange={noop}
1378
+ onChange={editable ? (value) => commit({ height: value }) : noop}
1285
1379
  unit="px"
1286
- disabled
1380
+ min={MIN_SCREEN_FRAME_SIZE_PX}
1381
+ disabled={!editable}
1287
1382
  inputClassName="h-6"
1288
1383
  />
1289
1384
  </div>
@@ -1374,10 +1469,14 @@ function PageProperties({
1374
1469
  styles,
1375
1470
  onStyleChange,
1376
1471
  onStylesChange,
1472
+ canvasBackground,
1473
+ onCanvasBackgroundChange,
1377
1474
  }: {
1378
1475
  styles: Record<string, string>;
1379
1476
  onStyleChange: StyleChangeHandler;
1380
1477
  onStylesChange?: StylesChangeHandler;
1478
+ canvasBackground?: string | null;
1479
+ onCanvasBackgroundChange?: (value: string, meta?: StyleChangeMeta) => void;
1381
1480
  }) {
1382
1481
  const t = useT();
1383
1482
  const baseFontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
@@ -1399,6 +1498,20 @@ function PageProperties({
1399
1498
 
1400
1499
  return (
1401
1500
  <div>
1501
+ {/* With nothing selected you are looking at the canvas, so this edits the
1502
+ canvas surround. The section below still owns the SCREEN's own
1503
+ document background and type defaults. */}
1504
+ {onCanvasBackgroundChange ? (
1505
+ <PanelSection title={t("editPanel.sections.canvas")}>
1506
+ <ColorInput
1507
+ label={t("editPanel.labels.background")}
1508
+ value={canvasBackground ?? ""}
1509
+ // meta carries phase: "preview" while dragging vs "commit" on
1510
+ // release. Dropping it persists every tick and the picker jumps.
1511
+ onChange={(value, meta) => onCanvasBackgroundChange(value, meta)}
1512
+ />
1513
+ </PanelSection>
1514
+ ) : null}
1402
1515
  <PanelSection title={t("editPanel.sections.page")}>
1403
1516
  <ColorInput
1404
1517
  label={t("editPanel.labels.background")}
@@ -1448,32 +1561,77 @@ function PageProperties({
1448
1561
  * frame above the export rows). Renders a proportional placeholder reflecting
1449
1562
  * the selected element's aspect ratio, fill, radius and dimensions.
1450
1563
  */
1451
- function ExportPreview({ element }: { element: ElementInfo | null }) {
1564
+ /** Rasterizes through the same path the export actions use, so the preview
1565
+ * cannot disagree with the file. A failed render must stay visibly failed —
1566
+ * never substitute a synthesized stand-in for the real content. */
1567
+ function ExportPreview({
1568
+ element,
1569
+ onRender,
1570
+ }: {
1571
+ element: ElementInfo | null;
1572
+ onRender?: () => Promise<Blob>;
1573
+ }) {
1574
+ const t = useT();
1452
1575
  const rect = element?.boundingRect;
1453
1576
  const width = rect?.width ?? 0;
1454
1577
  const height = rect?.height ?? 0;
1455
1578
  const aspect = width > 0 && height > 0 ? width / height : 1;
1456
- const styles = element?.computedStyles ?? {};
1457
- const fill = cssColorOrFallback(
1458
- styles.backgroundColor || styles.color,
1459
- "var(--design-editor-control-bg)",
1460
- );
1461
- const radius = Math.min(8, cssLengthNumber(styles.borderRadius || "0"));
1579
+ const [state, setState] = useState<
1580
+ | { status: "idle" }
1581
+ | { status: "loading" }
1582
+ | { status: "ready"; url: string }
1583
+ | { status: "failed" }
1584
+ >({ status: "idle" });
1585
+
1586
+ useEffect(() => {
1587
+ if (!onRender) {
1588
+ setState({ status: "idle" });
1589
+ return;
1590
+ }
1591
+ let cancelled = false;
1592
+ let objectUrl: string | null = null;
1593
+ setState({ status: "loading" });
1594
+ void onRender()
1595
+ .then((blob) => {
1596
+ if (cancelled) return;
1597
+ objectUrl = URL.createObjectURL(blob);
1598
+ setState({ status: "ready", url: objectUrl });
1599
+ })
1600
+ .catch(() => {
1601
+ if (!cancelled) setState({ status: "failed" });
1602
+ });
1603
+ return () => {
1604
+ cancelled = true;
1605
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
1606
+ };
1607
+ }, [onRender]);
1462
1608
 
1463
1609
  return (
1464
1610
  <div className="mt-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] p-3">
1465
1611
  <div
1466
- className="mx-auto flex max-h-28 items-center justify-center"
1612
+ className="mx-auto flex max-h-28 items-center justify-center overflow-hidden"
1467
1613
  style={{
1468
1614
  aspectRatio: aspect,
1469
1615
  width: aspect >= 1 ? "100%" : "auto",
1470
1616
  height: aspect < 1 ? "7rem" : "auto",
1471
1617
  }}
1472
1618
  >
1473
- <div
1474
- className="size-full border border-[var(--design-editor-control-border)] shadow-sm"
1475
- style={{ background: fill, borderRadius: radius }}
1476
- />
1619
+ {state.status === "ready" ? (
1620
+ <img
1621
+ src={state.url}
1622
+ alt=""
1623
+ className="size-full object-contain"
1624
+ style={{
1625
+ imageRendering: "auto",
1626
+ }}
1627
+ />
1628
+ ) : (
1629
+ <div className="flex size-full items-center justify-center rounded border border-[var(--design-editor-control-border)] bg-[var(--design-editor-panel-bg)] px-2 text-center !text-[10px] text-muted-foreground">
1630
+ {state.status === "failed"
1631
+ ? t("editPanel.exportPreview.failed")
1632
+ : t("editPanel.exportPreview.rendering")}
1633
+ </div>
1634
+ )}
1477
1635
  </div>
1478
1636
  <p className="mt-2 text-center text-[10px] tabular-nums text-muted-foreground">
1479
1637
  {Math.round(width)} × {Math.round(height)}
@@ -1591,6 +1749,9 @@ export const EditPanel = memo(function EditPanel({
1591
1749
  selectedElement,
1592
1750
  selectedElements,
1593
1751
  selectedScreenGeometry,
1752
+ canvasBackground,
1753
+ onCanvasBackgroundChange,
1754
+ onScreenGeometryChange,
1594
1755
  pageStyles = {},
1595
1756
  headerTrailing,
1596
1757
  width = 256,
@@ -1604,6 +1765,7 @@ export const EditPanel = memo(function EditPanel({
1604
1765
  onStyleChange: onStyleChangeProp,
1605
1766
  onStylesChange: onStylesChangeProp,
1606
1767
  onExport,
1768
+ onRenderExportPreview,
1607
1769
  exporting = false,
1608
1770
  fileId,
1609
1771
  activeContent,
@@ -2106,7 +2268,12 @@ export const EditPanel = memo(function EditPanel({
2106
2268
  ) : null}
2107
2269
 
2108
2270
  {!inspectorElement && selectedScreenGeometry ? (
2109
- <ScreenGeometryProperties screen={selectedScreenGeometry} />
2271
+ <ScreenGeometryProperties
2272
+ screen={selectedScreenGeometry}
2273
+ onGeometryChange={
2274
+ readOnly ? undefined : onScreenGeometryChange
2275
+ }
2276
+ />
2110
2277
  ) : null}
2111
2278
 
2112
2279
  {!inspectorElement && !selectedScreenGeometry && (
@@ -2114,6 +2281,10 @@ export const EditPanel = memo(function EditPanel({
2114
2281
  styles={pageStyles}
2115
2282
  onStyleChange={onStyleChange}
2116
2283
  onStylesChange={onStylesChange}
2284
+ canvasBackground={canvasBackground}
2285
+ onCanvasBackgroundChange={
2286
+ readOnly ? undefined : onCanvasBackgroundChange
2287
+ }
2117
2288
  />
2118
2289
  )}
2119
2290
 
@@ -2232,7 +2403,10 @@ export const EditPanel = memo(function EditPanel({
2232
2403
  onExport={onExport}
2233
2404
  />
2234
2405
  {showExportPreview ? (
2235
- <ExportPreview element={inspectorElement} />
2406
+ <ExportPreview
2407
+ element={inspectorElement}
2408
+ onRender={onRenderExportPreview}
2409
+ />
2236
2410
  ) : null}
2237
2411
  </PanelSection>
2238
2412
  ) : null}