From 9ba497247a4953b5265cb1671289fca95d7951e7 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Sat, 29 Aug 2026 05:41:43 -0400 Subject: [PATCH] feat: add --color-scheme so themed sites can be captured as authored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A site that follows the OS theme has no theme of its own to report, and headless Chromium resolves `prefers-color-scheme` to light. Every capture of such a site therefore came back light, whatever its authors actually look at all day. There was no way to ask for the other face. `--color-scheme ` sets what the browser reports. It is applied to both context-creation sites — the screenshot workers in `service.ts` and the video recorder in `video.ts` — so a run's stills and its footage cannot disagree with each other. The default is `light`, matching Playwright's own default, so existing captures are byte-for-byte unaffected. Verified end to end against a next-themes app on `system`: the same route captured at 1280x800 has a mean grey level of 0.94 under `--color-scheme light` and 0.08 under `--color-scheme dark`. Note for a follow-up: `CaptureConfigOverrides` in schemas.ts is a hand-written Partial that duplicates `CaptureConfigFields`, and adding a field to one without the other type-errors only at the call site. Deriving it would remove that step. Left alone here to keep this change to one subject. --- README.md | 6 ++++++ src/runner.test.ts | 22 ++++++++++++++++++++++ src/runner.ts | 19 +++++++++++++++++++ src/schemas.ts | 12 ++++++++++++ src/service.ts | 6 +++++- src/video.ts | 3 +++ 6 files changed, 67 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6286c65..73e9604 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,10 @@ Options: --video Capture videos in addition to screenshots --video-duration Video duration when --video (default: 10000) --no-interactions Disable scripted scrolling during video + --color-scheme prefers-color-scheme to report: light, dark, or + no-preference (default: light). A site that + follows the OS theme renders light under headless + Chromium, so pass dark to capture its dark face. --no-warmup Skip the pre-screenshot warm-up scroll --ffmpeg ffmpeg binary path (default: ffmpeg) --launch-args Extra Chromium switches, whitespace separated @@ -174,6 +178,7 @@ Examples: ui-capture https://example.com --video --max-depth 1 --concurrency 4 ui-capture https://example.com --viewports desktop:1920x1080,mobile:390x844 ui-capture https://example.com --hide ".cookie-banner,#chat-widget" + ui-capture https://example.com --color-scheme dark ``` ## Library @@ -322,6 +327,7 @@ All three errors are `S.TaggedError` subclasses, so they discriminate cleanly un | `warmupScroll` | `--no-warmup` (¬) | `boolean` | `true` | Top→bottom→top scroll before each shot to trigger lazy loads. | | `screenshotHideSelectors` | `--hide` | `string[]` (CSS selectors) | `[]` | Hidden via injected `visibility:hidden` style during capture. | | `menuInteractionSelectors` | `--menu-selectors` | `string[]` | `[]` | Clicked before link discovery for collapsed nav menus. | +| `colorScheme` | `--color-scheme` | `"light" \| "dark" \| "no-preference"` | `"light"` | The `prefers-color-scheme` reported to the page, applied to both the screenshot and video contexts; the default matches Playwright's, so existing captures are unchanged. | | `ffmpegPath` | `--ffmpeg` | `string` | `"ffmpeg"` | Absolute path or anything on `PATH`. | | `launchArgs` | `--launch-args` | `string[]` | `[]` | Appended after the baseline switches so they win on conflict; the CLI value splits on whitespace rather than commas, since one switch may itself contain commas. | diff --git a/src/runner.test.ts b/src/runner.test.ts index 3d8f28a..7d6fec0 100644 --- a/src/runner.test.ts +++ b/src/runner.test.ts @@ -40,6 +40,28 @@ describe("parseCliArgs", () => { }); describe("buildInvocation", () => { + it("leaves the color scheme unset so the default applies", () => { + const inv = buildInvocation(parseCliArgs(["https://example.com"])); + expect(inv.overrides.colorScheme).toBeUndefined(); + }); + + it("accepts each supported color scheme", () => { + for (const scheme of ["light", "dark", "no-preference"] as const) { + const inv = buildInvocation( + parseCliArgs(["https://example.com", "--color-scheme", scheme]), + ); + expect(inv.overrides.colorScheme).toBe(scheme); + } + }); + + it("rejects a color scheme Playwright would not accept", () => { + expect(() => + buildInvocation( + parseCliArgs(["https://example.com", "--color-scheme", "midnight"]), + ), + ).toThrow(/Invalid --color-scheme/); + }); + const cwd = process.cwd(); it("rejects calls without a positional URL", () => { diff --git a/src/runner.ts b/src/runner.ts index 2bbc171..ee9d05c 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -57,6 +57,10 @@ Options: --video Capture videos in addition to screenshots --video-duration Video duration when --video (default: 10000) --no-interactions Disable scripted scrolling during video + --color-scheme prefers-color-scheme to report: light, dark, or + no-preference (default: light). A site that + follows the OS theme renders light under headless + Chromium, so pass dark to capture its dark face. --no-warmup Skip the pre-screenshot warm-up scroll (warm-up triggers lazy-load + scroll-reveal animations so screenshots capture real content) @@ -70,6 +74,7 @@ Examples: ui-capture https://example.com ui-capture https://example.com --video --max-depth 1 --concurrency 4 ui-capture https://example.com --viewports desktop:1920x1080,mobile:390x844 + ui-capture https://example.com --color-scheme dark ui-capture https://example.com --launch-args "--enable-blink-features=CanvasDrawElement" `; @@ -180,6 +185,20 @@ export const buildInvocation = (parsed: ParsedArgs): CliInvocation => { const menuSelectors = parseList(opts["menu-selectors"]); if (menuSelectors) overrides.menuInteractionSelectors = menuSelectors; + const colorScheme = opts["color-scheme"]; + if (colorScheme !== undefined) { + if ( + colorScheme !== "light" && + colorScheme !== "dark" && + colorScheme !== "no-preference" + ) { + throw new Error( + `Invalid --color-scheme "${String(colorScheme)}". Expected light, dark, or no-preference.`, + ); + } + overrides.colorScheme = colorScheme; + } + if (opts.video === true) overrides.captureVideo = true; if (opts["no-warmup"] === true) overrides.warmupScroll = false; diff --git a/src/schemas.ts b/src/schemas.ts index f079e56..9e521f0 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -77,6 +77,15 @@ const CaptureConfigFields = { ffmpegPath: S.String, warmupScroll: S.Boolean, launchArgs: S.Array(S.String), + /** + * The `prefers-color-scheme` the browser reports to the page. + * + * Defaults to `"light"`, matching Playwright, so existing captures are + * unchanged. Sites that follow the OS theme render their light face under + * headless Chromium regardless of what their authors see day to day, so + * capturing such a site's dark face requires saying so explicitly. + */ + colorScheme: S.Literal("light", "dark", "no-preference"), }; export class CaptureConfig extends S.Class("CaptureConfig")( @@ -101,6 +110,7 @@ export class CaptureConfig extends S.Class("CaptureConfig")( ffmpegPath: "ffmpeg", warmupScroll: true, launchArgs: [], + colorScheme: "light", }); } @@ -151,6 +161,7 @@ export type CaptureConfigOverrides = Partial<{ ffmpegPath: string; warmupScroll: boolean; launchArgs: ReadonlyArray; + colorScheme: "light" | "dark" | "no-preference"; }>; const toViewportInstance = (viewport: ViewportConfigInput): ViewportConfig => @@ -201,5 +212,6 @@ export const createCaptureConfig = ( launchArgs: overrides.launchArgs ? Array.from(overrides.launchArgs) : base.launchArgs, + colorScheme: overrides.colorScheme ?? base.colorScheme, }); }; diff --git a/src/service.ts b/src/service.ts index ad10329..4c682e9 100644 --- a/src/service.ts +++ b/src/service.ts @@ -213,6 +213,7 @@ export class UICaptureService extends Effect.Service()( waitTime: cfg.waitTime, ffmpegPath: cfg.ffmpegPath, videoOptions: cfg.videoOptions, + colorScheme: cfg.colorScheme, }, ), ) @@ -439,7 +440,10 @@ export class UICaptureService extends Effect.Service()( } const browserRef = browser; const context = yield* Effect.tryPromise({ - try: () => browserRef.newContext(), + try: () => + browserRef.newContext({ + colorScheme: cfg.colorScheme, + }), catch: (error) => new CaptureError({ url, diff --git a/src/video.ts b/src/video.ts index f02114c..28ad413 100644 --- a/src/video.ts +++ b/src/video.ts @@ -35,6 +35,8 @@ export interface CaptureVideoConfig { readonly duration: number; readonly interactions: boolean; }; + /** Must match the screenshot context, or a run's stills and video disagree. */ + readonly colorScheme: "light" | "dark" | "no-preference"; } export const captureVideoForViewport = ( @@ -66,6 +68,7 @@ export const captureVideoForViewport = ( }, }, viewport: { width: viewport.width, height: viewport.height }, + colorScheme: cfg.colorScheme, }), catch: (error) => new CaptureError({