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 =
+ '
';
+
+const SIMPLE_ANDROID_HTML =
+ '';
+
+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(/^