diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts
index 4332c359d..48b80d9ad 100644
--- a/packages/engine/src/services/screenshotService.test.ts
+++ b/packages/engine/src/services/screenshotService.test.ts
@@ -7,6 +7,7 @@ import {
cdpSessionCache,
injectVideoFramesBatch,
syncVideoFrameVisibility,
+ materializeInheritedTextColorsInDocument,
} from "./screenshotService.js";
// Stub a Page + CDPSession just enough that pageScreenshotCapture can call
@@ -20,15 +21,103 @@ function makeFakePageWithCdp(send: (method: string, params: object) => Promise<{
return fakePage;
}
+describe("materializeInheritedTextColorsInDocument", () => {
+ function withLinkedomDocument(html: string) {
+ const { window, document } = parseHTML(html);
+ Object.defineProperty(window, "getComputedStyle", {
+ configurable: true,
+ value: (el: Element) => {
+ let cur: Element | null = el;
+ while (cur) {
+ const inlineColor = (cur as HTMLElement).style?.color;
+ if (inlineColor) {
+ return { color: inlineColor };
+ }
+ cur = cur.parentElement;
+ }
+ return { color: "" };
+ },
+ });
+
+ const globals = globalThis as unknown as { window?: typeof window; document?: Document };
+ const previousWindow = globals.window;
+ const previousDocument = globals.document;
+ globals.window = window;
+ globals.document = document;
+ return {
+ window,
+ document,
+ teardown: () => {
+ globals.window = previousWindow;
+ globals.document = previousDocument;
+ },
+ };
+ }
+
+ it("sets inline color from computed style when text relies on CSS inheritance", () => {
+ const { document, teardown } = withLinkedomDocument(
+ '
Invisible in render
Visible control
',
+ );
+ try {
+ materializeInheritedTextColorsInDocument();
+
+ const title = document.getElementById("title") as HTMLElement;
+ const subtitle = document.getElementById("subtitle") as HTMLElement;
+ expect(title.style.color).toBe("#e8e8e8");
+ expect(subtitle.style.color).toBe("#67e8f9");
+ } finally {
+ teardown();
+ }
+ });
+
+ it("does not override an existing inline color", () => {
+ const { document, teardown } = withLinkedomDocument(
+ '
Explicit red
',
+ );
+ try {
+ materializeInheritedTextColorsInDocument();
+
+ const title = document.getElementById("title") as HTMLElement;
+ expect(title.style.color).toBe("#ff0000");
+ } finally {
+ teardown();
+ }
+ });
+
+ it("skips container elements whose text lives only in descendants", () => {
+ const { document, teardown } = withLinkedomDocument(
+ '
',
+ );
+ try {
+ materializeInheritedTextColorsInDocument();
+
+ const wrap = document.getElementById("wrap") as HTMLElement;
+ const label = document.getElementById("label") as HTMLElement;
+ expect(wrap.style.color ?? "").toBe("");
+ expect(label.style.color).toBe("#e8e8e8");
+ } finally {
+ teardown();
+ }
+ });
+});
+
describe("pageScreenshotCapture supersample plumbing", () => {
// Minimal 1×1 transparent PNG, base64. The function returns Buffer.from(data, "base64")
// and we never inspect the bytes — only the params we pass to client.send.
const ONE_PIXEL_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
+ function makeScreenshotPage(send: (method: string, params: object) => Promise<{ data: string }>) {
+ const page = makeFakePageWithCdp(send) as Page & {
+ evaluate: (fn: () => void) => Promise
;
+ };
+ page.evaluate = async () => {};
+ return page;
+ }
+
it("passes `clip` with scale 1 when deviceScaleFactor is undefined (default 1)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
- const page = makeFakePageWithCdp(send);
+ const page = makeScreenshotPage(send);
await pageScreenshotCapture(page, {
width: 1920,
@@ -48,7 +137,7 @@ describe("pageScreenshotCapture supersample plumbing", () => {
it("passes `clip` with scale 1 when deviceScaleFactor is exactly 1", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
- const page = makeFakePageWithCdp(send);
+ const page = makeScreenshotPage(send);
await pageScreenshotCapture(page, {
width: 1920,
@@ -64,7 +153,7 @@ describe("pageScreenshotCapture supersample plumbing", () => {
it("passes `clip` with `scale = dpr` when deviceScaleFactor > 1 (the supersample contract)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
- const page = makeFakePageWithCdp(send);
+ const page = makeScreenshotPage(send);
await pageScreenshotCapture(page, {
width: 1920,
@@ -84,7 +173,7 @@ describe("pageScreenshotCapture supersample plumbing", () => {
it("propagates a non-2 supersample factor (e.g. 720p → 4K = 3×)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
- const page = makeFakePageWithCdp(send);
+ const page = makeScreenshotPage(send);
await pageScreenshotCapture(page, {
width: 1280,
diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts
index ffbe0be56..b582e8d2a 100644
--- a/packages/engine/src/services/screenshotService.ts
+++ b/packages/engine/src/services/screenshotService.ts
@@ -116,6 +116,37 @@ export async function beginFrameCapture(
};
}
+/**
+ * Materialize computed `color` as an inline style on text elements that rely
+ * on CSS inheritance. Chrome's `Page.captureScreenshot` compositor can fail to
+ * resolve inherited text color after GSAP clears its inline styles, leaving
+ * inherited-color text invisible in the captured frame.
+ *
+ * Runs in the page context via `page.evaluate` — must only reference browser
+ * globals so Puppeteer can serialize the function body.
+ */
+export function materializeInheritedTextColorsInDocument(): void {
+ const body = document.body;
+ if (!body) return;
+
+ for (const node of body.querySelectorAll("*")) {
+ const style = node.style;
+ if (style.color) continue;
+ for (let i = 0; i < node.childNodes.length; i++) {
+ const child = node.childNodes[i];
+ if (child?.nodeType === 3 && child.textContent && /\S/.test(child.textContent)) {
+ const computed = window.getComputedStyle(node).color;
+ if (computed) style.color = computed;
+ break;
+ }
+ }
+ }
+}
+
+async function materializeInheritedTextColors(page: Page): Promise {
+ await page.evaluate(materializeInheritedTextColorsInDocument);
+}
+
/**
* Capture a screenshot using standard Page.captureScreenshot CDP call.
* Fallback for environments where BeginFrame is unavailable (macOS, Windows).
@@ -127,6 +158,7 @@ export async function beginFrameCapture(
* `captureAlphaPng`. Keeping the fast path for opaque jpeg captures is fine.
*/
export async function pageScreenshotCapture(page: Page, options: CaptureOptions): Promise {
+ await materializeInheritedTextColors(page);
const client = await getCdpSession(page);
const isPng = options.format === "png";
const dpr = options.deviceScaleFactor ?? 1;
@@ -163,6 +195,7 @@ export async function captureScreenshotWithAlpha(
width: number,
height: number,
): Promise {
+ await materializeInheritedTextColors(page);
const client = await getCdpSession(page);
// Force transparent background so the screenshot has a real alpha channel
await client.send("Emulation.setDefaultBackgroundColorOverride", {
@@ -233,6 +266,7 @@ export async function initTransparentBackground(page: Page): Promise {
* two-pass compositing loop.
*/
export async function captureAlphaPng(page: Page, width: number, height: number): Promise {
+ await materializeInheritedTextColors(page);
const client = await getCdpSession(page);
const result = await client.send("Page.captureScreenshot", {
format: "png",