Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion docs/GUIDELINES.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
7 changes: 4 additions & 3 deletions src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'),
Expand All@@ -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 };

Expand All@@ -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);
}

Expand Down
75 changes: 47 additions & 28 deletions src/screenshot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<void> {
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<string> {
const { width = 1440, height = 900, scale = 2, nodeId, fullPage = false } = options;
const b = await getBrowser();
Expand All@@ -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;

Expand DownExpand Up@@ -221,17 +233,24 @@ 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<string> {
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();

try {
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 });

Expand Down
13 changes: 12 additions & 1 deletion src/structures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(),
},
};
}

Expand Down
33 changes: 31 additions & 2 deletions test-render-capture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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);
24 changes: 24 additions & 0 deletions test-set-genre.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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);
Loading