From 8194dd427617aa94aeee2c92aed15132c680e6c3 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:14:24 +0700 Subject: [PATCH 1/2] feat: prebuilt device chrome for MCP-rendered screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composite iOS / Android device chrome (status bar, dynamic island, home indicator, gesture pill) onto every Satori-rendered screen so MCP-driven flows look like the device they target without the agent authoring chrome HTML by hand. Two new tools (compose_chrome, get_chrome_info) let agents chrome retroactively and query safe-area before laying out HTML. Persists a {preset, chrome, chromeStyle, safeArea} block on every screen and bumps the file format v14 -> v15 with a one-line backfill. Reduces device presets from 7 SKU-named variants to 2 generics (iphone, android) — agents now think in terms of "what device" not "which model", and chrome geometry keys off these two canonical viewports. Adds 79 tests (chrome subsystem + renderer integration + tool wiring + v15 migration). Repo total: 780/780 passing, lint clean, no new build warnings, no new runtime dependencies. --- .../__tests__/satori-renderer.chrome.test.js | 134 +++++++++ .../renderer/chrome/__tests__/index.test.js | 226 ++++++++++++++ .../chrome/__tests__/variants.test.js | 113 +++++++ mcp-server/src/renderer/chrome/defaults.js | 24 ++ mcp-server/src/renderer/chrome/geometry.js | 39 +++ mcp-server/src/renderer/chrome/glyphs.js | 37 +++ mcp-server/src/renderer/chrome/index.js | 224 ++++++++++++++ mcp-server/src/renderer/chrome/registry.js | 26 ++ .../chrome/variants/android-gesture-pill.js | 35 +++ .../chrome/variants/dynamic-island.js | 30 ++ .../chrome/variants/home-indicator.js | 36 +++ .../chrome/variants/status-bar-android.js | 45 +++ .../chrome/variants/status-bar-ios.js | 49 ++++ mcp-server/src/renderer/device-presets.js | 53 +++- mcp-server/src/renderer/satori-renderer.js | 152 +++++++++- mcp-server/src/state.js | 3 + .../__tests__/screen-tools.chrome.test.js | 277 ++++++++++++++++++ mcp-server/src/tools/screen-tools.js | 228 +++++++++++++- src/constants.js | 2 +- src/hooks/useScreenManager.js | 1 + src/pages/docs/userGuide.md | 29 +- src/stubs/fs.js | 3 + src/utils/buildPayload.test.js | 4 +- src/utils/importFlow.js | 3 + src/utils/importFlow.test.js | 30 +- vite.config.js | 19 +- 26 files changed, 1773 insertions(+), 49 deletions(-) create mode 100644 mcp-server/src/renderer/__tests__/satori-renderer.chrome.test.js create mode 100644 mcp-server/src/renderer/chrome/__tests__/index.test.js create mode 100644 mcp-server/src/renderer/chrome/__tests__/variants.test.js create mode 100644 mcp-server/src/renderer/chrome/defaults.js create mode 100644 mcp-server/src/renderer/chrome/geometry.js create mode 100644 mcp-server/src/renderer/chrome/glyphs.js create mode 100644 mcp-server/src/renderer/chrome/index.js create mode 100644 mcp-server/src/renderer/chrome/registry.js create mode 100644 mcp-server/src/renderer/chrome/variants/android-gesture-pill.js create mode 100644 mcp-server/src/renderer/chrome/variants/dynamic-island.js create mode 100644 mcp-server/src/renderer/chrome/variants/home-indicator.js create mode 100644 mcp-server/src/renderer/chrome/variants/status-bar-android.js create mode 100644 mcp-server/src/renderer/chrome/variants/status-bar-ios.js create mode 100644 mcp-server/src/tools/__tests__/screen-tools.chrome.test.js diff --git a/mcp-server/src/renderer/__tests__/satori-renderer.chrome.test.js b/mcp-server/src/renderer/__tests__/satori-renderer.chrome.test.js new file mode 100644 index 0000000..6f1679c --- /dev/null +++ b/mcp-server/src/renderer/__tests__/satori-renderer.chrome.test.js @@ -0,0 +1,134 @@ +// @vitest-environment node +// +// Override the project-wide jsdom env: this file imports the real Satori +// renderer, which transitively pulls in node:fs/promises (emoji-loader). +// jsdom's import analyser refuses unknown node: protocols. + +import { describe, it, expect, beforeAll } from "vitest"; +import { SatoriRenderer } from "../satori-renderer.js"; + +// End-to-end integration test for Phase 2 (renderer integration). +// +// These exercise the real Satori → composeChromeSvg → Resvg pipeline. +// They are slower than the pure-function tests in chrome/__tests__/ but +// catch regressions the unit tests can't (missing fonts in Resvg, broken +// SVG composition, etc.). + +const SIMPLE_HTML = + '
Hello
'; + +const SIMPLE_ANDROID_HTML = + '
Hello
'; + +describe("SatoriRenderer chrome composition", () => { + let renderer; + + beforeAll(async () => { + renderer = new SatoriRenderer(); + await renderer.init(); + }); + + it("renders an iPhone screen with auto chrome at the device 2x dimensions", async () => { + const result = await renderer.render(SIMPLE_HTML, { device: "iphone", chrome: "auto" }); + expect(result.width).toBe(786); // 393 × 2 + expect(result.height).toBe(1704); // 852 × 2 + expect(result.device).toBe("iphone"); + expect(result.chrome).toEqual([ + "status-bar-ios", + "dynamic-island", + "home-indicator", + ]); + expect(result.chromeStyle).toBe("light"); + expect(result.safeArea).toEqual({ top: 59, bottom: 34, left: 0, right: 0 }); + expect(result.chromeRenderError).toBeUndefined(); + // svgString should now contain chrome group ids + expect(result.svgString).toContain("chrome-status-bar-ios"); + expect(result.svgString).toContain("chrome-dynamic-island"); + expect(result.svgString).toContain("chrome-home-indicator"); + // PNG buffer should be non-trivial + expect(result.pngBuffer.length).toBeGreaterThan(1000); + }); + + it("renders an Android screen with auto chrome", async () => { + const result = await renderer.render(SIMPLE_ANDROID_HTML, { device: "android", chrome: "auto" }); + expect(result.width).toBe(824); // 412 × 2 + expect(result.height).toBe(1830); // 915 × 2 + expect(result.device).toBe("android"); + expect(result.chrome).toEqual(["status-bar-android", "android-gesture-pill"]); + expect(result.safeArea).toEqual({ top: 36, bottom: 16, left: 0, right: 0 }); + expect(result.svgString).toContain("chrome-status-bar-android"); + expect(result.svgString).toContain("chrome-android-gesture-pill"); + }); + + it("respects chrome: false (no chrome composited, zero safeArea)", async () => { + const result = await renderer.render(SIMPLE_HTML, { device: "iphone", chrome: false }); + expect(result.chrome).toEqual([]); + expect(result.safeArea).toEqual({ top: 0, bottom: 0, left: 0, right: 0 }); + expect(result.svgString).not.toContain("chrome-status-bar-ios"); + }); + + it("respects an explicit chrome subset", async () => { + const result = await renderer.render(SIMPLE_HTML, { + device: "iphone", + chrome: ["status-bar-ios"], + }); + expect(result.chrome).toEqual(["status-bar-ios"]); + expect(result.safeArea).toEqual({ top: 54, bottom: 0, left: 0, right: 0 }); + expect(result.svgString).toContain("chrome-status-bar-ios"); + expect(result.svgString).not.toContain("chrome-dynamic-island"); + expect(result.svgString).not.toContain("chrome-home-indicator"); + }); + + it("chromeStyle: dark uses the dark palette", async () => { + const result = await renderer.render(SIMPLE_HTML, { + device: "iphone", + chrome: ["home-indicator"], + chromeStyle: "dark", + }); + expect(result.chromeStyle).toBe("dark"); + expect(result.svgString).toContain('fill="#ffffff"'); + }); + + it("custom width/height (no device) skips chrome and returns zero safeArea", async () => { + const result = await renderer.render(SIMPLE_HTML, { width: 500, height: 500, chrome: "auto" }); + expect(result.device).toBe(null); + expect(result.chrome).toEqual([]); + expect(result.safeArea).toEqual({ top: 0, bottom: 0, left: 0, right: 0 }); + expect(result.width).toBe(1000); // 500 × 2 + }); + + it("composeChrome (universal path) wraps a base SVG and emits a PNG", async () => { + const baseSvg = + ''; + const result = await renderer.composeChrome({ + baseSvg, + device: "iphone", + chrome: "auto", + }); + expect(result.width).toBe(786); + expect(result.height).toBe(1704); + expect(result.device).toBe("iphone"); + expect(result.chrome).toEqual([ + "status-bar-ios", + "dynamic-island", + "home-indicator", + ]); + expect(result.chromeRenderError).toBeUndefined(); + expect(result.pngBuffer.length).toBeGreaterThan(1000); + }); + + it("composeChrome (universal path) wraps a base PNG dataUri", async () => { + // 1×1 transparent PNG + const dataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9X0HuBkAAAAASUVORK5CYII="; + const result = await renderer.composeChrome({ + baseImageDataUri: dataUri, + device: "android", + chrome: "auto", + }); + expect(result.device).toBe("android"); + expect(result.chrome).toEqual(["status-bar-android", "android-gesture-pill"]); + expect(result.svgString).toContain(' { + it('returns the iPhone auto-list for "auto"', () => { + expect(expandAutoChrome("auto", "iphone")).toEqual([ + "status-bar-ios", + "dynamic-island", + "home-indicator", + ]); + }); + + it('returns the Android auto-list for "auto"', () => { + expect(expandAutoChrome("auto", "android")).toEqual([ + "status-bar-android", + "android-gesture-pill", + ]); + }); + + it("treats undefined and null as auto", () => { + expect(expandAutoChrome(undefined, "iphone")).toEqual(AUTO_CHROME_BY_DEVICE.iphone); + expect(expandAutoChrome(null, "iphone")).toEqual(AUTO_CHROME_BY_DEVICE.iphone); + }); + + it("returns [] for explicit false (chrome disabled)", () => { + expect(expandAutoChrome(false, "iphone")).toEqual([]); + }); + + it("returns [] for empty array (chrome explicitly disabled)", () => { + expect(expandAutoChrome([], "iphone")).toEqual([]); + }); + + it("returns [] for auto on an unsupported device", () => { + expect(expandAutoChrome("auto", "windows-phone")).toEqual([]); + }); + + it("passes through an explicit array unchanged when valid", () => { + expect(expandAutoChrome(["status-bar-ios"], "iphone")).toEqual(["status-bar-ios"]); + }); + + it("rejects unknown chrome ids in an explicit array", () => { + expect(() => expandAutoChrome(["bogus"], "iphone")).toThrow(/Unknown chrome element/); + }); + + it("rejects conflicting chrome elements (device-agnostic check)", () => { + // No device → skip appliesTo gating, exercise the conflict rule directly. + expect(() => + expandAutoChrome(["status-bar-ios", "status-bar-android"], undefined) + ).toThrow(/conflict/); + }); + + it("rejects an element not applicable to the device before conflict checks", () => { + expect(() => + expandAutoChrome(["status-bar-ios", "status-bar-android"], "iphone") + ).toThrow(/does not apply/); + }); + + it("rejects an element not applicable to the device", () => { + expect(() => expandAutoChrome(["dynamic-island"], "android")).toThrow(/does not apply/); + }); + + it("throws on an unrecognised input shape (e.g. string other than 'auto')", () => { + expect(() => expandAutoChrome("yes-please", "iphone")).toThrow(/Invalid chrome value/); + }); +}); + +// ── validateChrome ──────────────────────────────────────────────────────────── + +describe("validateChrome", () => { + it("accepts an empty array", () => { + expect(() => validateChrome([], "iphone")).not.toThrow(); + }); + + it("rejects non-array input", () => { + expect(() => validateChrome("auto", "iphone")).toThrow(/must be an array/); + }); +}); + +// ── computeSafeArea ─────────────────────────────────────────────────────────── + +describe("computeSafeArea (max-by-edge)", () => { + it("returns zero safe-area for empty list", () => { + expect(computeSafeArea([], "iphone")).toEqual({ top: 0, bottom: 0, left: 0, right: 0 }); + }); + + it("for iPhone auto, top safeArea is dynamic-island's 59 (not status bar's 54)", () => { + const sa = computeSafeArea(AUTO_CHROME_BY_DEVICE.iphone, "iphone"); + expect(sa.top).toBe(59); + expect(sa.bottom).toBe(34); + expect(sa.left).toBe(0); + expect(sa.right).toBe(0); + }); + + it("for Android auto, contributes top: 36 and bottom: 16", () => { + const sa = computeSafeArea(AUTO_CHROME_BY_DEVICE.android, "android"); + expect(sa.top).toBe(36); + expect(sa.bottom).toBe(16); + }); +}); + +// ── composeChromeSvg ────────────────────────────────────────────────────────── + +describe("composeChromeSvg", () => { + const baseSvg = + ''; + + it("wraps a Satori base SVG with a content group and chrome group", () => { + const { svgString } = composeChromeSvg({ + baseSvg, + device: "iphone", + chrome: AUTO_CHROME_BY_DEVICE.iphone, + viewport: IPHONE_VIEWPORT, + }); + expect(svgString).toMatch(/^'); + expect(svgString).toContain(''); + expect(svgString).toContain("chrome-status-bar-ios"); + expect(svgString).toContain("chrome-dynamic-island"); + expect(svgString).toContain("chrome-home-indicator"); + }); + + it("wraps a base PNG dataUri (universal path)", () => { + const dataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9X0HuBkAAAAASUVORK5CYII="; + const { svgString } = composeChromeSvg({ + baseImageDataUri: dataUri, + device: "android", + chrome: AUTO_CHROME_BY_DEVICE.android, + viewport: ANDROID_VIEWPORT, + }); + expect(svgString).toContain(' { + const { safeArea } = composeChromeSvg({ + baseSvg, + device: "iphone", + chrome: ["status-bar-ios", "dynamic-island", "home-indicator"], + viewport: IPHONE_VIEWPORT, + }); + expect(safeArea).toEqual({ top: 59, bottom: 34, left: 0, right: 0 }); + }); + + it("rejects when neither baseSvg nor baseImageDataUri provided", () => { + expect(() => + composeChromeSvg({ device: "iphone", chrome: [], viewport: IPHONE_VIEWPORT }) + ).toThrow(/baseSvg or baseImageDataUri/); + }); + + it("rejects when both bases provided", () => { + expect(() => + composeChromeSvg({ + baseSvg, + baseImageDataUri: "data:image/png;base64,xxx", + device: "iphone", + chrome: [], + viewport: IPHONE_VIEWPORT, + }) + ).toThrow(/not both/); + }); + + it("rejects missing viewport", () => { + expect(() => + composeChromeSvg({ baseSvg, device: "iphone", chrome: [], viewport: null }) + ).toThrow(/viewport/); + }); + + it("propagates appliesTo errors through composition", () => { + expect(() => + composeChromeSvg({ + baseSvg, + device: "iphone", + chrome: ["status-bar-ios", "status-bar-android"], + viewport: IPHONE_VIEWPORT, + }) + ).toThrow(/does not apply/); + }); +}); + +// ── getChromeInfo ───────────────────────────────────────────────────────────── + +describe("getChromeInfo", () => { + it("with no args returns the full catalog", () => { + const info = getChromeInfo(); + expect(info.devices).toBeDefined(); + expect(info.devices.map((d) => d.device).sort()).toEqual([...SUPPORTED_DEVICES].sort()); + expect(info.elements.length).toBe(5); + const ids = info.elements.map((e) => e.id); + expect(ids).toContain("status-bar-ios"); + expect(ids).toContain("dynamic-island"); + }); + + it("with device only returns that device's auto chrome and safeArea", () => { + const info = getChromeInfo({ device: "iphone" }); + expect(info.device).toBe("iphone"); + expect(info.chrome).toEqual(AUTO_CHROME_BY_DEVICE.iphone); + expect(info.safeArea.top).toBe(59); + }); + + it("with device + explicit chrome returns the safeArea for that combo", () => { + const info = getChromeInfo({ device: "iphone", chrome: ["status-bar-ios"] }); + expect(info.chrome).toEqual(["status-bar-ios"]); + expect(info.safeArea).toEqual({ top: 54, bottom: 0, left: 0, right: 0 }); + }); + + it("rejects unknown device", () => { + expect(() => getChromeInfo({ device: "blackberry" })).toThrow(/Unknown device/); + }); +}); diff --git a/mcp-server/src/renderer/chrome/__tests__/variants.test.js b/mcp-server/src/renderer/chrome/__tests__/variants.test.js new file mode 100644 index 0000000..1338cf3 --- /dev/null +++ b/mcp-server/src/renderer/chrome/__tests__/variants.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { CHROME_ELEMENTS, CHROME_IDS } from "../registry.js"; +import { CHROME_GEOMETRY, getBounds } from "../geometry.js"; + +// One consolidated variant test file (consciously diverging from plan's +// "one test file per variant" — same coverage, lower file sprawl). + +describe("CHROME_GEOMETRY", () => { + it("has an entry for every registered chrome id", () => { + for (const id of CHROME_IDS) { + expect(CHROME_GEOMETRY[id]).toBeDefined(); + } + }); + + it("rectangles fit inside the viewport for their device", () => { + const viewports = { iphone: { w: 393, h: 852 }, android: { w: 412, h: 915 } }; + for (const [id, perDevice] of Object.entries(CHROME_GEOMETRY)) { + for (const [device, rect] of Object.entries(perDevice)) { + const vp = viewports[device]; + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.y).toBeGreaterThanOrEqual(0); + expect(rect.x + rect.w, `${id}/${device} overflows width`).toBeLessThanOrEqual(vp.w); + expect(rect.y + rect.h, `${id}/${device} overflows height`).toBeLessThanOrEqual(vp.h); + } + } + }); + + it("getBounds throws for unknown element", () => { + expect(() => getBounds("not-a-thing", "iphone")).toThrow(/Unknown chrome element/); + }); + + it("getBounds throws when device has no geometry for that element", () => { + expect(() => getBounds("status-bar-ios", "android")).toThrow(/no geometry for device/); + }); +}); + +describe("ChromeElement contract", () => { + it.each(CHROME_IDS)("%s exposes the full contract", (id) => { + const el = CHROME_ELEMENTS[id]; + expect(el.id).toBe(id); + expect(Array.isArray(el.appliesTo)).toBe(true); + expect(el.appliesTo.length).toBeGreaterThan(0); + expect(Array.isArray(el.conflicts)).toBe(true); + expect(typeof el.bounds).toBe("function"); + expect(typeof el.safeArea).toBe("function"); + expect(typeof el.render).toBe("function"); + }); + + it.each(CHROME_IDS)("%s render produces a non-empty SVG fragment for each chromeStyle", (id) => { + const el = CHROME_ELEMENTS[id]; + for (const device of el.appliesTo) { + for (const chromeStyle of ["light", "dark"]) { + const out = el.render({ device, chromeStyle }); + expect(out, `${id}/${device}/${chromeStyle} produced empty output`).toMatch(/ { + const el = CHROME_ELEMENTS[id]; + for (const device of el.appliesTo) { + const sa = el.safeArea({ device }); + const edges = ["top", "bottom", "left", "right"].filter((k) => sa[k] !== undefined); + expect(edges.length).toBeGreaterThan(0); + } + }); +}); + +describe("safeArea contributions match the plan table", () => { + it("status-bar-ios contributes top: 54", () => { + expect(CHROME_ELEMENTS["status-bar-ios"].safeArea({ device: "iphone" })).toEqual({ top: 54 }); + }); + it("dynamic-island contributes top: 59", () => { + expect(CHROME_ELEMENTS["dynamic-island"].safeArea({ device: "iphone" })).toEqual({ top: 59 }); + }); + it("home-indicator contributes bottom: 34", () => { + expect(CHROME_ELEMENTS["home-indicator"].safeArea({ device: "iphone" })).toEqual({ bottom: 34 }); + }); + it("status-bar-android contributes top: 36", () => { + expect(CHROME_ELEMENTS["status-bar-android"].safeArea({ device: "android" })).toEqual({ top: 36 }); + }); + it("android-gesture-pill contributes bottom: 16", () => { + expect(CHROME_ELEMENTS["android-gesture-pill"].safeArea({ device: "android" })).toEqual({ bottom: 16 }); + }); +}); + +describe("conflict declarations", () => { + it("status-bar-ios and status-bar-android are mutually exclusive (declared on at least one side)", () => { + const ios = CHROME_ELEMENTS["status-bar-ios"]; + const android = CHROME_ELEMENTS["status-bar-android"]; + const declared = + ios.conflicts.includes("status-bar-android") || + android.conflicts.includes("status-bar-ios"); + expect(declared).toBe(true); + }); +}); + +describe("colour palette branches", () => { + it("home-indicator uses different fills for light vs dark", () => { + const light = CHROME_ELEMENTS["home-indicator"].render({ device: "iphone", chromeStyle: "light" }); + const dark = CHROME_ELEMENTS["home-indicator"].render({ device: "iphone", chromeStyle: "dark" }); + expect(light).toContain("#000000"); + expect(dark).toContain("#ffffff"); + }); + + it("dynamic-island stays black regardless of chromeStyle", () => { + const light = CHROME_ELEMENTS["dynamic-island"].render({ device: "iphone", chromeStyle: "light" }); + const dark = CHROME_ELEMENTS["dynamic-island"].render({ device: "iphone", chromeStyle: "dark" }); + expect(light).toContain("#000000"); + expect(dark).toContain("#000000"); + }); +}); diff --git a/mcp-server/src/renderer/chrome/defaults.js b/mcp-server/src/renderer/chrome/defaults.js new file mode 100644 index 0000000..49298ae --- /dev/null +++ b/mcp-server/src/renderer/chrome/defaults.js @@ -0,0 +1,24 @@ +// Auto-expansion table: device → ordered list of chrome ids applied when +// `chrome === "auto"` (the default). +// +// This is the ONE place that says "iphone gets a status bar + island + home +// indicator". Everything else routes through expandAutoChrome() in index.js. + +export const AUTO_CHROME_BY_DEVICE = { + iphone: ["status-bar-ios", "dynamic-island", "home-indicator"], + android: ["status-bar-android", "android-gesture-pill"], +}; + +export const SUPPORTED_DEVICES = Object.keys(AUTO_CHROME_BY_DEVICE); + +export function isSupportedDevice(device) { + return SUPPORTED_DEVICES.includes(device); +} + +// Per-device validation helper: confirms that every id in `chromeList` is +// allowed on `device` (defers to registry-level appliesTo check in +// validateChrome). Kept here so callers can guard early without pulling +// the full chrome subsystem. +export function isAutoChrome(input) { + return input === undefined || input === null || input === "auto"; +} diff --git a/mcp-server/src/renderer/chrome/geometry.js b/mcp-server/src/renderer/chrome/geometry.js new file mode 100644 index 0000000..7126bc1 --- /dev/null +++ b/mcp-server/src/renderer/chrome/geometry.js @@ -0,0 +1,39 @@ +// Per-element × per-device chrome rectangles. +// All coordinates are CSS pixels in the device viewport coordinate space. +// This is the SINGLE SOURCE OF TRUTH for chrome positioning — all variant +// modules ask for their bounds here, and tests assert against these values. +// +// Adding a new device or element = add a row here + a variant module + a +// registry entry. No other file should hard-code chrome geometry. + +export const CHROME_GEOMETRY = { + "status-bar-ios": { + iphone: { x: 0, y: 0, w: 393, h: 54 }, + }, + "dynamic-island": { + iphone: { x: 134, y: 11, w: 125, h: 37 }, + }, + "home-indicator": { + iphone: { x: 130, y: 838, w: 134, h: 5 }, + }, + "status-bar-android": { + android: { x: 0, y: 0, w: 412, h: 36 }, + }, + "android-gesture-pill": { + android: { x: 130, y: 905, w: 152, h: 4 }, + }, +}; + +export function getBounds(elementId, device) { + const elem = CHROME_GEOMETRY[elementId]; + if (!elem) { + throw new Error(`Unknown chrome element: ${elementId}`); + } + const bounds = elem[device]; + if (!bounds) { + throw new Error( + `Chrome element "${elementId}" has no geometry for device "${device}"` + ); + } + return bounds; +} diff --git a/mcp-server/src/renderer/chrome/glyphs.js b/mcp-server/src/renderer/chrome/glyphs.js new file mode 100644 index 0000000..d9a56a0 --- /dev/null +++ b/mcp-server/src/renderer/chrome/glyphs.js @@ -0,0 +1,37 @@ +// Inline SVG glyph paths for status-bar icons. +// +// These intentionally do NOT depend on any glyph font: Resvg can rasterize +// system fonts inconsistently across platforms. Geometric primitives (rect, +// circle, path with arcs) are guaranteed to render identically everywhere. +// +// Each glyph is drawn relative to its own local origin (0,0). Callers wrap +// the fragment in a to position it. +// +// Color is set by the parent group's `fill` / `stroke` attributes; nothing +// here should hardcode a color. + +// 4 ascending signal bars in an 18×12 box. +export const SIGNAL_BARS_SVG = ` + + + + +`; + +// WiFi: three concentric arcs over a dot, in a 16×13 box. +// Stroke uses currentColor so it inherits the parent group's fill. +export const WIFI_SVG = ` + + + + + + +`; + +// Battery: rounded body (50% opacity outline) + tip + interior fill, 24×11. +export const BATTERY_SVG = ` + + + +`; diff --git a/mcp-server/src/renderer/chrome/index.js b/mcp-server/src/renderer/chrome/index.js new file mode 100644 index 0000000..4a3716d --- /dev/null +++ b/mcp-server/src/renderer/chrome/index.js @@ -0,0 +1,224 @@ +// Public API for the chrome subsystem. +// +// Three things callers need: +// 1. expandAutoChrome(input, device) — turn "auto" / explicit-array / false +// into the canonical list of chrome ids to render. +// 2. composeChromeSvg({...}) — produce a wrapper SVG that paints chrome on +// top of either Satori-produced inner SVG or a base PNG (universal path). +// 3. getChromeInfo({device, chrome}) — let agents query safe-area and the +// auto-expansion table BEFORE authoring HTML. + +import { CHROME_ELEMENTS, CHROME_IDS } from "./registry.js"; +import { + AUTO_CHROME_BY_DEVICE, + SUPPORTED_DEVICES, + isSupportedDevice, + isAutoChrome, +} from "./defaults.js"; + +export { + CHROME_ELEMENTS, + CHROME_IDS, + AUTO_CHROME_BY_DEVICE, + SUPPORTED_DEVICES, + isSupportedDevice, + isAutoChrome, +}; + +const ZERO_SAFE_AREA = Object.freeze({ top: 0, bottom: 0, left: 0, right: 0 }); + +/** + * Resolve the user's chrome request into a concrete ordered list of ids. + * + * @param {("auto"|false|string[]|null|undefined)} input + * @param {string} device — "iphone" | "android" + * @returns {string[]} canonical chrome id list (empty when chrome is disabled + * or device is unsupported). + */ +export function expandAutoChrome(input, device) { + if (input === false) return []; + if (isAutoChrome(input)) { + if (!isSupportedDevice(device)) return []; + return [...AUTO_CHROME_BY_DEVICE[device]]; + } + if (Array.isArray(input)) { + const list = [...input]; + validateChrome(list, device); + return list; + } + throw new Error( + `Invalid chrome value: ${JSON.stringify(input)}. Expected "auto", false, or an array of chrome ids.` + ); +} + +/** + * Validate a chrome list against device + conflict rules. Throws on the + * first violation — never returns a "soft" warning. + */ +export function validateChrome(chromeList, device) { + if (!Array.isArray(chromeList)) { + throw new Error("chrome must be an array of element ids"); + } + for (const id of chromeList) { + const elem = CHROME_ELEMENTS[id]; + if (!elem) { + throw new Error( + `Unknown chrome element: "${id}". Valid: ${CHROME_IDS.join(", ")}` + ); + } + if (device && !elem.appliesTo.includes(device)) { + throw new Error( + `Chrome element "${id}" does not apply to device "${device}". Applies to: ${elem.appliesTo.join(", ")}` + ); + } + } + for (let i = 0; i < chromeList.length; i++) { + const a = CHROME_ELEMENTS[chromeList[i]]; + for (let j = i + 1; j < chromeList.length; j++) { + const otherId = chromeList[j]; + if (a.conflicts.includes(otherId)) { + throw new Error( + `Chrome elements conflict: "${a.id}" and "${otherId}" cannot coexist.` + ); + } + } + } +} + +/** + * Max-by-edge composition of safeArea contributions. Returns a fresh object + * (never the frozen ZERO_SAFE_AREA). + */ +export function computeSafeArea(chromeList, device) { + if (!chromeList || chromeList.length === 0) { + return { ...ZERO_SAFE_AREA }; + } + return chromeList.reduce( + (acc, id) => { + const sa = CHROME_ELEMENTS[id].safeArea({ device }); + return { + top: Math.max(acc.top, sa.top ?? 0), + bottom: Math.max(acc.bottom, sa.bottom ?? 0), + left: Math.max(acc.left, sa.left ?? 0), + right: Math.max(acc.right, sa.right ?? 0), + }; + }, + { ...ZERO_SAFE_AREA } + ); +} + +function renderChromeFragments(chromeList, device, chromeStyle) { + return chromeList + .map((id) => CHROME_ELEMENTS[id].render({ device, chromeStyle })) + .join("\n"); +} + +// Extract the inner content of a satori-produced ... wrapper so +// we can re-wrap it together with the chrome layer in one outer . +function stripSvgWrapper(svgString) { + const match = svgString.match(/]*>([\s\S]*)<\/svg>\s*$/); + return match ? match[1] : svgString; +} + +/** + * Compose chrome onto a base layer. + * + * Two modes — choose ONE of baseSvg / baseImageDataUri: + * - baseSvg: inner content (or full ) from Satori. Stripped of its + * outer wrapper and re-inlined under . + * - baseImageDataUri: a "data:image/...;base64,..." string. Wrapped in + * at full viewport. Used by compose_chrome on + * uploaded / Figma-pasted screens. + * + * @param {Object} args + * @param {string} [args.baseSvg] + * @param {string} [args.baseImageDataUri] + * @param {string} args.device — "iphone" | "android" + * @param {string[]} args.chrome — already-expanded list of chrome ids + * @param {string} [args.chromeStyle="light"] + * @param {{width:number,height:number}} args.viewport + * @returns {{ svgString: string, safeArea: {top,bottom,left,right} }} + */ +export function composeChromeSvg({ + baseSvg, + baseImageDataUri, + device, + chrome, + chromeStyle = "light", + viewport, +}) { + if (!viewport || !viewport.width || !viewport.height) { + throw new Error("composeChromeSvg requires viewport with width and height"); + } + if (!baseSvg && !baseImageDataUri) { + throw new Error("composeChromeSvg requires either baseSvg or baseImageDataUri"); + } + if (baseSvg && baseImageDataUri) { + throw new Error("composeChromeSvg accepts baseSvg OR baseImageDataUri, not both"); + } + + validateChrome(chrome, device); + + const { width: w, height: h } = viewport; + const safeArea = computeSafeArea(chrome, device); + const chromeFragment = renderChromeFragments(chrome, device, chromeStyle); + + const baseLayer = baseSvg + ? stripSvgWrapper(baseSvg) + : ``; + + const svgString = + `` + + `${baseLayer}` + + `${chromeFragment}` + + ``; + + return { svgString, safeArea }; +} + +/** + * Agent-facing query: "what chrome will I get for this device, and what + * safe-area should my HTML respect?" + * + * Three call shapes: + * getChromeInfo({}) → catalog of devices + elements + * getChromeInfo({device}) → auto-chrome + safeArea for device + * getChromeInfo({device, chrome:["..."]}) → safeArea for an explicit set + */ +export function getChromeInfo({ device, chrome } = {}) { + if (device !== undefined && !isSupportedDevice(device)) { + throw new Error( + `Unknown device: "${device}". Supported: ${SUPPORTED_DEVICES.join(", ")}` + ); + } + if (device && chrome !== undefined) { + const expanded = expandAutoChrome(chrome, device); + return { + device, + chrome: expanded, + safeArea: computeSafeArea(expanded, device), + autoChrome: AUTO_CHROME_BY_DEVICE[device], + }; + } + if (device) { + const expanded = AUTO_CHROME_BY_DEVICE[device]; + return { + device, + chrome: expanded, + safeArea: computeSafeArea(expanded, device), + autoChrome: expanded, + }; + } + return { + devices: SUPPORTED_DEVICES.map((d) => ({ + device: d, + autoChrome: AUTO_CHROME_BY_DEVICE[d], + safeArea: computeSafeArea(AUTO_CHROME_BY_DEVICE[d], d), + })), + elements: Object.values(CHROME_ELEMENTS).map((e) => ({ + id: e.id, + appliesTo: e.appliesTo, + conflicts: e.conflicts, + })), + }; +} diff --git a/mcp-server/src/renderer/chrome/registry.js b/mcp-server/src/renderer/chrome/registry.js new file mode 100644 index 0000000..7b3d275 --- /dev/null +++ b/mcp-server/src/renderer/chrome/registry.js @@ -0,0 +1,26 @@ +import { statusBarIos } from "./variants/status-bar-ios.js"; +import { statusBarAndroid } from "./variants/status-bar-android.js"; +import { dynamicIsland } from "./variants/dynamic-island.js"; +import { homeIndicator } from "./variants/home-indicator.js"; +import { androidGesturePill } from "./variants/android-gesture-pill.js"; + +// The canonical chrome registry. Adding a new chrome element = create a +// variant module under variants/ and add a row here. +// +// Each entry implements the ChromeElement contract: +// id: string — unique id +// appliesTo: string[] — list of device presets it can be drawn on +// conflicts: string[] — list of element ids that cannot coexist +// bounds({device}): {x,y,w,h} — viewport-space rectangle +// safeArea({device}): partial — {top?, bottom?, left?, right?} +// render({device, chromeStyle}): string — SVG fragment, no wrapper + +export const CHROME_ELEMENTS = { + "status-bar-ios": statusBarIos, + "status-bar-android": statusBarAndroid, + "dynamic-island": dynamicIsland, + "home-indicator": homeIndicator, + "android-gesture-pill": androidGesturePill, +}; + +export const CHROME_IDS = Object.keys(CHROME_ELEMENTS); diff --git a/mcp-server/src/renderer/chrome/variants/android-gesture-pill.js b/mcp-server/src/renderer/chrome/variants/android-gesture-pill.js new file mode 100644 index 0000000..741dc98 --- /dev/null +++ b/mcp-server/src/renderer/chrome/variants/android-gesture-pill.js @@ -0,0 +1,35 @@ +import { getBounds } from "../geometry.js"; + +// Android gesture-navigation pill. +// +// Thin pill at the bottom of the Android viewport (the gesture nav handle). +// Color follows chromeStyle. +// +// Safe-area contribution: bottom: 16. (Material guidance for systems using +// gesture nav: keep interactive UI ~16dp clear of the pill.) + +const PALETTE = { + light: { fg: "#000000" }, + dark: { fg: "#ffffff" }, +}; + +export const androidGesturePill = { + id: "android-gesture-pill", + appliesTo: ["android"], + conflicts: [], + + bounds: ({ device }) => getBounds("android-gesture-pill", device), + + safeArea: () => ({ bottom: 16 }), + + render: ({ device, chromeStyle }) => { + const { x, y, w, h } = getBounds("android-gesture-pill", device); + const palette = PALETTE[chromeStyle] ?? PALETTE.light; + const r = h / 2; + return ` + + + + `; + }, +}; diff --git a/mcp-server/src/renderer/chrome/variants/dynamic-island.js b/mcp-server/src/renderer/chrome/variants/dynamic-island.js new file mode 100644 index 0000000..d43e37e --- /dev/null +++ b/mcp-server/src/renderer/chrome/variants/dynamic-island.js @@ -0,0 +1,30 @@ +import { getBounds } from "../geometry.js"; + +// Dynamic Island. +// +// A solid black pill centered horizontally near the top of the iPhone +// viewport. It does NOT change color with chromeStyle — the real island is +// always black. +// +// Safe-area contribution: top: 59 (= y(11) + h(37) + breathing room(11)). +// When combined with status-bar-ios (top: 54), max-by-edge gives top: 59. + +export const dynamicIsland = { + id: "dynamic-island", + appliesTo: ["iphone"], + conflicts: [], + + bounds: ({ device }) => getBounds("dynamic-island", device), + + safeArea: () => ({ top: 59 }), + + render: ({ device }) => { + const { x, y, w, h } = getBounds("dynamic-island", device); + const r = h / 2; + return ` + + + + `; + }, +}; diff --git a/mcp-server/src/renderer/chrome/variants/home-indicator.js b/mcp-server/src/renderer/chrome/variants/home-indicator.js new file mode 100644 index 0000000..387e299 --- /dev/null +++ b/mcp-server/src/renderer/chrome/variants/home-indicator.js @@ -0,0 +1,36 @@ +import { getBounds } from "../geometry.js"; + +// iOS home indicator. +// +// Thin pill at the bottom edge of the iPhone viewport (the "swipe-up" bar). +// Color follows chromeStyle: black on light backgrounds, white on dark. +// +// Safe-area contribution: bottom: 34. (Apple's published bottom safe-area +// for modern Pro-class iPhones — designed to keep tappable UI clear of the +// home gesture region, not just the indicator itself.) + +const PALETTE = { + light: { fg: "#000000" }, + dark: { fg: "#ffffff" }, +}; + +export const homeIndicator = { + id: "home-indicator", + appliesTo: ["iphone"], + conflicts: [], + + bounds: ({ device }) => getBounds("home-indicator", device), + + safeArea: () => ({ bottom: 34 }), + + render: ({ device, chromeStyle }) => { + const { x, y, w, h } = getBounds("home-indicator", device); + const palette = PALETTE[chromeStyle] ?? PALETTE.light; + const r = h / 2; + return ` + + + + `; + }, +}; diff --git a/mcp-server/src/renderer/chrome/variants/status-bar-android.js b/mcp-server/src/renderer/chrome/variants/status-bar-android.js new file mode 100644 index 0000000..80f052e --- /dev/null +++ b/mcp-server/src/renderer/chrome/variants/status-bar-android.js @@ -0,0 +1,45 @@ +import { getBounds } from "../geometry.js"; +import { SIGNAL_BARS_SVG, WIFI_SVG, BATTERY_SVG } from "../glyphs.js"; + +// Android status bar (Material 3-ish). +// +// Layout (within the 412×36 bar at the top of the Android viewport): +// - "9:41" time label, left-aligned at 24px, vertically centered +// - Right cluster (signal | wifi | battery), right-aligned with ~6px gaps +// +// Safe-area contribution: top: 36. + +const PALETTE = { + light: { fg: "#000000" }, + dark: { fg: "#ffffff" }, +}; + +export const statusBarAndroid = { + id: "status-bar-android", + appliesTo: ["android"], + conflicts: ["status-bar-ios"], + + bounds: ({ device }) => getBounds("status-bar-android", device), + + safeArea: () => ({ top: 36 }), + + render: ({ device, chromeStyle }) => { + const { x, y, w } = getBounds("status-bar-android", device); + const palette = PALETTE[chromeStyle] ?? PALETTE.light; + const fg = palette.fg; + + const rightEdge = x + w - 16; + const batteryX = rightEdge - 24; + const wifiX = batteryX - 22; + const signalX = wifiX - 24; + + return ` + + 9:41 + ${SIGNAL_BARS_SVG} + ${WIFI_SVG} + ${BATTERY_SVG} + + `; + }, +}; diff --git a/mcp-server/src/renderer/chrome/variants/status-bar-ios.js b/mcp-server/src/renderer/chrome/variants/status-bar-ios.js new file mode 100644 index 0000000..07a964c --- /dev/null +++ b/mcp-server/src/renderer/chrome/variants/status-bar-ios.js @@ -0,0 +1,49 @@ +import { getBounds } from "../geometry.js"; +import { SIGNAL_BARS_SVG, WIFI_SVG, BATTERY_SVG } from "../glyphs.js"; + +// iOS status bar. +// +// Layout (within the 393×54 bar at the top of the iPhone viewport): +// - "9:41" time label, left-aligned, vertically centered +// - Right cluster (signal | wifi | battery), right-aligned with ~12px gaps +// - The Dynamic Island sits in the center; this bar leaves room for it by +// concentrating glyphs on the left/right edges, never the middle. +// +// Safe-area contribution: top: 54. +// Combined with dynamic-island (top: 59), max-by-edge gives top: 59. + +const PALETTE = { + light: { fg: "#000000" }, + dark: { fg: "#ffffff" }, +}; + +export const statusBarIos = { + id: "status-bar-ios", + appliesTo: ["iphone"], + conflicts: ["status-bar-android"], + + bounds: ({ device }) => getBounds("status-bar-ios", device), + + safeArea: () => ({ top: 54 }), + + render: ({ device, chromeStyle }) => { + const { x, y, w } = getBounds("status-bar-ios", device); + const palette = PALETTE[chromeStyle] ?? PALETTE.light; + const fg = palette.fg; + + // Right cluster anchored to (right edge - 16px); battery rightmost. + const rightEdge = x + w - 16; + const batteryX = rightEdge - 24; // 24 = battery width + const wifiX = batteryX - 22; // 16 (wifi w) + 6 (gap) + const signalX = wifiX - 24; // 18 (signal w) + 6 (gap) + + return ` + + 9:41 + ${SIGNAL_BARS_SVG} + ${WIFI_SVG} + ${BATTERY_SVG} + + `; + }, +}; diff --git a/mcp-server/src/renderer/device-presets.js b/mcp-server/src/renderer/device-presets.js index 0484def..9c0ac47 100644 --- a/mcp-server/src/renderer/device-presets.js +++ b/mcp-server/src/renderer/device-presets.js @@ -1,14 +1,23 @@ +// Canonical device viewports for MCP screen rendering. +// +// **v2 (item 2.8):** Reduced to two generic devices — `iphone` and +// `android` — that drive both viewport sizing and chrome composition. +// Previous SKU-named presets (iphone-15-pro, ipad, etc.) were removed +// when the chrome system shipped: agents now think in terms of "what +// device" not "which model", and chrome geometry is keyed off these two +// canonical viewports. + export const DEVICE_PRESETS = { - "iphone-15-pro": { width: 393, height: 852, deviceScaleFactor: 2, label: "iPhone 15 Pro" }, - "iphone-se": { width: 375, height: 667, deviceScaleFactor: 2, label: "iPhone SE" }, - "iphone-16-pro-max": { width: 440, height: 956, deviceScaleFactor: 2, label: "iPhone 16 Pro Max" }, - "ipad": { width: 820, height: 1180, deviceScaleFactor: 2, label: "iPad" }, - "ipad-pro-13": { width: 1032, height: 1376, deviceScaleFactor: 2, label: "iPad Pro 13\"" }, - "android": { width: 412, height: 915, deviceScaleFactor: 2, label: "Android" }, - "android-tablet": { width: 800, height: 1280, deviceScaleFactor: 2, label: "Android Tablet" }, + iphone: { width: 393, height: 852, deviceScaleFactor: 2, label: "iPhone (modern Pro class)" }, + android: { width: 412, height: 915, deviceScaleFactor: 2, label: "Android (Pixel class)" }, }; -export const DEFAULT_DEVICE = "iphone-15-pro"; +export const DEFAULT_DEVICE = "iphone"; + +// Re-export the chrome auto-expansion table from the chrome subsystem so +// callers that already depend on device-presets.js don't need to learn +// about the chrome module path. +export { AUTO_CHROME_BY_DEVICE } from "./chrome/defaults.js"; export function resolveViewport(device, customWidth, customHeight) { if (customWidth && customHeight) { @@ -16,7 +25,31 @@ export function resolveViewport(device, customWidth, customHeight) { } const preset = DEVICE_PRESETS[device || DEFAULT_DEVICE]; if (!preset) { - throw new Error(`Unknown device preset: ${device}. Available: ${Object.keys(DEVICE_PRESETS).join(", ")}`); + throw new Error( + `Unknown device preset: ${device}. Available: ${Object.keys(DEVICE_PRESETS).join(", ")}` + ); + } + return { + width: preset.width, + height: preset.height, + deviceScaleFactor: preset.deviceScaleFactor, + }; +} + +/** + * Reverse-lookup: which preset key matches an exact (width × height) pair? + * Used by `compose_chrome` to infer device on uploaded/Figma screens that + * have no persisted device. Considers both raw CSS dimensions and the 2× + * Retina-rendered output (since some images are stored at output size). + * + * @returns {string|null} preset id or null if no match. + */ +export function inferDeviceFromDimensions(width, height) { + for (const [id, preset] of Object.entries(DEVICE_PRESETS)) { + if (preset.width === width && preset.height === height) return id; + const dx = preset.width * preset.deviceScaleFactor; + const dy = preset.height * preset.deviceScaleFactor; + if (dx === width && dy === height) return id; } - return { width: preset.width, height: preset.height, deviceScaleFactor: preset.deviceScaleFactor }; + return null; } diff --git a/mcp-server/src/renderer/satori-renderer.js b/mcp-server/src/renderer/satori-renderer.js index 9329d8f..6364829 100644 --- a/mcp-server/src/renderer/satori-renderer.js +++ b/mcp-server/src/renderer/satori-renderer.js @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; import { resolveViewport, DEVICE_PRESETS } from "./device-presets.js"; import { getEmojiCode, loadEmojiSvg } from "./emoji-loader.js"; +import { composeChromeSvg, expandAutoChrome } from "./chrome/index.js"; // Dynamic imports resolved at runtime to support esbuild bundling let _satori = null; @@ -39,27 +39,52 @@ const ASSETS_DIR = resolveAssetsDir(); export class SatoriRenderer { constructor() { this.fonts = null; + // Resolved on init() — passed to Resvg so the chrome layer's + // glyphs render with the same Inter family Satori uses. + this.resvgFontPaths = []; } async init() { await loadDeps(); - const regularFont = readFileSync(new URL("Inter-Regular.ttf", ASSETS_DIR)); - const boldFont = readFileSync(new URL("Inter-Bold.ttf", ASSETS_DIR)); + const regularUrl = new URL("Inter-Regular.ttf", ASSETS_DIR); + const boldUrl = new URL("Inter-Bold.ttf", ASSETS_DIR); + + const regularFont = readFileSync(regularUrl); + const boldFont = readFileSync(boldUrl); this.fonts = [ { name: "Inter", data: regularFont, weight: 400, style: "normal" }, { name: "Inter", data: boldFont, weight: 700, style: "normal" }, ]; + + this.resvgFontPaths = [fileURLToPath(regularUrl), fileURLToPath(boldUrl)]; } + /** + * Render HTML to a PNG, optionally composing device chrome on top. + * + * Chrome integration: + * - `device` (default "iphone") drives viewport sizing AND chrome + * auto-expansion. + * - `chrome` accepts: "auto" (default; expands per device), false + * (no chrome), or an explicit array of element ids. + * - `chromeStyle` is "light" (default) or "dark". + * + * Fallback: if chrome composition throws (bad geometry, Resvg config + * mismatch, etc.) we still return a usable PNG — just without chrome + * — and surface `chromeRenderError: true` so callers can flag it. + */ async render(htmlString, options = {}) { if (!this.fonts) { throw new Error("SatoriRenderer not initialized. Call init() first."); } - const { device, width, height } = options; + const { device, width, height, chrome, chromeStyle = "light" } = options; const viewport = resolveViewport(device, width, height); + // Resolved device id (or null when only custom width/height was given — + // chrome only applies when we know which device we're targeting). + const resolvedDevice = (width && height) ? null : (device || "iphone"); // satori-html does not decode HTML entities. Agents frequently write // numeric entities (●, ●) and safe named entities (•, @@ -79,7 +104,7 @@ export class SatoriRenderer { const markup = _html(fixedHtml); // VDOM -> SVG string - const svgString = await _satori(markup, { + const satoriSvg = await _satori(markup, { width: viewport.width, height: viewport.height, fonts: this.fonts, @@ -91,18 +116,50 @@ export class SatoriRenderer { }, }); - // SVG -> PNG buffer at 2x (Retina) - const resvg = new _Resvg(svgString, { + // Chrome composition. Wrap in try/catch so a bad geometry / chrome bug + // never prevents the user from getting a PNG back. + const expandedChrome = expandAutoChrome(chrome, resolvedDevice); + let composed = { svgString: satoriSvg, safeArea: { top: 0, bottom: 0, left: 0, right: 0 } }; + let chromeRenderError = null; + + if (resolvedDevice && expandedChrome.length > 0) { + try { + composed = composeChromeSvg({ + baseSvg: satoriSvg, + device: resolvedDevice, + chrome: expandedChrome, + chromeStyle, + viewport: { width: viewport.width, height: viewport.height }, + }); + } catch (err) { + chromeRenderError = err.message || String(err); + } + } + + // SVG -> PNG buffer at 2x (Retina). Pass the Inter font files so that + // chrome elements render with the same family as Satori uses. + const resvgOptions = { fitTo: { mode: "width", value: viewport.width * viewport.deviceScaleFactor }, - }); + font: { + fontFiles: this.resvgFontPaths, + loadSystemFonts: false, + defaultFontFamily: "Inter", + }, + }; + const resvg = new _Resvg(composed.svgString, resvgOptions); const rendered = resvg.render(); const pngBuffer = rendered.asPng(); return { pngBuffer, - svgString, + svgString: composed.svgString, width: viewport.width * viewport.deviceScaleFactor, height: viewport.height * viewport.deviceScaleFactor, + device: resolvedDevice, + chrome: chromeRenderError ? [] : expandedChrome, + chromeStyle, + safeArea: chromeRenderError ? { top: 0, bottom: 0, left: 0, right: 0 } : composed.safeArea, + ...(chromeRenderError ? { chromeRenderError } : {}), }; } @@ -123,6 +180,83 @@ export class SatoriRenderer { } } +/** + * Compose chrome onto an existing image (universal path). + * + * Used by the `compose_chrome` tool to retroactively chrome an uploaded + * PNG, Figma-pasted screen, or a screen that was previously rendered + * with chrome disabled. Re-uses the same Resvg config as `render` so + * font/output behaviour is consistent. + * + * Either `baseSvg` (preferred — fast path for code-rendered screens with + * cached svgContent) or `baseImageDataUri` is required. + * + * @returns {{ pngBuffer, svgString, width, height, device, chrome, chromeStyle, safeArea, chromeRenderError? }} + */ +SatoriRenderer.prototype.composeChrome = async function composeChrome({ + baseSvg, + baseImageDataUri, + device, + chrome, + chromeStyle = "light", +}) { + if (!this.fonts) { + throw new Error("SatoriRenderer not initialized. Call init() first."); + } + if (!device) { + throw new Error("composeChrome requires a device id"); + } + + const viewport = resolveViewport(device); + const expanded = expandAutoChrome(chrome, device); + + let svgString; + let safeArea; + let chromeRenderError = null; + try { + const composed = composeChromeSvg({ + baseSvg, + baseImageDataUri, + device, + chrome: expanded, + chromeStyle, + viewport: { width: viewport.width, height: viewport.height }, + }); + svgString = composed.svgString; + safeArea = composed.safeArea; + } catch (err) { + chromeRenderError = err.message || String(err); + // Fallback: re-emit the base layer with no chrome so the screen is still usable. + const baseLayer = baseSvg + ? baseSvg + : ``; + svgString = baseLayer; + safeArea = { top: 0, bottom: 0, left: 0, right: 0 }; + } + + const resvg = new _Resvg(svgString, { + fitTo: { mode: "width", value: viewport.width * viewport.deviceScaleFactor }, + font: { + fontFiles: this.resvgFontPaths, + loadSystemFonts: false, + defaultFontFamily: "Inter", + }, + }); + const pngBuffer = resvg.render().asPng(); + + return { + pngBuffer, + svgString, + width: viewport.width * viewport.deviceScaleFactor, + height: viewport.height * viewport.deviceScaleFactor, + device, + chrome: chromeRenderError ? [] : expanded, + chromeStyle, + safeArea, + ...(chromeRenderError ? { chromeRenderError } : {}), + }; +}; + /** * Satori requires every element with more than one child to have an explicit * display property (flex, contents, or none). This function injects diff --git a/mcp-server/src/state.js b/mcp-server/src/state.js index 0a6b6a4..d55e404 100644 --- a/mcp-server/src/state.js +++ b/mcp-server/src/state.js @@ -152,6 +152,7 @@ export class FlowState { notes = "", tbd = false, tbdNote = "", + device = null, } = options; const pos = position || gridPosition(this.screens.length); @@ -184,6 +185,7 @@ export class FlowState { figmaSource: null, componentId: null, componentRole: null, + device, }; this.screens.push(screen); @@ -205,6 +207,7 @@ export class FlowState { "imageData", "imageWidth", "imageHeight", "svgContent", "sourceHtml", "wireframe", "componentId", "componentRole", + "device", ]; for (const key of allowed) { if (updates[key] !== undefined) { diff --git a/mcp-server/src/tools/__tests__/screen-tools.chrome.test.js b/mcp-server/src/tools/__tests__/screen-tools.chrome.test.js new file mode 100644 index 0000000..feac3ef --- /dev/null +++ b/mcp-server/src/tools/__tests__/screen-tools.chrome.test.js @@ -0,0 +1,277 @@ +// @vitest-environment node +// +// Phase 3/4 integration tests for the chrome-aware screen tools. Drives the +// real SatoriRenderer + chrome composition + state persistence end-to-end so +// regressions in the schema, the device-block plumbing, or the +// compose_chrome / get_chrome_info handlers surface here. + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { FlowState } from "../../state.js"; +import { SatoriRenderer } from "../../renderer/satori-renderer.js"; +import { handleScreenTool, screenTools } from "../screen-tools.js"; + +const SIMPLE_HTML = + '
Hello
'; + +const ANDROID_HTML = + '
Hello
'; + +let renderer; + +beforeAll(async () => { + renderer = new SatoriRenderer(); + await renderer.init(); +}, 30_000); + +let tmpDir; +let state; +let tmpFile; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "drawd-chrome-")); + tmpFile = path.join(tmpDir, "flow.drawd"); + state = new FlowState(); + state.createNew(tmpFile, { name: "Test Flow" }); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("screen-tools chrome integration", () => { + describe("schema", () => { + it("registers compose_chrome and get_chrome_info", () => { + const names = screenTools.map((t) => t.name); + expect(names).toContain("compose_chrome"); + expect(names).toContain("get_chrome_info"); + }); + + it("constrains create_screen.device to the two supported devices", () => { + const create = screenTools.find((t) => t.name === "create_screen"); + expect(create.inputSchema.properties.device.enum).toEqual(["iphone", "android"]); + }); + + it("exposes chrome and chromeStyle on render-producing tools", () => { + const renderTools = ["create_screen", "update_screen_image", "batch_create_screens"]; + for (const name of renderTools) { + const tool = screenTools.find((t) => t.name === name); + expect(tool.inputSchema.properties.chrome).toBeDefined(); + expect(tool.inputSchema.properties.chromeStyle.enum).toEqual(["light", "dark"]); + } + }); + }); + + describe("create_screen with default chrome", () => { + it("persists a device block with auto-expanded chrome and safeArea", async () => { + const result = await handleScreenTool( + "create_screen", + { html: SIMPLE_HTML, name: "Home", device: "iphone" }, + state, + renderer, + ); + + expect(result.device).toEqual({ + preset: "iphone", + chrome: ["status-bar-ios", "dynamic-island", "home-indicator"], + chromeStyle: "light", + safeArea: { top: 59, bottom: 34, left: 0, right: 0 }, + }); + + // Round-trip through the file: save → reload → device block survives + state.save(); + const reloaded = new FlowState(); + reloaded.load(tmpFile); + expect(reloaded.screens[0].device).toEqual(result.device); + }); + + it("respects an explicit chrome subset", async () => { + const result = await handleScreenTool( + "create_screen", + { + html: SIMPLE_HTML, + name: "Home", + device: "iphone", + chrome: ["status-bar-ios"], + }, + state, + renderer, + ); + expect(result.device.chrome).toEqual(["status-bar-ios"]); + expect(result.device.safeArea).toEqual({ top: 54, bottom: 0, left: 0, right: 0 }); + }); + + it("returns a null device block when custom width/height bypasses presets", async () => { + const result = await handleScreenTool( + "create_screen", + { html: SIMPLE_HTML, name: "Custom", width: 500, height: 500 }, + state, + renderer, + ); + expect(result.device).toBeNull(); + expect(state.screens[0].device).toBeNull(); + }); + + it("renders Android with its auto chrome set", async () => { + const result = await handleScreenTool( + "create_screen", + { html: ANDROID_HTML, name: "Android Home", device: "android" }, + state, + renderer, + ); + expect(result.device.preset).toBe("android"); + expect(result.device.chrome).toEqual(["status-bar-android", "android-gesture-pill"]); + expect(result.device.safeArea).toEqual({ top: 36, bottom: 16, left: 0, right: 0 }); + }); + }); + + describe("update_screen_image", () => { + it("inherits the persisted device when caller doesn't override", async () => { + // Create with iPhone + dark chrome + await handleScreenTool( + "create_screen", + { html: SIMPLE_HTML, name: "Home", device: "iphone", chromeStyle: "dark" }, + state, + renderer, + ); + const screenId = state.screens[0].id; + + // Update with new HTML but no device/style — should keep iphone+dark + await handleScreenTool( + "update_screen_image", + { screenId, html: SIMPLE_HTML }, + state, + renderer, + ); + expect(state.screens[0].device.preset).toBe("iphone"); + expect(state.screens[0].device.chromeStyle).toBe("dark"); + }); + }); + + describe("list_screens", () => { + it("includes the device summary block when present", async () => { + await handleScreenTool( + "create_screen", + { html: SIMPLE_HTML, name: "Home", device: "iphone" }, + state, + renderer, + ); + const result = await handleScreenTool("list_screens", {}, state, renderer); + expect(result.screens[0].device).toEqual({ + preset: "iphone", + chrome: ["status-bar-ios", "dynamic-island", "home-indicator"], + chromeStyle: "light", + safeArea: { top: 59, bottom: 34, left: 0, right: 0 }, + }); + }); + + it("omits the device block when no chrome was applied", async () => { + await handleScreenTool( + "create_screen", + { html: SIMPLE_HTML, name: "Custom", width: 500, height: 500 }, + state, + renderer, + ); + const result = await handleScreenTool("list_screens", {}, state, renderer); + expect(result.screens[0].device).toBeUndefined(); + }); + }); + + describe("compose_chrome", () => { + it("updates an existing screen's image and device block", async () => { + // First, create a screen WITHOUT chrome + await handleScreenTool( + "create_screen", + { html: SIMPLE_HTML, name: "Home", device: "iphone", chrome: false }, + state, + renderer, + ); + const screenId = state.screens[0].id; + // device block should reflect the no-chrome state + expect(state.screens[0].device.chrome).toEqual([]); + + // Now compose chrome on it + const result = await handleScreenTool( + "compose_chrome", + { screenId }, + state, + renderer, + ); + expect(result.success).toBe(true); + expect(result.device.preset).toBe("iphone"); + expect(result.device.chrome).toEqual([ + "status-bar-ios", + "dynamic-island", + "home-indicator", + ]); + expect(state.screens[0].device.safeArea).toEqual({ top: 59, bottom: 34, left: 0, right: 0 }); + }); + + it("throws a friendly error when device cannot be inferred", async () => { + await handleScreenTool( + "create_screen", + { html: SIMPLE_HTML, name: "Custom", width: 500, height: 500 }, + state, + renderer, + ); + const screenId = state.screens[0].id; + await expect( + handleScreenTool("compose_chrome", { screenId }, state, renderer), + ).rejects.toThrow(/Cannot infer device/); + }); + + it("uses an explicit device argument when no persisted device exists", async () => { + // Create with custom dimensions matching iphone @2x (786×1704) + await handleScreenTool( + "create_screen", + { html: SIMPLE_HTML, name: "Custom", width: 393, height: 852 }, + state, + renderer, + ); + const screenId = state.screens[0].id; + // strip the persisted device manually so we exercise the fallback + state.screens[0].device = null; + + const result = await handleScreenTool( + "compose_chrome", + { screenId, device: "iphone" }, + state, + renderer, + ); + expect(result.device.preset).toBe("iphone"); + }); + }); + + describe("get_chrome_info", () => { + it("returns the full catalog when called with no args", async () => { + const info = await handleScreenTool("get_chrome_info", {}, state, renderer); + expect(info.devices).toHaveLength(2); + expect(info.devices.map((d) => d.device).sort()).toEqual(["android", "iphone"]); + expect(info.elements.length).toBeGreaterThanOrEqual(5); + }); + + it("returns auto chrome + safeArea for a single device", async () => { + const info = await handleScreenTool("get_chrome_info", { device: "iphone" }, state, renderer); + expect(info.device).toBe("iphone"); + expect(info.chrome).toEqual([ + "status-bar-ios", + "dynamic-island", + "home-indicator", + ]); + expect(info.safeArea).toEqual({ top: 59, bottom: 34, left: 0, right: 0 }); + }); + + it("computes safeArea for an explicit chrome subset", async () => { + const info = await handleScreenTool( + "get_chrome_info", + { device: "iphone", chrome: ["status-bar-ios"] }, + state, + renderer, + ); + expect(info.chrome).toEqual(["status-bar-ios"]); + expect(info.safeArea).toEqual({ top: 54, bottom: 0, left: 0, right: 0 }); + }); + }); +}); diff --git a/mcp-server/src/tools/screen-tools.js b/mcp-server/src/tools/screen-tools.js index c2a71db..8c9be42 100644 --- a/mcp-server/src/tools/screen-tools.js +++ b/mcp-server/src/tools/screen-tools.js @@ -1,9 +1,28 @@ import { DEFAULT_SCREEN_WIDTH } from "../../../src/constants.js"; +import { + SUPPORTED_DEVICES, + CHROME_IDS, + getChromeInfo, +} from "../renderer/chrome/index.js"; +import { inferDeviceFromDimensions } from "../renderer/device-presets.js"; + +// Shared schema fragments. The chrome system is the same shape on every +// render-producing tool, so we describe it once and reuse the description. +const CHROME_DESC = + "Device chrome compositing. Pass \"auto\" (default) to apply the device's standard chrome (status bar + dynamic island/home indicator on iPhone, status bar + gesture pill on Android). Pass false to skip chrome entirely. Pass an explicit array of chrome ids to override (e.g. [\"status-bar-ios\"]). Available ids: " + + CHROME_IDS.join(", ") + + ". Chrome is composited on top of the rendered HTML — design HTML for the FULL viewport and respect the device safeArea returned by get_chrome_info."; + +const CHROME_STYLE_DESC = + "Chrome palette. \"light\" (default) uses dark glyphs on a transparent base — appropriate for light app screens. \"dark\" uses white glyphs — use when the app screen has a dark/photo background."; + +const DEVICE_DESC = + "Device preset for viewport size and chrome geometry. \"iphone\" = 393×852 (modern Pro class). \"android\" = 412×915 (Pixel class). Defaults to \"iphone\" when omitted unless explicit width/height is given."; export const screenTools = [ { name: "create_screen", - description: "Create a new screen by rendering HTML content to an image. If no position is specified, the screen is auto-placed on a grid layout.", + description: "Create a new screen by rendering HTML content to an image. If no position is specified, the screen is auto-placed on a grid layout. Device chrome (status bar, home indicator/gesture pill, etc.) is composited automatically — design HTML for the full viewport and respect the device safe-area returned by get_chrome_info.", inputSchema: { type: "object", properties: { @@ -11,11 +30,17 @@ export const screenTools = [ name: { type: "string", description: "Screen name (e.g., 'Login Screen', 'Home Feed')" }, device: { type: "string", - description: "Device preset for viewport size", - enum: ["iphone-15-pro", "iphone-se", "iphone-16-pro-max", "ipad", "ipad-pro-13", "android", "android-tablet"], + description: DEVICE_DESC, + enum: SUPPORTED_DEVICES, + }, + width: { type: "number", description: "Custom viewport width (overrides device preset; disables chrome)" }, + height: { type: "number", description: "Custom viewport height (overrides device preset; disables chrome)" }, + chrome: { description: CHROME_DESC }, + chromeStyle: { + type: "string", + description: CHROME_STYLE_DESC, + enum: ["light", "dark"], }, - width: { type: "number", description: "Custom viewport width (overrides device preset)" }, - height: { type: "number", description: "Custom viewport height (overrides device preset)" }, position: { type: "object", properties: { @@ -81,7 +106,7 @@ export const screenTools = [ }, { name: "list_screens", - description: "List all screens in the current flow with summary info (without image data).", + description: "List all screens in the current flow with summary info (without image data). The 'device' summary block is present only when chrome was rendered for that screen.", inputSchema: { type: "object", properties: {}, @@ -89,7 +114,7 @@ export const screenTools = [ }, { name: "get_screen", - description: "Get full details of a specific screen, including hotspots. Image data is excluded by default to keep responses small. When included, the image is downsampled to 400 px wide by default to reduce token cost; pass imageMaxWidth: 0 for the original full-resolution image.", + description: "Get full details of a specific screen, including hotspots and (if present) the persisted device/chrome/safeArea block. Image data is excluded by default to keep responses small. When included, the image is downsampled to 400 px wide by default to reduce token cost; pass imageMaxWidth: 0 for the original full-resolution image.", inputSchema: { type: "object", properties: { @@ -113,7 +138,7 @@ export const screenTools = [ }, { name: "update_screen_image", - description: "Re-render a screen's image from new HTML content. Use inline styles only (no