@ultimat3/scraping 17.0.0 → 19.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,7 @@ gate, offline included.
93
93
  | `page.query(selector)` | `ElementSnapshot[]` — the same matches PLUS `visible`, `enabled`, and the layout `box`/`hitTarget` a driver with a layout engine can answer. The read for a decision ABOUT an element |
94
94
  | `page.frame(nameOrSelector)` | a `ScrapeFrame`, re-resolved on every call through it |
95
95
  | `page.offline(enabled)` | cuts the BROWSER's network, or restores it |
96
+ | `page.colorScheme(scheme)` | what the browser reports as the user's OS colour preference — `'light'`, `'dark'`, or `'no-preference'` to CLEAR the override |
96
97
 
97
98
  `query()` exists because there is exactly one definition of "visible" in this framework and it is
98
99
  the port's. A caller that had to compute its own wrote
@@ -111,6 +112,31 @@ NAVIGATE a frame: a recorded frame is one static document, so a click inside one
111
112
  browser's own requests, so a driver that quietly answered "done" would let "a like taken offline is
112
113
  queued" pass against an app that was online for the whole test.
113
114
 
115
+ **`page.colorScheme()` sets the INPUT to a theme decision, never its outcome.** An attribute on the
116
+ document — `data-theme`, `class="dark"` — is the outcome, and the component owns it: one that
117
+ resolves `'system'` itself overwrites or deletes the attribute on mount, so a harness that set it is
118
+ silently overruled. Measured on `examples/dummy`, `As of 2026-08`: `x shot --island` photographed
119
+ every state in both themes and the two files came back byte-identical, same md5, from two addresses
120
+ that really did serve different documents. Emulating the preference is what reaches a component that
121
+ decides for itself; set the attribute as well for one that reads a theme it does not own.
122
+
123
+ **`'no-preference'` CLEARS the override, and is not a third value.** CDP treats an explicit
124
+ `prefers-color-scheme: no-preference` as an override and an EMPTY feature list as a reset, so that
125
+ is what this sends. Measured on Chrome 150 headless, `As of 2026-08`: after either one,
126
+ `(prefers-color-scheme: dark)` is false and `(prefers-color-scheme: light)` is true — the same
127
+ answers an untouched page gives. They diverge on a browser that has a real preference, where the
128
+ override forces the light answer and the reset gives the machine's own back, which is what this
129
+ value promises. (`no-preference` left the `prefers-color-scheme` query itself in 2020, so nothing
130
+ matches it in any of those readings.)
131
+
132
+ Accepted and RECORDED on the offline drivers rather than refused, which is the opposite of
133
+ `offline()` — and the line between them is which side of a capture the verb is on. An offline
134
+ *assertion* is reachable on a driver that answers content, so a resolved `setOfflineMode` would let
135
+ it pass against an app that never went offline; a colour preference has no such assertion, and the
136
+ only thing it could be wrong about is a picture. So the offline drivers answer **different
137
+ deterministic bytes per scheme**, exactly as they already do per `clip` — a fake returning one
138
+ constant for both themes is precisely what let the defect above ship.
139
+
114
140
  ## What the page reports back, and the one thing a picture cannot say
115
141
 
116
142
  Three bounded rings, read off `ScrapePage`. Bounded because a ten-thousand-page run that kept
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "17.0.0",
3
+ "version": "19.0.0",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,16 +23,16 @@
23
23
  "LICENSE"
24
24
  ],
25
25
  "engines": {
26
- "bun": ">=1.3.0"
26
+ "bun": ">=1.4.0"
27
27
  },
28
28
  "scripts": {
29
29
  "typecheck": "tsc --noEmit -p tsconfig.json",
30
30
  "test": "bun test"
31
31
  },
32
32
  "dependencies": {
33
- "@ultimat3/core": "17.0.0",
34
- "@ultimat3/jobs": "17.0.0",
35
- "@ultimat3/schema": "17.0.0",
36
- "@ultimat3/storage": "17.0.0"
33
+ "@ultimat3/core": "19.0.0",
34
+ "@ultimat3/jobs": "19.0.0",
35
+ "@ultimat3/schema": "19.0.0",
36
+ "@ultimat3/storage": "19.0.0"
37
37
  }
38
38
  }
package/src/cdp-fake.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // Precedent: `packages/storage/src/driver-s3-fixture.ts` ships the same way.
10
10
 
11
11
  import type { CdpBrowserLike, CdpFrameLike, CdpLauncherLike, CdpPageLike } from './cdp-port';
12
+ import { COLOR_SCHEME_FEATURE } from './color-scheme';
12
13
  import { queryHtml } from './html-query';
13
14
  import type { ElementSnapshot, ScrapeCookie } from './target';
14
15
 
@@ -70,6 +71,12 @@ export interface FakeCdpBrowser extends CdpBrowserLike {
70
71
  readonly closed: boolean;
71
72
  /** What `page.setOfflineMode()` last set, so a test asserts on the CONDITION, not on a call. */
72
73
  readonly offline: boolean;
74
+ /**
75
+ * What `page.emulateMediaFeatures()` last set `prefers-color-scheme` to, for `offline`'s reason
76
+ * — the condition the page is now in, never the fact that a method was called. `null` until
77
+ * something sets one, which is the launcher's own default and not a value this fake invents.
78
+ */
79
+ readonly colorScheme: string | null;
73
80
  }
74
81
 
75
82
  /** A layout box every element gets, so the CDP path exercises the fields the fake target lacks. */
@@ -113,6 +120,7 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
113
120
  let html = init.html;
114
121
  let closed = false;
115
122
  let offline = false;
123
+ let colorScheme: string | null = null;
116
124
  const covered = new Set(init.covered ?? []);
117
125
  const storage: Record<string, string> = { ...init.storage };
118
126
  let cookies: readonly ScrapeCookie[] = init.cookies ?? [];
@@ -202,6 +210,20 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
202
210
  offline = enabled;
203
211
  return Promise.resolve();
204
212
  },
213
+ // CDP's own two rules, both of them. An EMPTY list is a RESET — measured on Chrome 150, after
214
+ // `emulateMediaFeatures([])` the page answers exactly what an untouched one does — so it must
215
+ // clear here rather than be ignored, or `'no-preference'` records the previous scheme forever.
216
+ // And a non-empty list is read by NAME, never `features[0].value`: the port's shape is a list,
217
+ // and a caller setting `prefers-reduced-motion` beside the scheme must not overwrite it.
218
+ emulateMediaFeatures: (features: readonly { name: string; value: string }[]) => {
219
+ if (features.length === 0) {
220
+ colorScheme = null;
221
+ return Promise.resolve();
222
+ }
223
+ const found = features.find((feature) => feature.name === COLOR_SCHEME_FEATURE);
224
+ if (found !== undefined) colorScheme = found.value;
225
+ return Promise.resolve();
226
+ },
205
227
  on: (event: string, handler: (payload: unknown) => void) => {
206
228
  const listeners = handlers.get(event) ?? [];
207
229
  listeners.push(handler);
@@ -250,6 +272,9 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
250
272
  get offline(): boolean {
251
273
  return offline;
252
274
  },
275
+ get colorScheme(): string | null {
276
+ return colorScheme;
277
+ },
253
278
  };
254
279
  }
255
280
 
package/src/cdp-port.ts CHANGED
@@ -69,6 +69,22 @@ export interface CdpPageLike {
69
69
  * a type error for a capability the port cannot make them have.
70
70
  */
71
71
  setOfflineMode?(enabled: boolean): Promise<void>;
72
+ /**
73
+ * The browser's own media features — `Emulation.setEmulatedMedia` under CDP. `prefers-color-scheme`
74
+ * is the one this package sets; the array shape is the library's and is restated, not narrowed,
75
+ * because this file is the shape of somebody ELSE's object.
76
+ *
77
+ * OPTIONAL, read defensively, for `setOfflineMode`'s reason: a provider SDK or a launcher that
78
+ * predates the method must still satisfy the port. It does not go unwired by being optional —
79
+ * `cdp-target.ts` refuses BY NAME with `X_NOT_IMPLEMENTED` and a fix when a launcher lacks it.
80
+ *
81
+ * `name` stays a bare `string`. A union of the features this package sets would be this file
82
+ * naming somebody else's vocabulary, and a launcher accepting more would then fail to satisfy
83
+ * the port for no reason — the argument `on(event: string, …)` already makes.
84
+ */
85
+ emulateMediaFeatures?(
86
+ features: readonly { readonly name: string; readonly value: string }[],
87
+ ): Promise<void>;
72
88
  /**
73
89
  * `event` stays a bare `string` — a union of the four names this package subscribes to would be
74
90
  * this file naming somebody else's event vocabulary, which is the thing it exists not to do, and
package/src/cdp-target.ts CHANGED
@@ -8,6 +8,7 @@ import { browserRecord } from './browser-record';
8
8
  import type { CdpBrowserLike, CdpFrameLike, CdpPageLike, CdpRequestLike } from './cdp-port';
9
9
  import { clearExpression, parseSnapshots, snapshotExpression } from './cdp-snapshot';
10
10
  import type { ScrapeClock } from './clock';
11
+ import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
11
12
  import { browserUnreachable, pageCrashed, scrapeNotImplemented } from './error-throws';
12
13
  import type { InterceptRules } from './intercept';
13
14
  import { interceptVerdict, refusalEntry } from './intercept';
@@ -361,6 +362,23 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
361
362
  }
362
363
  await source.setOfflineMode(enabled);
363
364
  }),
365
+ setColorScheme: (scheme: ColorScheme): Promise<void> =>
366
+ guard('setColorScheme', async () => {
367
+ // `CdpPageLike` already declares the member optional, so it is READ, never re-declared in
368
+ // a local cast: a second copy of somebody else's shape is a copy that can drift from the
369
+ // seam it is standing in for (axiom 2). `setOfflineMode` above still casts and is the
370
+ // older spelling.
371
+ const emulate = init.page.emulateMediaFeatures;
372
+ if (typeof emulate !== 'function') {
373
+ throw scrapeNotImplemented(
374
+ 'setColorScheme() on a CDP page with no emulateMediaFeatures() method',
375
+ 'upgrade the launcher to a puppeteer-core that exposes page.emulateMediaFeatures(), or have the page under test set its own theme',
376
+ );
377
+ }
378
+ // `.call`, because the member is read off the object: an unbound method loses `this` and
379
+ // puppeteer's own `Page` needs it.
380
+ await emulate.call(init.page, colorSchemeFeatures(scheme));
381
+ }),
364
382
  screenshot: (options: CaptureOptions) =>
365
383
  guard('screenshot', async () => {
366
384
  // `fullPage` is OMITTED when a clip is given rather than sent as `false`: the two are
@@ -0,0 +1,49 @@
1
+ // The OS-level colour preference a page is shown, and the CDP media feature that carries it.
2
+ //
3
+ // Its own module for `capture-clip.ts`'s reason: the vocabulary belongs to EVERY driver
4
+ // identically, and `target.ts` is a port declaration, which is not a place a decision lives.
5
+
6
+ /**
7
+ * What a browser is told the user prefers — the INPUT to a theme decision, never its outcome.
8
+ *
9
+ * Setting `data-theme` on the document models the outcome, and the component owns that: a
10
+ * component that resolves `'system'` itself deletes or overwrites the attribute on mount, so a
11
+ * harness that set it is silently overruled and both themes converge on one picture. Measured on
12
+ * `examples/dummy`'s settings island (issue #338): `<state>-light.png` and `<state>-dark.png` came
13
+ * back byte-identical, same md5, from two addresses that really did serve different documents.
14
+ *
15
+ * The set is closed at THREE. `'light'` and `'dark'` are CSS's own values; `'no-preference'` is
16
+ * this vocabulary's way of saying **clear the override**, and it earns its place because without
17
+ * it a preference, once set, is permanent for the session.
18
+ *
19
+ * It is a CLEAR and not a value, and the difference is measured. CDP's `Emulation.setEmulatedMedia`
20
+ * treats an explicit `prefers-color-scheme: no-preference` as an OVERRIDE and an EMPTY feature list
21
+ * as a reset, so `colorSchemeFeatures` sends the empty list. On Chrome 150 headless the two are
22
+ * observationally identical — after either, `(prefers-color-scheme: dark)` is false,
23
+ * `(prefers-color-scheme: light)` is true, and `(prefers-color-scheme: no-preference)` is false,
24
+ * which is also what an untouched page answers. They diverge on a browser that HAS a real
25
+ * preference: the override forces the light-equivalent answer, while the reset gives the machine's
26
+ * own back — and "the launcher's default" is what this value promises.
27
+ *
28
+ * `no-preference` was dropped from the `prefers-color-scheme` media query in 2020, which is why
29
+ * the third row above is false in every one of those readings. It is a control word here, never a
30
+ * query a stylesheet can match.
31
+ */
32
+ export const COLOR_SCHEMES = ['light', 'dark', 'no-preference'] as const;
33
+ export type ColorScheme = (typeof COLOR_SCHEMES)[number];
34
+
35
+ /** CSS's own feature name, and CDP's. One constant, because two spellings is a silent no-op. */
36
+ export const COLOR_SCHEME_FEATURE = 'prefers-color-scheme';
37
+
38
+ export function isColorScheme(value: unknown): value is ColorScheme {
39
+ return typeof value === 'string' && COLOR_SCHEMES.includes(value as ColorScheme);
40
+ }
41
+
42
+ /**
43
+ * The CDP feature list for one scheme — the ONE place the clear is spelled, so a driver and a fake
44
+ * cannot disagree about what `'no-preference'` means.
45
+ */
46
+ export const colorSchemeFeatures = (
47
+ scheme: ColorScheme,
48
+ ): readonly { readonly name: string; readonly value: string }[] =>
49
+ scheme === 'no-preference' ? [] : [{ name: COLOR_SCHEME_FEATURE, value: scheme }];
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { CaptureClip } from './capture-clip';
9
9
  import type { ScrapeClock } from './clock';
10
+ import type { ColorScheme } from './color-scheme';
10
11
  import {
11
12
  browserUnreachable,
12
13
  downloadTimeout,
@@ -38,15 +39,27 @@ import type {
38
39
  const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
39
40
 
40
41
  /**
41
- * A clipped capture answers DIFFERENT deterministic bytes, one picture per rectangle. CI has no
42
- * Chrome, so the offline drivers are the only place a crop can be proved at all: a fake that
43
- * answered the same eight bytes whatever the rectangle would let a driver that silently drops the
44
- * clip pass every test there is. Unclipped bytes are unchanged, byte for byte.
42
+ * A capture answers DIFFERENT deterministic bytes for every framing fact that was set — one
43
+ * picture per rectangle, one per colour preference. CI has no Chrome, so the offline drivers are
44
+ * the only place a framing knob can be proved at all: a fake that answered the same eight bytes
45
+ * whatever the rectangle would let a driver that silently drops the clip pass every test there is.
46
+ *
47
+ * The colour scheme joined that rule on 2026-08-26, and it is the same defect one axis over. `x
48
+ * shot --island` photographed every state twice and delivered the second picture as a byte-for-
49
+ * byte COPY of the first (issue #338), and no test could see it because this fake answered one
50
+ * constant for both themes.
51
+ *
52
+ * Bytes with NO framing fact set are unchanged, and clip-only bytes are unchanged too — the notes
53
+ * append in a fixed order, so every digest asserted before this existed still holds.
45
54
  */
46
- const clippedPng = (clip: CaptureClip): Uint8Array => {
47
- const suffix = new TextEncoder().encode(
48
- ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`,
49
- );
55
+ const framedPng = (clip: CaptureClip | undefined, scheme: ColorScheme | null): Uint8Array => {
56
+ const notes =
57
+ (clip === undefined
58
+ ? ''
59
+ : ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`) +
60
+ (scheme === null ? '' : ` scheme ${scheme}`);
61
+ if (notes === '') return FAKE_PNG;
62
+ const suffix = new TextEncoder().encode(notes);
50
63
  const out = new Uint8Array(FAKE_PNG.length + suffix.length);
51
64
  out.set(FAKE_PNG);
52
65
  out.set(suffix, FAKE_PNG.length);
@@ -127,6 +140,9 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
127
140
  const frameOverlays = new Map<string, Map<string, string>>();
128
141
  let page: PageRecording = init.start ?? EMPTY;
129
142
  let armed: string | undefined;
143
+ // `null` and not `'no-preference'`: nothing set one, which is the launcher's own default and not
144
+ // a value this driver invents — and it is what keeps an unframed picture's bytes unchanged.
145
+ let colorScheme: ColorScheme | null = null;
130
146
  let closed = false;
131
147
  let session: SessionSnapshot = init.session ?? {
132
148
  ...EMPTY_SESSION,
@@ -333,8 +349,34 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
333
349
  'run this assertion on localBrowser()/remoteBrowser(), whose setOfflineMode() reaches a real browser — an offline driver has no network to cut, so it cannot prove an offline behaviour',
334
350
  );
335
351
  },
352
+ /**
353
+ * ACCEPTED and recorded, where `setOfflineMode` refuses — and the line between them is which
354
+ * side of a capture the verb is on, not whether this driver has a browser.
355
+ *
356
+ * `setOfflineMode` is refused because an offline ASSERTION is reachable here: this driver
357
+ * answers content, so `expect(page).toShow('queued')` would go green against an app that was
358
+ * online the whole time. A colour preference has no such assertion to pass. Nothing here
359
+ * evaluates CSS, and the only thing a preference could be wrong ABOUT is a picture — which on
360
+ * this driver is `FAKE_PNG`, a constant that claims nothing about a theme, exactly as `pdf()`
361
+ * answers `FAKE_PDF` rather than refusing.
362
+ *
363
+ * Refusing would cost the capability its unit test. `x shot --island` is proved end to end on
364
+ * a machine with no Chrome by driving this driver, so a refusal here makes the command
365
+ * untestable offline — and the picture it would be protecting does not exist.
366
+ *
367
+ * Observable through the PICTURE, which is the only place it could ever be wrong: `framedPng`
368
+ * answers different deterministic bytes per scheme, exactly as it already does per clip, so a
369
+ * driver that silently dropped the preference fails a test rather than passing every one.
370
+ */
371
+ setColorScheme(scheme: ColorScheme): Promise<void> {
372
+ // `'no-preference'` is a CLEAR on the real driver, so it is a clear here — and the picture
373
+ // goes back to the unframed bytes, which is the same fact stated in the one place a fake can
374
+ // state it.
375
+ colorScheme = scheme === 'no-preference' ? null : scheme;
376
+ return Promise.resolve();
377
+ },
336
378
  screenshot: (options: CaptureOptions): Promise<Uint8Array> =>
337
- Promise.resolve(options.clip === undefined ? FAKE_PNG : clippedPng(options.clip)),
379
+ Promise.resolve(framedPng(options.clip, colorScheme)),
338
380
  pdf: (_options: CaptureOptions): Promise<Uint8Array> => Promise.resolve(FAKE_PDF),
339
381
  cookies: (): Promise<readonly ScrapeCookie[]> => Promise.resolve(session.cookies),
340
382
  session: (): Promise<SessionSnapshot> => Promise.resolve(session),
package/src/index.ts CHANGED
@@ -34,6 +34,8 @@ export type { CdpTargetInit } from './cdp-target';
34
34
  export { CDP_DRIVER, cdpTarget } from './cdp-target';
35
35
  export type { Deadline, ScrapeClock, TestScrapeClock } from './clock';
36
36
  export { deadline, systemScrapeClock, testClock, throwIfAborted } from './clock';
37
+ export type { ColorScheme } from './color-scheme';
38
+ export { COLOR_SCHEME_FEATURE, COLOR_SCHEMES, isColorScheme } from './color-scheme';
37
39
  export {
38
40
  cookieDomainMatches,
39
41
  cookieHeaderFor,
@@ -10,6 +10,7 @@ import { awaitActionable } from './actionability';
10
10
  import { assertCaptureFraming } from './capture-clip';
11
11
  import type { ScrapeClock } from './clock';
12
12
  import { deadline } from './clock';
13
+ import type { ColorScheme } from './color-scheme';
13
14
  import { hostBlocked, secretExposed, selectorMissing } from './error-throws';
14
15
  import { hostDecision } from './hosts';
15
16
  import type {
@@ -243,6 +244,11 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
243
244
  async offline(enabled: boolean): Promise<void> {
244
245
  await target.setOfflineMode(enabled);
245
246
  },
247
+ // `async`, for `offline()`'s reason: a driver that throws synchronously from a promise-typed
248
+ // method would escape the caller's `.catch()`.
249
+ async colorScheme(scheme: ColorScheme): Promise<void> {
250
+ await target.setColorScheme(scheme);
251
+ },
246
252
  cookies: (): Promise<readonly ScrapeCookie[]> => target.cookies(),
247
253
  session: () => target.session(),
248
254
  // Redacted BY VALUE on the way out, the same pass `html()` makes. A console line and a request
package/src/page.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import type { Secret } from '@ultimat3/core';
11
11
  import type { ActionabilityState } from './actionability';
12
12
  import type { CaptureFraming } from './capture-clip';
13
+ import type { ColorScheme } from './color-scheme';
13
14
  import type { ConsoleLine, NetworkEntry, PageError } from './rings';
14
15
  import type { SessionSnapshot } from './session-state';
15
16
  import type { ElementSnapshot, ScrapeCookie, ScrapeDownloadFile } from './target';
@@ -109,6 +110,21 @@ export interface ScrapePage extends ScrapeFrame {
109
110
  * "done" would let an offline assertion pass against an app that never went offline.
110
111
  */
111
112
  offline(enabled: boolean): Promise<void>;
113
+ /**
114
+ * Tell the browser what the user's OS colour preference is, so a component that resolves its own
115
+ * theme resolves to this one. `'no-preference'` CLEARS the override and gives the browser's own
116
+ * answer back — it is not a third value a stylesheet can match.
117
+ *
118
+ * The INPUT, never the outcome: an attribute on the document is the outcome of a theme decision
119
+ * and the component owns that — one honouring `'system'` overwrites or deletes it on mount, and
120
+ * the picture then shows the theme the component chose rather than the one that was asked for.
121
+ *
122
+ * ACCEPTED on every driver, unlike `offline()`: the offline drivers evaluate no CSS, but they
123
+ * answer different deterministic bytes per scheme, so the one thing a preference could be wrong
124
+ * about is still under test. `X_NOT_IMPLEMENTED` is reserved for a LAUNCHER that lacks the
125
+ * method, which is a fact about the build rather than about the driver.
126
+ */
127
+ colorScheme(scheme: ColorScheme): Promise<void>;
112
128
  /**
113
129
  * The handoff, made explicit: what the HTTP leg will send, as a value an author can inspect and
114
130
  * a fixture can assert on. `http` uses it automatically — this is for seeing what carried over.
package/src/target.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // fixture replayer and the fake alike.
5
5
 
6
6
  import type { CaptureFraming } from './capture-clip';
7
+ import type { ColorScheme } from './color-scheme';
7
8
  import type { ConsoleRing, NetworkRing, PageErrorRing } from './rings';
8
9
  import type { SessionSnapshot } from './session-state';
9
10
 
@@ -141,6 +142,28 @@ export interface ScrapeTarget {
141
142
  * green against an app that was online the whole time.
142
143
  */
143
144
  setOfflineMode(enabled: boolean): Promise<void>;
145
+ /**
146
+ * What the browser reports as the user's OS colour preference — the INPUT to a theme decision.
147
+ * `'no-preference'` CLEARS the override rather than setting one (`color-scheme.ts`).
148
+ *
149
+ * The INPUT and never the outcome. Setting `data-theme` on the document models the outcome, and
150
+ * the component owns that: one that resolves `'system'` itself overwrites or deletes the
151
+ * attribute on mount, so a harness that set it is silently overruled and every theme converges
152
+ * on one picture — measured, and byte-identical, in issue #338.
153
+ *
154
+ * REQUIRED here where `CdpPageLike.emulateMediaFeatures` is optional, for `setOfflineMode`'s
155
+ * reason: the asymmetry is the enforcement, and a driver author gets a type error naming this
156
+ * member rather than a silent no-op. A launcher without the method is `X_NOT_IMPLEMENTED`.
157
+ *
158
+ * A DRIVER WITH NO CSS ENGINE ACCEPTS IT, which is the one place this parts company with
159
+ * `setOfflineMode`, and the line between them is which side of a capture the verb is on. An
160
+ * offline ASSERTION is reachable on a driver that answers content, so a resolved
161
+ * `setOfflineMode` would let it pass against an app that never went offline. A colour preference
162
+ * has no such assertion: the only thing it could be wrong about is a picture, and the offline
163
+ * drivers answer different deterministic bytes per scheme — exactly as they do per `clip` —
164
+ * so a driver that dropped the preference fails a test rather than passing every one.
165
+ */
166
+ setColorScheme(scheme: ColorScheme): Promise<void>;
144
167
  screenshot(options: CaptureOptions): Promise<Uint8Array>;
145
168
  pdf(options: CaptureOptions): Promise<Uint8Array>;
146
169
  cookies(): Promise<readonly ScrapeCookie[]>;