From d83af515d21ca819b6cf3c62a7a9442254c4a4be Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 4 Aug 2026 16:32:53 -0400 Subject: [PATCH] properly reference template on subsequent prompts --- .../.agents/skills/design-templates/SKILL.md | 22 +++ templates/design/AGENTS.md | 3 + .../create-design-from-template.spec.ts | 13 +- .../actions/create-design-from-template.ts | 19 ++ .../design/actions/get-design-snapshot.ts | 18 ++ .../actions/get-design-template.spec.ts | 172 +++++++++++++++++ .../design/actions/get-design-template.ts | 173 ++++++++++++++++++ templates/design/actions/view-screen.ts | 31 ++++ .../generation-prompt-directives.ts | 1 + .../design/server/lib/design-template-data.ts | 138 ++++++++++++++ .../server/lib/design-template-source.test.ts | 91 +++++++++ 11 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 templates/design/actions/get-design-template.spec.ts create mode 100644 templates/design/actions/get-design-template.ts create mode 100644 templates/design/server/lib/design-template-source.test.ts diff --git a/templates/design/.agents/skills/design-templates/SKILL.md b/templates/design/.agents/skills/design-templates/SKILL.md index 0823c5378b..4117829fa3 100644 --- a/templates/design/.agents/skills/design-templates/SKILL.md +++ b/templates/design/.agents/skills/design-templates/SKILL.md @@ -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=""` 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 diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index fb50559dbb..2add917717 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -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 diff --git a/templates/design/actions/create-design-from-template.spec.ts b/templates/design/actions/create-design-from-template.spec.ts index 2b40eb0c67..5842595ae4 100644 --- a/templates/design/actions/create-design-from-template.spec.ts +++ b/templates/design/actions/create-design-from-template.spec.ts @@ -45,7 +45,8 @@ vi.mock("../server/db/index.js", () => { filename: "index.html", fileType: "html", content: - '
Brand

Editable

', + '' + + '
Brand

Editable

', }, ]; return { @@ -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"', ); diff --git a/templates/design/actions/create-design-from-template.ts b/templates/design/actions/create-design-from-template.ts index a9fff5c3bf..d542c3bc89 100644 --- a/templates/design/actions/create-design-from-template.ts +++ b/templates/design/actions/create-design-from-template.ts @@ -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"; @@ -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, @@ -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; diff --git a/templates/design/actions/get-design-snapshot.ts b/templates/design/actions/get-design-snapshot.ts index db4f92f4b4..7ca19d55a9 100644 --- a/templates/design/actions/get-design-snapshot.ts +++ b/templates/design/actions/get-design-snapshot.ts @@ -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 @@ -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 @@ -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, diff --git a/templates/design/actions/get-design-template.spec.ts b/templates/design/actions/get-design-template.spec.ts new file mode 100644 index 0000000000..ffcddcadd8 --- /dev/null +++ b/templates/design/actions/get-design-template.spec.ts @@ -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()), + 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: + '
Brand

Editable

', + }, + ], + }), + }), + }), +})); + +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"); + }); +}); diff --git a/templates/design/actions/get-design-template.ts b/templates/design/actions/get-design-template.ts new file mode 100644 index 0000000000..5edd7b0dfb --- /dev/null +++ b/templates/design/actions/get-design-template.ts @@ -0,0 +1,173 @@ +import { defineAction } from "@agent-native/core"; +import { resolveAccess } from "@agent-native/core/sharing"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { + parseDesignTemplateData, + readDesignTemplateSource, + templateFileDimensions, +} from "../server/lib/design-template-data.js"; +import { getDesignTemplatePreset } from "../shared/design-template-presets.js"; +import { lockedLayerSnapshots } from "../shared/locked-layers.js"; + +interface OriginalTemplateFile { + templateFileId: string; + filename: string; + fileType: string; + content: string; + width: number | null; + height: number | null; +} + +export default defineAction({ + description: + "Read the ORIGINAL, unmodified template a design was created from. " + + "Copied template screens are edited in place, so the design's own files stop showing what the template looked like as soon as the first refinement lands. " + + "Call this on any follow-up request against a template-created design to recover the template's exact canvas dimensions, typography, and markup before editing. " + + "Pass designId to resolve the template behind an open design, or templateId to read a template directly. Read-only.", + schema: z + .object({ + designId: z + .string() + .optional() + .describe("Design created from a template; resolves its template"), + templateId: z + .string() + .optional() + .describe("Template to read directly when the design id is unknown"), + }) + .refine((input) => input.designId || input.templateId, { + message: "Pass designId or templateId", + }), + readOnly: true, + http: { method: "GET" }, + run: async ({ designId, templateId }) => { + let resolvedTemplateId = templateId; + let designFileIdByTemplateFileId = new Map(); + let instantiatedAt: string | null = null; + + if (designId) { + const designAccess = await resolveAccess("design", designId); + if (!designAccess) throw new Error("Design not found"); + const design = + designAccess.resource as typeof schema.designs.$inferSelect; + const source = readDesignTemplateSource( + parseDesignTemplateData(design.data), + ); + if (!source) { + return { + designId, + fromTemplate: false as const, + message: + "This design was not created from a template; there is no original template to compare against.", + }; + } + if (templateId && templateId !== source.templateId) { + throw new Error( + `Design ${designId} was created from template "${source.templateId}", not "${templateId}"`, + ); + } + resolvedTemplateId = source.templateId; + instantiatedAt = source.instantiatedAt; + designFileIdByTemplateFileId = new Map( + source.files.map( + (file) => [file.templateFileId, file.designFileId] as const, + ), + ); + } + + if (!resolvedTemplateId) throw new Error("Pass designId or templateId"); + + const preset = getDesignTemplatePreset(resolvedTemplateId); + let title: string; + let category: string; + let description: string | null; + let templateDesignSystemId: string | null; + let files: OriginalTemplateFile[]; + + if (preset) { + title = preset.title; + category = preset.category; + description = preset.description; + templateDesignSystemId = null; + files = [ + { + templateFileId: `file:${preset.id}`, + filename: preset.filename, + fileType: "html", + content: preset.content, + width: preset.width, + height: preset.height, + }, + ]; + } else { + const access = await resolveAccess("design-template", resolvedTemplateId); + if (!access) throw new Error("Template not found"); + const template = access.resource; + title = String(template.title ?? "Untitled template"); + category = String(template.category ?? "other"); + description = + typeof template.description === "string" ? template.description : null; + templateDesignSystemId = + typeof template.designSystemId === "string" + ? template.designSystemId + : null; + + const templateData = parseDesignTemplateData( + typeof template.data === "string" ? template.data : "{}", + ); + const fallbackWidth = + typeof template.width === "number" ? template.width : null; + const fallbackHeight = + typeof template.height === "number" ? template.height : null; + + const rows = await getDb() + .select({ + id: schema.designTemplateFiles.id, + filename: schema.designTemplateFiles.filename, + fileType: schema.designTemplateFiles.fileType, + content: schema.designTemplateFiles.content, + }) + .from(schema.designTemplateFiles) + .where(eq(schema.designTemplateFiles.templateId, resolvedTemplateId)); + + files = rows.map((row) => { + const frame = templateFileDimensions(templateData, row.id); + return { + templateFileId: row.id, + filename: row.filename, + fileType: row.fileType, + content: row.content, + width: frame.width ?? fallbackWidth, + height: frame.height ?? fallbackHeight, + }; + }); + } + + return { + templateId: resolvedTemplateId, + title, + description, + category, + designSystemId: templateDesignSystemId, + isBuiltIn: Boolean(preset), + ...(designId + ? { designId, fromTemplate: true as const, instantiatedAt } + : {}), + files: files.map((file) => ({ + ...file, + designFileId: + designFileIdByTemplateFileId.get(file.templateFileId) ?? null, + lockedLayers: lockedLayerSnapshots(file.content).map((layer) => ({ + nodeId: layer.id, + layerName: layer.label, + })), + })), + fileCount: files.length, + nextRequiredAction: + "Treat these dimensions, fonts, and locked layers as authoritative. Apply the user's request with edit-design on the design's own files; do not resize the artboard or call generate-design.", + }; + }, +}); diff --git a/templates/design/actions/view-screen.ts b/templates/design/actions/view-screen.ts index ba8d428246..28712f6d8e 100644 --- a/templates/design/actions/view-screen.ts +++ b/templates/design/actions/view-screen.ts @@ -28,6 +28,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { readDesignTemplateSource } from "../server/lib/design-template-data.js"; import { parseCanvasFrameGeometryById } from "../shared/canvas-frames.js"; import { getDesignTemplatePreset } from "../shared/design-template-presets.js"; import { designGenerationSessionKey } from "../shared/generation-session.js"; @@ -331,6 +332,36 @@ export default defineAction({ activeCodeFile: resolveActiveCodeFile(files, designSelection), canvasFrames: parseCanvasFrameGeometryById(data.canvasFrames), }; + try { + const templateSource = readDesignTemplateSource(data); + if (templateSource) { + (screen.design as Record).createdFromTemplate = { + templateId: templateSource.templateId, + title: templateSource.title, + category: templateSource.category, + instantiatedAt: templateSource.instantiatedAt, + designSystemId: templateSource.appliedDesignSystemId, + lockedDimensions: templateSource.files.map((file) => ({ + designFileId: file.designFileId, + filename: file.filename, + width: file.width, + height: file.height, + })), + lockedFonts: templateSource.fonts, + note: + "The screens below are edited copies of this template, so their current content no longer shows what the template specified. " + + "The dimensions and fonts above come from the template and stay authoritative for every request, including this one: keep each screen at exactly those dimensions and keep those font families. " + + "Do not resize the artboard, change canvasFrames width or height, switch the primary viewport, or substitute a typeface to fit new content. " + + `Refine with edit-design; do not call generate-design. Call \`get-design-template --designId="${designId}"\` when you need the template's original markup or locked layers.`, + }; + } + } catch (error) { + (screen.design as Record).createdFromTemplate = { + unreadable: + error instanceof Error ? error.message : "unknown parse failure", + note: "This design claims a template that could not be read. Ask the user which template it came from instead of editing dimensions or typography.", + }; + } const proposalPrefix = `${DESIGN_REPROMPT_PROPOSAL_STATE_PREFIX}${designId}:`; const pendingPrefix = `${DESIGN_REPROMPT_PENDING_STATE_PREFIX}${designId}:`; const [proposalEntries, pendingEntries] = await Promise.all([ diff --git a/templates/design/app/pages/design-editor/generation-prompt-directives.ts b/templates/design/app/pages/design-editor/generation-prompt-directives.ts index a75eeb9c0a..a936336f01 100644 --- a/templates/design/app/pages/design-editor/generation-prompt-directives.ts +++ b/templates/design/app/pages/design-editor/generation-prompt-directives.ts @@ -182,6 +182,7 @@ export function designTemplateRefinementDirectives( `This design was copied from template "${templateId}". Its files, canvas dimensions, defaults, and locked layers already exist.`, ...designSystemTemplateEditDirectives(designSystemId), `Call \`get-design-snapshot --designId="${designId}"\` exactly once before editing.`, + `The copied screens are edited in place, so they stop showing the template once this run saves. \`view-screen\` reports the template's authoritative dimensions and fonts as \`design.createdFromTemplate\` on every turn — keep them unchanged. Call \`get-design-template --designId="${designId}"\` when you need the template's original markup or locked layers.`, "Refine the existing template with `edit-design`; do not call `generate-design`, `delete-file`, or create a replacement screen.", 'Layers marked `data-agent-native-locked="true"` and everything inside them must remain byte-for-byte unchanged. The server rejects changes to locked backgrounds, logos, and other fixed template layers.', "Preserve canvasFrames and the template's width and height. Change only the unlocked content needed for the user's request.", diff --git a/templates/design/server/lib/design-template-data.ts b/templates/design/server/lib/design-template-data.ts index 760de02ce2..b40088f295 100644 --- a/templates/design/server/lib/design-template-data.ts +++ b/templates/design/server/lib/design-template-data.ts @@ -23,6 +23,123 @@ export function parseDesignTemplateData( } } +export interface DesignTemplateSourceFile { + designFileId: string; + templateFileId: string; + filename: string | null; + width: number | null; + height: number | null; +} + +export interface DesignTemplateSource { + templateId: string; + title: string | null; + category: string | null; + instantiatedAt: string | null; + appliedDesignSystemId: string | null; + /** + * Captured at copy time, not re-read from the template. Dimensions and fonts + * are small enough to restate on every turn, which is what stops a follow-up + * request from resizing the artboard or swapping the typeface. The full + * template markup stays behind `get-design-template`. + */ + files: DesignTemplateSourceFile[]; + fonts: string[]; +} + +const MAX_TRACKED_FONTS = 12; + +/** + * Font drift is invisible in a layout diff and is the most common way a + * refined template stops looking like its template. Capture the declared + * families once so later turns can restate them without re-parsing markup. + */ +export function extractTemplateFonts(html: string): string[] { + const fonts = new Set(); + + for (const match of html.matchAll(/font-family\s*:\s*([^;}"']+)/gi)) { + const family = match[1] + ?.split(",")[0] + ?.trim() + .replace(/^["']|["']$/g, ""); + if (family && !/^(inherit|initial|unset|var\()/i.test(family)) { + fonts.add(family); + } + } + for (const link of html.matchAll(/fonts\.googleapis\.com\/[^"'\s>]+/gi)) { + for (const family of link[0].matchAll(/family=([^&:"']+)/gi)) { + const name = decodeURIComponent(family[1]!).replace(/\+/g, " ").trim(); + if (name) fonts.add(name); + } + } + + return [...fonts].slice(0, MAX_TRACKED_FONTS); +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) + ? Math.round(value) + : null; +} + +/** + * Reads the template a design was created from. Returns null when the design + * was not created from a template; throws when the design claims a template + * but the record cannot be read, because silently treating that as "no + * template" would drop the template constraints the design is bound to. + */ +export function readDesignTemplateSource( + data: Record, +): DesignTemplateSource | null { + const raw = data.templateSource; + if (raw === undefined || raw === null) return null; + + const source = record(raw); + const templateId = source.templateId; + if (typeof templateId !== "string" || !templateId.trim()) { + throw new Error( + "Design records a templateSource without a readable templateId", + ); + } + + return { + templateId, + title: typeof source.title === "string" ? source.title : null, + category: typeof source.category === "string" ? source.category : null, + instantiatedAt: + typeof source.instantiatedAt === "string" ? source.instantiatedAt : null, + appliedDesignSystemId: + typeof source.appliedDesignSystemId === "string" + ? source.appliedDesignSystemId + : null, + files: (Array.isArray(source.files) ? source.files : []).flatMap( + (entry) => { + const file = record(entry); + const designFileId = file.designFileId; + const templateFileId = file.templateFileId; + if ( + typeof designFileId !== "string" || + typeof templateFileId !== "string" + ) { + return []; + } + return [ + { + designFileId, + templateFileId, + filename: typeof file.filename === "string" ? file.filename : null, + width: finiteNumber(file.width), + height: finiteNumber(file.height), + }, + ]; + }, + ), + fonts: (Array.isArray(source.fonts) ? source.fonts : []).filter( + (font): font is string => typeof font === "string", + ), + }; +} + /** * Templates are portable/shareable snapshots, so they must never retain * localhost bridge credentials. Reuse the same viewer-safe redaction applied @@ -68,6 +185,27 @@ export function remapTemplateFileIds( return next; } +/** + * Exact frame lookup for one file. Unlike `firstTemplateDimensions` this never + * falls back to another screen's frame: a screen with no recorded frame must + * report "unknown", not a neighbour's dimensions. + */ +export function templateFileDimensions( + data: Record, + fileId: string, +): { width: number | null; height: number | null } { + const frame = record(record(data.canvasFrames)[fileId]) as CanvasFrame; + const width = + typeof frame.width === "number" && Number.isFinite(frame.width) + ? Math.round(frame.width) + : null; + const height = + typeof frame.height === "number" && Number.isFinite(frame.height) + ? Math.round(frame.height) + : null; + return { width, height }; +} + export function firstTemplateDimensions( data: Record, preferredFileId?: string, diff --git a/templates/design/server/lib/design-template-source.test.ts b/templates/design/server/lib/design-template-source.test.ts new file mode 100644 index 0000000000..1b8ae750ea --- /dev/null +++ b/templates/design/server/lib/design-template-source.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + extractTemplateFonts, + firstTemplateDimensions, + readDesignTemplateSource, + templateFileDimensions, +} from "./design-template-data.js"; + +describe("design template source", () => { + it("reports a missing frame as unknown instead of borrowing a sibling frame", () => { + const data = { canvasFrames: { a: { width: 1080, height: 1080 } } }; + + expect(templateFileDimensions(data, "a")).toEqual({ + width: 1080, + height: 1080, + }); + expect(templateFileDimensions(data, "b")).toEqual({ + width: null, + height: null, + }); + // The older helper deliberately falls back, which is why the baseline uses + // the exact lookup instead. + expect(firstTemplateDimensions(data, "b")).toEqual({ + width: 1080, + height: 1080, + }); + }); + + it("extracts declared and linked font families", () => { + const fonts = extractTemplateFonts( + [ + '', + "", + ].join(""), + ); + + expect(fonts).toEqual(["Sora", "Playfair Display"]); + }); + + it("separates a design with no template from one that is unreadable", () => { + expect(readDesignTemplateSource({})).toBeNull(); + expect(() => + readDesignTemplateSource({ templateSource: { title: "Campaign" } }), + ).toThrow("readable templateId"); + }); + + it("reads the captured dimensions and fonts a later turn depends on", () => { + const source = readDesignTemplateSource({ + templateSource: { + templateId: "saved-template", + files: [ + { + designFileId: "copied", + templateFileId: "original", + filename: "index.html", + width: 1080, + height: 1080, + }, + { designFileId: "missing-template-file" }, + ], + fonts: ["Sora", 7], + }, + }); + + expect(source?.files).toEqual([ + { + designFileId: "copied", + templateFileId: "original", + filename: "index.html", + width: 1080, + height: 1080, + }, + ]); + expect(source?.fonts).toEqual(["Sora"]); + }); + + it("reads a design copied before the baseline was captured", () => { + const source = readDesignTemplateSource({ + templateSource: { templateId: "saved-template", title: "Campaign" }, + }); + + expect(source).toMatchObject({ templateId: "saved-template" }); + expect(source?.files).toEqual([]); + expect(source?.fonts).toEqual([]); + }); +});