Skip to content
Draft
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
22 changes: 22 additions & 0 deletions templates/design/.agents/skills/design-templates/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ description: >-
dimensions, linked design system, and locked-layer boundaries before
reporting completion.

## The Template On Follow-Up Requests

A copied screen is edited in place. After the first refinement saves, the
design's own files are the *result*, not the template — so a later turn that
reads only `get-design-snapshot` has no idea what the template specified, and
fonts and artboard dimensions drift a little further on each request.

The two facts that drift are cheap, so they arrive on their own. `view-screen`
reports `design.createdFromTemplate` on every turn for a template-created
design, carrying `lockedDimensions` per screen and `lockedFonts`. Both are
captured from the template at copy time, so they describe the template even
after the design has been edited many times. Honour them in every
`edit-design` pass: never resize the artboard, change a `canvasFrames` width or
height, switch the primary viewport, or substitute a typeface to fit new
content.

Call `get-design-template --designId="<id>"` when you need more than those two
facts — the template's original markup, or its locked layers — for example
before a structural edit or when the user asks how far the design has moved
from its template. The full template files are large, which is why they are a
deliberate second call rather than part of every turn.

## Locked Layers

`data-agent-native-locked="true"` is authoritative. Keep each locked element
Expand Down
3 changes: 3 additions & 0 deletions templates/design/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ ladder.
- Resolve templates or prior designs with `list-design-templates` and
`list-designs`, copy with `create-design-from-template`, then inspect and
adapt copied files with `get-design-snapshot` and `edit-design`.
- Copied template screens are edited in place. Preserve
`createdFromTemplate.lockedDimensions`/`lockedFonts` from `view-screen` in
every edit; `get-design-template` returns the original.

## Core Rules

Expand Down
13 changes: 12 additions & 1 deletion templates/design/actions/create-design-from-template.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ vi.mock("../server/db/index.js", () => {
filename: "index.html",
fileType: "html",
content:
'<main style="width:1080px;height:1080px"><div data-agent-native-locked="true">Brand</div><p>Editable</p></main>',
'<link href="https://fonts.googleapis.com/css2?family=Sora:wght@700" rel="stylesheet">' +
'<main style="width:1080px;height:1080px;font-family:Sora,sans-serif"><div data-agent-native-locked="true">Brand</div><p>Editable</p></main>',
},
];
return {
Expand Down Expand Up @@ -156,6 +157,16 @@ describe("create-design-from-template", () => {
appliedDesignSystemId: "override-system",
designSystemOverridden: true,
});
expect(data.templateSource.files).toEqual([
{
designFileId: "copied-file",
templateFileId: "template-file",
filename: "index.html",
width: 1080,
height: 1080,
},
]);
expect(data.templateSource.fonts).toEqual(["Sora"]);
expect(testState.insertedFiles[0]?.content).toContain(
'data-agent-native-locked="true"',
);
Expand Down
19 changes: 19 additions & 0 deletions templates/design/actions/create-design-from-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import {
extractTemplateFonts,
redactTemplateDesignData,
remapTemplateFileIds,
templateFileDimensions,
} from "../server/lib/design-template-data.js";
import { getDesignTemplatePreset } from "../shared/design-template-presets.js";
import { countLockedLayersAcrossFiles } from "../shared/locked-layers.js";
Expand Down Expand Up @@ -149,6 +151,11 @@ export default defineAction({
redactTemplateDesignData(templateData),
fileIdMap,
);
// The copied screens are edited in place, so the design's own files stop
// being evidence of what the template looked like after the first
// refinement. Capturing the small facts here — which template file backs
// each screen, its exact frame, and the declared fonts — is what lets
// every later turn restate them without re-reading the template.
data.templateSource = {
templateId,
title: templateTitle,
Expand All @@ -158,6 +165,18 @@ export default defineAction({
templateDesignSystemId,
appliedDesignSystemId: linkedDesignSystemId,
designSystemOverridden,
files: files.map((file) => {
const designFileId = fileIdMap.get(file.id)!;
const { width, height } = templateFileDimensions(data, designFileId);
return {
designFileId,
templateFileId: file.id,
filename: file.filename,
width,
height,
};
}),
fonts: extractTemplateFonts(files.map((file) => file.content).join("\n")),
};
if (prompt) data.templatePrompt = prompt;

Expand Down
18 changes: 18 additions & 0 deletions templates/design/actions/get-design-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { z } from "zod";

import { schema } from "../server/db/index.js";
import { buildDesignSnapshot } from "../server/lib/design-snapshot.js";
import {
parseDesignTemplateData,
readDesignTemplateSource,
} from "../server/lib/design-template-data.js";
import { lockedLayerSnapshots } from "../shared/locked-layers.js";
import "../server/db/index.js"; // ensure registerShareableResource runs

Expand Down Expand Up @@ -66,6 +70,9 @@ export default defineAction({
const design = access.resource as typeof schema.designs.$inferSelect;

const snapshot = await buildDesignSnapshot(designId, design.data);
const templateSource = readDesignTemplateSource(
parseDesignTemplateData(design.data),
);
const requestedFileId = fileId?.trim();
const requestedFilename = filename?.trim();
const files = requestedFileId
Expand Down Expand Up @@ -97,6 +104,17 @@ export default defineAction({
projectType: design.projectType,
designSystemId: design.designSystemId ?? null,
updatedAt: design.updatedAt,
...(templateSource
? {
createdFromTemplate: {
templateId: templateSource.templateId,
title: templateSource.title,
note:
"These files are edited copies of the template, not the template itself. " +
`Call \`get-design-template --designId="${designId}"\` for the original canvas dimensions, typography, and locked layers, and preserve them in this edit.`,
},
}
: {}),
files: files.map((f) => ({
id: f.id,
filename: f.filename,
Expand Down
172 changes: 172 additions & 0 deletions templates/design/actions/get-design-template.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const testState = vi.hoisted(() => ({
resolveAccess: vi.fn(),
}));

vi.mock("@agent-native/core/sharing", () => ({
resolveAccess: (...args: unknown[]) => testState.resolveAccess(...args),
}));

vi.mock("drizzle-orm", async (importOriginal) => ({
...(await importOriginal<typeof import("drizzle-orm")>()),
eq: (column: unknown, value: unknown) => ({ column, value }),
}));

vi.mock("../server/db/index.js", () => ({
schema: {
designs: { table: "designs" },
designTemplateFiles: {
table: "designTemplateFiles",
id: "designTemplateFiles.id",
templateId: "designTemplateFiles.templateId",
filename: "designTemplateFiles.filename",
fileType: "designTemplateFiles.fileType",
content: "designTemplateFiles.content",
},
},
getDb: () => ({
select: () => ({
from: () => ({
where: async () => [
{
id: "template-file",
filename: "index.html",
fileType: "html",
content:
'<main style="font-family:Sora,sans-serif"><div data-agent-native-locked="true" id="brand">Brand</div><p>Editable</p></main>',
},
],
}),
}),
}),
}));

import action from "./get-design-template.js";

const TEMPLATE_RESOURCE = {
id: "saved-template",
title: "Saved campaign",
description: "Reusable campaign",
category: "social",
designSystemId: null,
width: 1080,
height: 1080,
data: JSON.stringify({
canvasFrames: {
"template-file": { x: 0, y: 0, width: 1080, height: 1080 },
},
}),
};

function designResource(data: unknown) {
return {
role: "owner",
resource: {
id: "design-1",
title: "Summer promo",
data: JSON.stringify(data),
},
};
}

describe("get-design-template", () => {
beforeEach(() => {
vi.clearAllMocks();
testState.resolveAccess.mockImplementation(
async (type: string, id: string) => {
if (type === "design-template" && id === "saved-template") {
return { role: "owner", resource: TEMPLATE_RESOURCE };
}
return null;
},
);
});

it("returns the original template behind a design whose screens were already edited", async () => {
testState.resolveAccess.mockImplementation(
async (type: string, id: string) => {
if (type === "design" && id === "design-1") {
return designResource({
// The design's own frame has drifted away from the template.
canvasFrames: { "copied-file": { width: 800, height: 600 } },
templateSource: {
templateId: "saved-template",
title: "Saved campaign",
instantiatedAt: "2026-07-14T00:00:00.000Z",
files: [
{
designFileId: "copied-file",
templateFileId: "template-file",
},
],
},
});
}
if (type === "design-template" && id === "saved-template") {
return { role: "owner", resource: TEMPLATE_RESOURCE };
}
return null;
},
);

const result = await action.run({ designId: "design-1" });

expect(result).toMatchObject({
templateId: "saved-template",
fromTemplate: true,
fileCount: 1,
});
expect(result.files?.[0]).toMatchObject({
templateFileId: "template-file",
designFileId: "copied-file",
width: 1080,
height: 1080,
});
expect(result.files?.[0]?.content).toContain("font-family:Sora");
expect(result.files?.[0]?.lockedLayers).toHaveLength(1);
});

it("reports designs that were never created from a template", async () => {
testState.resolveAccess.mockImplementation(async (type: string) =>
type === "design" ? designResource({ canvasFrames: {} }) : null,
);

const result = await action.run({ designId: "design-1" });

expect(result).toMatchObject({ fromTemplate: false });
});

it("fails loudly when a design claims an unreadable template", async () => {
testState.resolveAccess.mockImplementation(async (type: string) =>
type === "design"
? designResource({ templateSource: { title: "Saved campaign" } })
: null,
);

await expect(action.run({ designId: "design-1" })).rejects.toThrow(
"readable templateId",
);
});

it("reads a built-in preset directly by template id", async () => {
const result = await action.run({ templateId: "preset-social-square" });

expect(result).toMatchObject({ isBuiltIn: true, fileCount: 1 });
expect(result.files?.[0]?.width).toBeGreaterThan(0);
});

it("rejects a template id that does not match the design's template", async () => {
testState.resolveAccess.mockImplementation(async (type: string) =>
type === "design"
? designResource({
templateSource: { templateId: "saved-template", files: [] },
})
: null,
);

await expect(
action.run({ designId: "design-1", templateId: "other-template" }),
).rejects.toThrow("was created from template");
});
});
Loading
Loading