Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ Options:
--video Capture videos in addition to screenshots
--video-duration <ms> Video duration when --video (default: 10000)
--no-interactions Disable scripted scrolling during video
--color-scheme <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 <path> ffmpeg binary path (default: ffmpeg)
--launch-args <args> Extra Chromium switches, whitespace separated
Expand All @@ -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
Expand Down Expand Up @@ -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. |

Expand Down
22 changes: 22 additions & 0 deletions src/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
19 changes: 19 additions & 0 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Options:
--video Capture videos in addition to screenshots
--video-duration <ms> Video duration when --video (default: 10000)
--no-interactions Disable scripted scrolling during video
--color-scheme <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)
Expand All @@ -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"
`;

Expand Down Expand Up @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>("CaptureConfig")(
Expand All @@ -101,6 +110,7 @@ export class CaptureConfig extends S.Class<CaptureConfig>("CaptureConfig")(
ffmpegPath: "ffmpeg",
warmupScroll: true,
launchArgs: [],
colorScheme: "light",
});
}

Expand Down Expand Up @@ -151,6 +161,7 @@ export type CaptureConfigOverrides = Partial<{
ffmpegPath: string;
warmupScroll: boolean;
launchArgs: ReadonlyArray<string>;
colorScheme: "light" | "dark" | "no-preference";
}>;

const toViewportInstance = (viewport: ViewportConfigInput): ViewportConfig =>
Expand Down Expand Up @@ -201,5 +212,6 @@ export const createCaptureConfig = (
launchArgs: overrides.launchArgs
? Array.from(overrides.launchArgs)
: base.launchArgs,
colorScheme: overrides.colorScheme ?? base.colorScheme,
});
};
6 changes: 5 additions & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export class UICaptureService extends Effect.Service<UICaptureService>()(
waitTime: cfg.waitTime,
ffmpegPath: cfg.ffmpegPath,
videoOptions: cfg.videoOptions,
colorScheme: cfg.colorScheme,
},
),
)
Expand Down Expand Up @@ -439,7 +440,10 @@ export class UICaptureService extends Effect.Service<UICaptureService>()(
}
const browserRef = browser;
const context = yield* Effect.tryPromise({
try: () => browserRef.newContext(),
try: () =>
browserRef.newContext({
colorScheme: cfg.colorScheme,
}),
catch: (error) =>
new CaptureError({
url,
Expand Down
3 changes: 3 additions & 0 deletions src/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -66,6 +68,7 @@ export const captureVideoForViewport = (
},
},
viewport: { width: viewport.width, height: viewport.height },
colorScheme: cfg.colorScheme,
}),
catch: (error) =>
new CaptureError({
Expand Down
Loading