Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions templates/design/actions/get-design.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ const mocks = vi.hoisted(() => {
const selectChain = {
from: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
};
selectChain.from.mockReturnValue(selectChain);
selectChain.where.mockReturnValue(selectChain);

return {
asc: vi.fn((column) => ({ asc: column })),
eq: vi.fn((left, right) => ({ left, right })),
getDb: vi.fn(() => ({
select: vi.fn(() => selectChain),
Expand All @@ -23,14 +26,19 @@ vi.mock("@agent-native/core/sharing", () => ({
}));

vi.mock("drizzle-orm", () => ({
asc: mocks.asc,
eq: mocks.eq,
sql: vi.fn((strings, ...values) => ({ strings, values })),
}));

vi.mock("../server/db/index.js", () => ({
getDb: mocks.getDb,
schema: {
designFiles: { designId: "designFiles.designId" },
designFiles: {
id: "designFiles.id",
designId: "designFiles.designId",
createdAt: "designFiles.createdAt",
},
},
}));

Expand All @@ -40,7 +48,8 @@ import action from "./get-design.js";
describe("get-design", () => {
beforeEach(() => {
mocks.resolveAccess.mockReset();
mocks.selectChain.where.mockReset();
mocks.selectChain.orderBy.mockReset();
mocks.asc.mockClear();
mocks.resolveAccess.mockResolvedValue({
role: "viewer",
resource: {
Expand All @@ -65,7 +74,7 @@ describe("get-design", () => {
updatedAt: "2026-06-29T00:00:00.000Z",
},
});
mocks.selectChain.where.mockResolvedValue([
mocks.selectChain.orderBy.mockResolvedValue([
{
id: "file_123",
filename: "index.html",
Expand Down Expand Up @@ -107,6 +116,17 @@ describe("get-design", () => {
expect(result.data).not.toContain("example-private-bridge-token");
});

it("returns files in a stable order so a design lays itself out the same way twice", async () => {
// Heap order is not stable across writes, and this array feeds the overview
// screen stack plus each screen's index within its layout group.
await action.run({ id: "design_123" });

expect(mocks.selectChain.orderBy).toHaveBeenCalledWith(
{ asc: "designFiles.createdAt" },
{ asc: "designFiles.id" },
);
});

it("returns only the read-only preview token to an editor", async () => {
mocks.resolveAccess.mockResolvedValueOnce({
role: "editor",
Expand Down
14 changes: 11 additions & 3 deletions templates/design/actions/get-design.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineAction } from "@agent-native/core";
import { resolveAccess } from "@agent-native/core/sharing";
import { eq } from "drizzle-orm";
import { asc, eq } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
Expand All @@ -26,11 +26,19 @@ export default defineAction({
const row = access.resource;
const db = getDb();

// Fetch associated files
// Fetch associated files in a stable order. This array feeds the overview
// canvas's screen stack and each screen's index within its layout group, so
// unordered rows (Postgres returns heap order, which an UPDATE can change)
// meant the same design could lay itself out differently on two loads.
// Note this is deterministic, not creation-ordered: files written in one
// batch share a `createdAt` to the millisecond and fall back to the id
// tiebreak. Nothing may depend on the index matching the order a generator
// wrote in — see the order-independence case in variant-lineup.test.ts.
const files = await db
.select()
.from(schema.designFiles)
.where(eq(schema.designFiles.designId, id));
.where(eq(schema.designFiles.designId, id))
.orderBy(asc(schema.designFiles.createdAt), asc(schema.designFiles.id));

return {
id: row.id,
Expand Down
50 changes: 50 additions & 0 deletions templates/design/actions/present-design-variants.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,56 @@ describe("present-design-variants", () => {
);
});

it("spaces desktop directions by their whole painted row, not just the primary frame", async () => {
await action.run({
designId: "design_123",
prompt: "Landing page for a Super Mario game",
variants: [
{ id: "retro", label: "Classic 8-Bit Retro" },
{ id: "modern", label: "Modern 3D" },
{ id: "comic", label: "Comic Poster" },
],
});

const data = mocks.designData;
// Desktop-base directions (1440) each paint a 390 mobile preview to their
// right, so a cell is 1440 + 24 + 390 = 1854 wide before the 96 gap.
// Spacing by the primary width alone dropped the next direction 1110px
// inside the previous one's breakpoint row.
expect(Object.values(data.canvasFrames).map((frame) => frame.x)).toEqual([
0, 1950, 3900,
]);
expect(
Object.values(data.canvasFrames).map((frame) => frame.width),
).toEqual([1440, 1440, 1440]);
});

it("reserves every breakpoint the design already has", async () => {
mocks.designData.breakpointSet = {
id: "existing",
breakpoints: [
{ id: "m", label: "Mobile", widthPx: 390 },
{ id: "t", label: "Tablet", widthPx: 768 },
{ id: "d", label: "Desktop", widthPx: 1440 },
],
};

await action.run({
designId: "design_123",
prompt: "Landing page for a Super Mario game",
variants: [
{ id: "retro", label: "Classic 8-Bit Retro" },
{ id: "modern", label: "Modern 3D" },
],
});

// 1440 base drops the redundant 1440 preview and reserves 768 + 390:
// 1440 + (24 + 768) + (24 + 390) = 2646, then the 96 gap.
expect(
Object.values(mocks.designData.canvasFrames).map((f) => f.x),
).toEqual([0, 2742]);
});

it("renders compact fallback variants from non-todo mobile direction data", async () => {
await action.run({
designId: "design_123",
Expand Down
56 changes: 52 additions & 4 deletions templates/design/actions/present-design-variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
} from "../shared/canvas-frames.js";
import { isUniqueConstraintViolation } from "../shared/db-conflict.js";
import { widthToPrefix } from "../shared/responsive-classes.js";
import {
getResponsiveGroupWidth,
visibleBreakpointWidths,
} from "../shared/responsive-frame-layout.js";
import { annotateScreenHtmlForPersist } from "../shared/screen-annotation.js";

const VARIANT_GAP = 96;
Expand Down Expand Up @@ -734,7 +738,19 @@ ${compact ? ".sidebar { padding: 16px; } .nav { grid-template-columns: repeat(2,
</html>`;
}

function placeVariantScreens(screens: VariantScreen[]) {
/**
* Lays the generated directions out in rows of up to three.
*
* Each cell reserves the screen's WHOLE painted footprint, not just its
* primary frame: this action also installs a design-wide breakpoint set, and
* the overview paints one preview per breakpoint to the right of every primary
* frame. Spacing by `width + VARIANT_GAP` alone drops the next direction on top
* of the previous one's breakpoint row.
*/
function placeVariantScreens(
screens: VariantScreen[],
breakpointWidths: readonly number[],
) {
const placements: CanvasFramePlacement[] = [];
const columns = Math.min(MAX_COLUMNS, Math.max(1, screens.length));
let rowY = 0;
Expand All @@ -754,7 +770,17 @@ function placeVariantScreens(screens: VariantScreen[]) {
height: screen.height,
z: rowStart + offset,
});
x += screen.width + VARIANT_GAP;
x +=
getResponsiveGroupWidth({
primaryWidth: screen.width,
// Frames are placed at their natural device width, so the breakpoint
// previews beside them are drawn unscaled.
scale: 1,
visibleWidths: visibleBreakpointWidths(
breakpointWidths,
screen.width,
),
}) + VARIANT_GAP;
rowHeight = Math.max(rowHeight, screen.height);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Reserve breakpoint preview height when advancing variant rows

x now accounts for every breakpoint preview, but rowHeight is still only screen.height. A generated mobile/tablet screen can have a much taller painted breakpoint preview when the design already has a wider breakpoint, so the next variant row is placed over the previous row's preview. Compute the maximum painted group height for the row using the same responsive sizing rules as the canvas, and add a regression case with a wider existing breakpoint.

Additional Info
Confirmed by code review agent; browser visual verification was unavailable because executor tooling lacked browser controls.

Fix in Builder

}

Expand All @@ -764,6 +790,22 @@ function placeVariantScreens(screens: VariantScreen[]) {
return placements;
}

/** Widths the overview will paint beside each primary frame once this call
* settles: the design's own set when it has one, otherwise the set this action
* is about to install. */
function effectiveBreakpointWidths(currentBreakpointSet: unknown): number[] {
const breakpoints = hasBreakpointSet(currentBreakpointSet)
? ((currentBreakpointSet as { breakpoints: Array<{ widthPx?: unknown }> })
.breakpoints ?? [])
: DEFAULT_RESPONSIVE_BREAKPOINTS;
return breakpoints
.map((breakpoint) => breakpoint.widthPx)
.filter(
(widthPx): widthPx is number =>
typeof widthPx === "number" && Number.isFinite(widthPx) && widthPx > 0,
);
}

export default defineAction({
description:
"Present generated design directions as normal screens on the Design " +
Expand Down Expand Up @@ -917,13 +959,19 @@ export default defineAction({
});
}

const placements = placeVariantScreens(screens);
await mutateDesignData({
designId,
mutate: (current, { updatedAt }) => {
// Placement depends on the breakpoint set in effect after this call, so
// it is resolved here (inside the compare-and-set body) rather than
// against a design snapshot that a concurrent write may have moved on
// from.
const mergedFrames = mergeCanvasFramePlacements({
existing: current.canvasFrames,
placements,
placements: placeVariantScreens(
screens,
effectiveBreakpointWidths(current.breakpointSet),
),
resolveFileId: (placement) => placement.fileId,
});
const previousMetadata = isRecord(current.screenMetadata)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
isFrameWithinOverscannedViewport,
OVERVIEW_CULLING_ENABLED,
OVERVIEW_CULLING_OVERSCAN_FACTOR,
OVERVIEW_LIVE_IFRAME_BUDGET,
OVERVIEW_LIVE_IFRAME_CEILING,
OVERVIEW_LIVE_SCREEN_BUDGET,
type ScreenCullCandidate,
type OverscannedViewportBounds,
} from "./multi-screen/culling";
Expand Down Expand Up @@ -115,6 +116,7 @@ describe("MultiScreenCanvas viewport culling", () => {
previous?: ReturnType<typeof computeBoundedScreenCullState>;
epoch?: number;
budget?: number;
screenBudget?: number;
} = {},
) {
return computeBoundedScreenCullState({
Expand All @@ -129,6 +131,7 @@ describe("MultiScreenCanvas viewport culling", () => {
options.previous?.lastVisibleEpochByScreenId ??
new Map<string, number>(),
accessEpoch: options.epoch ?? 1,
liveScreenBudget: options.screenBudget,
liveIframeBudget: options.budget,
});
}
Expand All @@ -139,19 +142,61 @@ describe("MultiScreenCanvas viewport culling", () => {
);
const result = compute(candidates);

expect(OVERVIEW_LIVE_IFRAME_BUDGET).toBeGreaterThan(0);
expect(result.liveScreenIds.size).toBe(OVERVIEW_LIVE_IFRAME_BUDGET);
expect(result.mountedIframeCount).toBe(OVERVIEW_LIVE_IFRAME_BUDGET);
expect(OVERVIEW_LIVE_SCREEN_BUDGET).toBeGreaterThan(0);
expect(result.liveScreenIds.size).toBe(OVERVIEW_LIVE_SCREEN_BUDGET);
expect(result.mountedIframeCount).toBe(OVERVIEW_LIVE_SCREEN_BUDGET);
expect(
[...result.tierByScreenId.values()].filter(
(tier) => tier === "visible",
),
).toHaveLength(OVERVIEW_LIVE_IFRAME_BUDGET);
).toHaveLength(OVERVIEW_LIVE_SCREEN_BUDGET);
expect(
[...result.tierByScreenId.values()].filter(
(tier) => tier === "placeholder",
),
).toHaveLength(120 - OVERVIEW_LIVE_IFRAME_BUDGET);
).toHaveLength(120 - OVERVIEW_LIVE_SCREEN_BUDGET);
});

it("keeps a breakpoint-bearing board fully mounted instead of evicting on camera moves", () => {
// The reported flicker: 14 screens x (primary + two breakpoint previews)
// = 42 contexts. Charged against one flat 32-iframe budget this admitted
// only ten screens, so every committed pan/zoom re-ranked the survivors
// and destroyed/recreated the losers' documents. Budgeting screens keeps
// the whole board live, and the iframe ceiling still bounds memory.
const candidates = Array.from({ length: 14 }, (_, index) =>
candidate(`responsive-${index}`, index * 2_700, 3),
);
const first = compute(candidates);
expect(first.liveScreenIds.size).toBe(14);
expect(first.mountedIframeCount).toBe(42);
expect(first.mountedIframeCount).toBeLessThanOrEqual(
OVERVIEW_LIVE_IFRAME_CEILING,
);
expect([...first.tierByScreenId.values()]).not.toContain("evicted");

// Same board one camera commit later: nothing may change hands.
const second = compute(candidates, { previous: first, epoch: 2 });
expect(second.liveScreenIds).toEqual(first.liveScreenIds);
expect([...second.tierByScreenId.values()]).not.toContain("evicted");
});

it("still bounds a huge breakpoint-bearing board by the iframe ceiling", () => {
const candidates = Array.from({ length: 120 }, (_, index) =>
candidate(
`responsive-${String(index).padStart(3, "0")}`,
index * 500,
4,
),
);
const result = compute(candidates);

// 32 screens x 4 contexts would be 128, past the ceiling, so the ceiling
// binds first and no screen is ever partially mounted.
expect(result.mountedIframeCount).toBeLessThanOrEqual(
OVERVIEW_LIVE_IFRAME_CEILING,
);
expect(result.mountedIframeCount % 4).toBe(0);
expect(result.liveScreenIds.size).toBe(OVERVIEW_LIVE_IFRAME_CEILING / 4);
});

it("evicts least-recently-visible screens and restores them on revisit", () => {
Expand Down
3 changes: 1 addition & 2 deletions templates/design/app/components/design/MultiScreenCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ import {
} from "./multi-screen/draft-primitives";
import {
angleBetween,
BREAKPOINT_FRAME_GAP,
cloneFrameGeometryById,
deviceViewportFloorForWidth,
findTopFrameEntryAtPoint,
Expand Down Expand Up @@ -9820,8 +9821,6 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {

// ── Breakpoint preview row (§6.4) ────────────────────────────────────────────

/** Gap between adjacent breakpoint frames in canvas pixels. */
const BREAKPOINT_FRAME_GAP = 24;
const BREAKPOINT_LABEL_WITH_WIDTH_MIN_FRAME_WIDTH = 128;

/**
Expand Down
Loading
Loading