diff --git a/README.md b/README.md index 12977aa..d17790c 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ Render canvas to PNG (returned as base64 image). | `nodeId` | string? | Specific node to capture | | `width` | number? | Viewport width (default 1440) | | `height` | number? | Viewport height (default 900) | -| `fullPage` | boolean? | Capture the whole design instead of one viewport — default false, so existing captures (diffs, exports, responsive sets, the pattern gate) are byte-identical. A canvas taller than its artboard otherwise cuts off at the artboard height; before this, the only way to see the rest was editing the root height by hand | +| `fullPage` | boolean? | Capture the whole design instead of one viewport — default false, so existing captures (diffs, responsive sets, the pattern gate) are byte-identical. A canvas taller than its artboard otherwise cuts off at the artboard height. `export` takes the same option (ignored for PDF, which paginates) | | `scale` | number? | Device scale (default 2) | | `theme` | string? | `"dark"` renders the design system's dark token layer (`dark.colors`/`dark.elevation` overrides); default light — a no-op without a dark layer | diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 09026ed..799d72d 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -34,7 +34,7 @@ Pick the right `width` per node — this is the single biggest lever for respons **Default to fluid.** Reach for fixed pixel widths only when the content genuinely shouldn't scale. -**Artboard height is a viewport, not a limit.** The root's `height` sizes the default `screenshot` capture, but content taller than it is normal — it scrolls on a real page, and a `clip` finding from `canvas_stress` for exactly that case is `info`, not a problem to fix. To actually see past the artboard (a long page, a design that grew under stress content), pass `fullPage: true` to `screenshot` instead of editing the root height by hand. +**Artboard height is a viewport, not a limit.** The root's `height` sizes the default `screenshot` capture, but content taller than it is normal — it scrolls on a real page, and a `clip` finding from `canvas_stress` for exactly that case is `info`, not a problem to fix. To actually see past the artboard (a long page, a design that grew under stress content), pass `fullPage: true` to `screenshot` — or to `export`, which takes the same option — instead of editing the root height by hand. ## Responsive hints diff --git a/src/index.ts b/src/index.ts index e836dbc..6964a99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1292,7 +1292,7 @@ Fonts named in typography tokens load automatically at render time — you only // --- export --- server.tool( 'export', - 'Export a canvas or specific nodes to files (PNG, JPEG, WebP, PDF). Writes files to the specified output directory.', + 'Export a canvas or specific nodes to files (PNG, JPEG, WebP, PDF). Writes files to the specified output directory. Pass fullPage to capture a design taller than its artboard — the same option screenshot takes.', { canvasId: z.string().describe('Canvas ID'), format: z.enum(['png', 'jpeg', 'webp', 'pdf']).describe('Export format'), @@ -1302,8 +1302,9 @@ server.tool( height: z.number().optional().describe('Viewport height in pixels (default 900)'), scale: z.number().optional().describe('Device scale factor (default 2 for retina)'), theme: z.enum(['light', 'dark']).optional().describe('Render theme — "dark" applies the design system\'s dark token layer (dark.colors/dark.elevation overrides); default light. No-op when no dark layer exists.'), + fullPage: z.boolean().optional().describe('Capture the whole design rather than one viewport — the same option screenshot takes. A canvas taller than its artboard is otherwise cut off at the artboard height. Default false, so existing exports are unchanged. Ignored for PDF (which paginates) and when nodeIds are given.'), }, - async ({ canvasId, format, outputPath, nodeIds, width, height, scale, theme }) => { + async ({ canvasId, format, outputPath, nodeIds, width, height, scale, theme, fullPage }) => { const canvas = getCanvas(canvasId); if (!canvas) return { content: [{ type: 'text', text: 'Error: Canvas not found' }], isError: true }; @@ -1320,7 +1321,7 @@ server.tool( exportedFiles.push(filePath); } } else { - const filePath = await exportToFile(html, { width: w, height: h, scale, format, outputPath, fileName: canvas.name.replace(/\s+/g, '-').toLowerCase() }); + const filePath = await exportToFile(html, { width: w, height: h, scale, format, outputPath, fullPage, fileName: canvas.name.replace(/\s+/g, '-').toLowerCase() }); exportedFiles.push(filePath); } diff --git a/src/screenshot.ts b/src/screenshot.ts index 915d8a9..7a59815 100644 --- a/src/screenshot.ts +++ b/src/screenshot.ts @@ -77,6 +77,44 @@ export interface ScreenshotOptions { fullPage?: boolean; } +/** + * Grow the page to its real content height so a capture can exceed the artboard. + * + * A full-document capture has to relax the ARTBOARD, not just ask Puppeteer for + * fullPage. The root frame carries the canvas height as a fixed `height`, so + * taller content overflows it without extending the scrollable area and Chrome + * captures the artboard box either way. Turning that height into a floor on the + * root — and only the root, which is body's single child — lets the document + * grow to its real content height. Inner fixed heights are untouched. + * + * Puppeteer's own `fullPage` is NOT reliable here: it returns the full document + * on a fresh browser, then silently returns viewport-sized output for every + * later call once any non-fullPage capture has run in the same browser. + * framesmith keeps ONE browser for the whole session and takes many screenshots + * through it, so that is the normal case, not the edge — the flag would have + * appeared to work in a unit test and failed in use. Measuring the document and + * sizing the viewport to it is deterministic and owes nothing to that behaviour. + * + * Shared by takeScreenshot and exportToFile: `export` shipped without full-page + * support while `screenshot` had it, so saving a long design meant passing the + * height by hand. One helper means the two capture paths can't drift again. + */ +async function expandViewportToContent( + page: Page, + { width, height, scale }: { width: number; height: number; scale: number }, +): Promise { + await page.addStyleTag({ + content: `body > *:first-child { height: auto !important; min-height: ${height}px; }`, + }); + const contentHeight = await page.evaluate(() => Math.max( + document.body.scrollHeight, + document.documentElement.scrollHeight, + )); + if (contentHeight > height) { + await page.setViewport({ width, height: contentHeight, deviceScaleFactor: scale }); + } +} + export async function takeScreenshot(html: string, options: ScreenshotOptions = {}): Promise { const { width = 1440, height = 900, scale = 2, nodeId, fullPage = false } = options; const b = await getBrowser(); @@ -91,33 +129,7 @@ export async function takeScreenshot(html: string, options: ScreenshotOptions = await page.setContent(html, { waitUntil: 'domcontentloaded' }); - // A full-document capture has to relax the ARTBOARD, not just ask Puppeteer - // for fullPage. The root frame carries the canvas height as a fixed `height`, - // so taller content overflows it without extending the scrollable area and - // Chrome captures the artboard box either way. Turning that height into a - // floor on the root — and only the root, which is body's single child — lets - // the document grow to its real content height. Inner fixed heights are - // untouched. - if (fullPage && !nodeId) { - await page.addStyleTag({ - content: `body > *:first-child { height: auto !important; min-height: ${height}px; }`, - }); - // Puppeteer's own `fullPage` is NOT reliable here: it returns the full - // document on a fresh browser, then silently returns viewport-sized output - // for every later call once any non-fullPage capture has run in the same - // browser. framesmith keeps ONE browser for the whole session and takes - // many screenshots through it, so that is the normal case, not the edge — - // the flag would have appeared to work in a unit test and failed in use. - // Measuring the document and sizing the viewport to it is deterministic - // and owes nothing to that behaviour. - const contentHeight = await page.evaluate(() => Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight, - )); - if (contentHeight > height) { - await page.setViewport({ width, height: contentHeight, deviceScaleFactor: scale }); - } - } + if (fullPage && !nodeId) await expandViewportToContent(page, { width, height, scale }); let screenshotBuffer: Uint8Array; @@ -221,10 +233,12 @@ export interface ExportOptions { outputPath: string; nodeId?: string; fileName?: string; + /** Capture the whole design rather than one viewport (see takeScreenshot). */ + fullPage?: boolean; } export async function exportToFile(html: string, options: ExportOptions): Promise { - const { width = 1440, height = 900, scale = 2, format, outputPath, nodeId, fileName } = options; + const { width = 1440, height = 900, scale = 2, format, outputPath, nodeId, fileName, fullPage = false } = options; const b = await getBrowser(); const page = await b.newPage(); @@ -232,6 +246,11 @@ export async function exportToFile(html: string, options: ExportOptions): Promis await page.setViewport({ width, height, deviceScaleFactor: scale }); await page.setContent(html, { waitUntil: 'domcontentloaded' }); + // PDF paginates on its own; growing the viewport would fight that. + if (fullPage && !nodeId && format !== 'pdf') { + await expandViewportToContent(page, { width, height, scale }); + } + const dir = resolve(outputPath); await mkdir(dir, { recursive: true }); diff --git a/src/structures.ts b/src/structures.ts index 7d3f372..a1fc659 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -1668,9 +1668,20 @@ export function applyStructure( // Provenance into the open metadata bag (C3). `preset` is filled later by // apply_preset (T7); `seed` is reserved (C6). + // MERGE, don't replace. A canvas may already carry a genre stamp from + // canvas_set_genre (metadata.provenance.preset), and replacing the whole + // provenance object silently discarded it — declaring `commerce` and THEN + // stamping a layout, which is the order the docs encourage, left the canvas + // with no genre at all and its prices flagged as fabricated. apply_preset + // has always spread the existing provenance here; this now matches it. canvas.metadata = { ...canvas.metadata, - provenance: { structure: structure.name, axes: structure.axes, at: new Date().toISOString() }, + provenance: { + ...canvas.metadata?.provenance, + structure: structure.name, + axes: structure.axes, + at: new Date().toISOString(), + }, }; } diff --git a/test-render-capture.ts b/test-render-capture.ts index fb2d519..0cd19d0 100644 --- a/test-render-capture.ts +++ b/test-render-capture.ts @@ -17,11 +17,13 @@ import './test-env.js'; * * Needs Chrome for the capture case. Run with: npx tsx test-render-capture.ts */ -import { readFileSync } from 'node:fs'; +import { readFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createCanvas, getCanvas } from './src/scene-graph.js'; import { parseAndExecute } from './src/operations.js'; import { renderToHtml } from './src/renderer.js'; -import { takeScreenshot, shutdown } from './src/screenshot.js'; +import { takeScreenshot, exportToFile, shutdown } from './src/screenshot.js'; let allPass = true; function check(name: string, cond: boolean, extra?: string) { @@ -87,6 +89,33 @@ c=I("document", {type:"frame", width:"100%", height:500, fill:"#94A3B8"}) check('a repeat full capture through the same browser still works', a.h >= 1500, `${a.w}x${a.h}`); } +// ── 3. export captures the whole design too ────────────────────────────────── +{ + // `fullPage` shipped on `screenshot` and not on `export`, so saving a design + // taller than its artboard still meant passing the height by hand. Both now + // share one helper; this pins that they agree. + const c = createCanvas('tall-export'); + parseAndExecute(c.root, ` +U("document", {width:800, height:600, layout:"vertical", gap:0, fill:"#FFFFFF"}) +a=I("document", {type:"frame", width:"100%", height:500, fill:"#E2E8F0"}) +b=I("document", {type:"frame", width:"100%", height:500, fill:"#CBD5E1"}) +c=I("document", {type:"frame", width:"100%", height:500, fill:"#94A3B8"}) +`, c); + const cv = getCanvas(c.id)!; + const html = renderToHtml(cv.root, 800, 600, cv); + const dir = mkdtempSync(join(tmpdir(), 'framesmith-export-')); + + const viewportPath = await exportToFile(html, { width: 800, height: 600, scale: 1, format: 'png', outputPath: dir, fileName: 'viewport' }); + const wholePath = await exportToFile(html, { width: 800, height: 600, scale: 1, format: 'png', outputPath: dir, fileName: 'whole', fullPage: true }); + + const dims = (p: string) => { const b = readFileSync(p); return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }; }; + const v = dims(viewportPath); + const f = dims(wholePath); + check('export default is still exactly the artboard', v.w === 800 && v.h === 600, `${v.w}x${v.h}`); + check('export fullPage reaches all 1500px of content', f.h >= 1500, `${f.w}x${f.h}`); + check('export width is unaffected either way', f.w === v.w); +} + console.log(allPass ? '\nAll render-capture tests passed.' : '\nSOME TESTS FAILED'); await shutdown(); process.exit(allPass ? 0 : 1); diff --git a/test-set-genre.ts b/test-set-genre.ts index 718e5b3..bd78838 100644 --- a/test-set-genre.ts +++ b/test-set-genre.ts @@ -8,6 +8,7 @@ import './test-env.js'; import { createCanvas, setCanvasGenre, getCanvas } from './src/scene-graph.js'; +import { applyStructure } from './src/structures.js'; import { parseAndExecute } from './src/operations.js'; import { canvasVersionHash } from './src/version.js'; import { evaluateCanvas, relaxedByGenre, knownGenres } from './src/evaluate.js'; @@ -70,5 +71,28 @@ check('relaxedByGenre: checkout aliases commerce', relaxedByGenre('checkout').jo // Missing canvas → undefined. check('unknown canvas → undefined', setCanvasGenre('nope', 'dashboard') === undefined); +// ── stamping a page structure must not erase the genre ─────────────────────── +{ + // Found by building the v2.1.0 checkout example. `canvas_set_genre` writes + // metadata.provenance.preset; `applyStructure` then recorded its own + // provenance by REPLACING the object, so declaring a genre and then stamping + // a layout — the order the docs encourage — silently dropped the genre and + // the screen's prices went back to being flagged as fabricated. It failed + // silently: nothing errored, the evaluation just reported genre.active null. + const canvas = createCanvas('genre-then-structure'); + setCanvasGenre(canvas.id, 'commerce'); + applyStructure(canvas, 'settings'); + const after = getCanvas(canvas.id)!; + const prov = after.metadata?.provenance as { preset?: string; structure?: string } | undefined; + + check('the genre survives a page-structure stamp', prov?.preset === 'commerce', `preset=${prov?.preset}`); + check('...and the structure is recorded alongside it', prov?.structure === 'settings', `structure=${prov?.structure}`); + + // The evaluator is what actually consumes it — assert the end effect, not + // just the metadata shape. + const r = await evaluateCanvas(after, { mode: 'fast', categories: ['cliche'] }); + check('...so the evaluator still sees the genre', r.genre?.active === 'commerce', JSON.stringify(r.genre?.active)); +} + console.log(allPass ? '\nAll set-genre tests passed.' : '\nSOME TESTS FAILED'); process.exit(allPass ? 0 : 1);