image-deep-research 0.0.0-stage → 1.2.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.
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: image-deep-research
3
+ description: Image deep research and visual research - use when a question is better answered by looking than by reading. Finds reference images on open, licensed collections (Openverse, Wikimedia Commons, the Art Institute of Chicago, the Met), renders real websites into screenshot contact sheets, builds a moodboard, measures a live site's palette and typefaces, and reads every picture as a picture. For design references, styles, palettes, typefaces, materials, competitors and moodboards.
4
+ argument-hint: "<topic, style, palette, typeface, competitor list, or URLs to look at>"
5
+ ---
6
+
7
+ # Image deep research
8
+
9
+ Most design questions are answered by looking at twenty examples, not by
10
+ reading one article about them. This skill gets the pictures in front of you
11
+ cheaply, checks that they are real, keeps the licence beside every one, and
12
+ then makes you read them before you say anything about them.
13
+
14
+ The request is whatever the user typed with the skill. If it is empty, ask one
15
+ question: what should be looked at.
16
+
17
+ ## Where the scripts are
18
+
19
+ The scripts live in `scripts/` next to this file. In Claude Code that folder
20
+ is `${CLAUDE_SKILL_DIR}/scripts`. In Codex, or anywhere that variable comes
21
+ through empty, use the `scripts` folder beside this SKILL.md. They need Node 22
22
+ or newer and nothing else; rendering needs Chrome, Edge or Chromium (set
23
+ `IDR_BROWSER` to the executable if it is somewhere unusual). The browser runs
24
+ headless and never opens a window.
25
+
26
+ ## The loop
27
+
28
+ Deep research here means rounds, not one search:
29
+
30
+ 1. **Write the question down** in a form a picture can answer. "What do
31
+ luxury watch sites look like" produces mush; "what ground colour, what
32
+ headline size, and what the first screen shows on six luxury watch sites"
33
+ produces numbers you can build with.
34
+ 2. **Gather** from the two sources below: real sites for how a thing is done
35
+ on the web, open collections for subjects, palettes, materials and art.
36
+ 3. **Read the contact sheets**, then only the two or three individual images
37
+ worth a closer look. A sheet of eight costs one read instead of eight.
38
+ In a coding session or for a first look, add `--compact` to either
39
+ script: one sheet of up to 16 numbered tiles, 1288x812 and at most 1,334
40
+ image tokens on any Claude model, one text line per result or site, no
41
+ URLs (`images.mjs --pick 3,7 --results <path>` prints the ones you keep).
42
+ Open a single image only for what the sheet and the numbers cannot answer.
43
+ 4. **Refine and go again.** The first round tells you the right words: the
44
+ name of the style, the photographer, the typeface, the movement. Search
45
+ again with them. Stop when a new round stops changing the answer.
46
+ 5. **Report** (see the end of this file).
47
+
48
+ ## Render real sites - the highest-signal source
49
+
50
+ ```bash
51
+ node "${CLAUDE_SKILL_DIR}/scripts/study.mjs" <url> <url> ...
52
+ node "${CLAUDE_SKILL_DIR}/scripts/study.mjs" --list editorial
53
+ ```
54
+
55
+ Each site is rendered in a headless browser at the top and one screen down
56
+ (`--scroll 0,900`, `--width 390` for phones, `--reduced-motion` for comparison), tiled into contact sheets
57
+ eight to a sheet, and measured: ground colours by element area (overlaps can count twice), text
58
+ colours by amount of text, and the heading and body typefaces as the browser
59
+ computed them. The measurements print per site and land in `report.json`
60
+ beside the sheets. Curated lists: `editorial`, `object`, `cinema`, `product`.
61
+ Shots record viewport, scroll, time and motion preference; stills do not prove motion.
62
+
63
+ Two things it cannot see, and it says so rather than guessing: a page that
64
+ answers with a bot challenge or Access Denied, or renders next to nothing (a
65
+ preloader, an empty script shell), is reported as a wall with the reason and
66
+ kept off the sheet; and a canvas-heavy page is flagged, because its
67
+ screenshot may be a preloader frame. For those, try the `y900`
68
+ tile, a longer `--wait 8000`, or a video walkthrough of the site.
69
+
70
+ ## Find images on open collections
71
+
72
+ ```bash
73
+ node "${CLAUDE_SKILL_DIR}/scripts/images.mjs" "<query>" --sheet
74
+ node "${CLAUDE_SKILL_DIR}/scripts/images.mjs" "<query>" --commercial --download --sources openverse,commons
75
+ ```
76
+
77
+ Searches Openverse (Creative Commons and public-domain images from Flickr,
78
+ museums and more), Wikimedia Commons, the Art Institute of Chicago (public
79
+ domain works only) and the Met (Open Access works only). No keys. Every result
80
+ URL is fetched before it is reported, and one that does not answer with an
81
+ image is marked FAIL and never put on the moodboard. `--commercial` keeps only
82
+ licences that allow commercial use and changes (it drops NC and ND). `--sheet`
83
+ tiles the verified images into a numbered moodboard; `--download` saves them.
84
+ Results, with title, creator, licence, licence URL and source page, go to
85
+ `results.json`.
86
+ URL checks cover headers, not rights. Sheets cache bounded rasters; read source licences.
87
+
88
+ Unsplash and Pexels need a free key for their APIs; with one, their CDN URLs
89
+ can be used directly. Check them the same way: an image URL is not a result
90
+ until it has answered with an image.
91
+
92
+ ## Design galleries, for finding sites worth rendering
93
+
94
+ Awwwards, Godly, Land-book, SiteInspire, Minimal Gallery, Curated.design,
95
+ One Page Love. Fetch the gallery page, pull out the outbound site URLs, then
96
+ render them with `study.mjs`. A gallery's own thumbnails are small and
97
+ colour-shifted: render the real site instead.
98
+
99
+ ## Inside Ultimate Frontend Skills
100
+
101
+ This skill also ships inside the Ultimate Frontend Skills plugin. When
102
+ `${CLAUDE_PLUGIN_ROOT}/scripts/webdesign.mjs` exists (in Codex: a
103
+ `scripts/webdesign.mjs` in the plugin folder that holds `.codex-plugin/`),
104
+ there is a corpus of award-winning sites to pick references from by
105
+ technique:
106
+
107
+ ```bash
108
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/webdesign.mjs" awards "<topic>" --verbose
109
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/webdesign.mjs" study --awards "<topic>" --n 6
110
+ ```
111
+
112
+ When that file is not there, this is the standalone plugin: skip this
113
+ section and find sites through the galleries above.
114
+
115
+ ## Reading what you got
116
+
117
+ Go in with the question from step 1. Worth extracting every time: the type
118
+ pairing and sizes, the ground colours, where the accent appears, section
119
+ rhythm, what the first screen does, and the one move you have not seen before.
120
+ The `report.json` numbers are what the browser computed; the sheet is what a
121
+ person sees. Use both, and say which one a claim comes from.
122
+
123
+ ## Report
124
+
125
+ - The file paths of the contact sheets and the moodboard.
126
+ - Every image you recommend with its verified URL, creator, licence and
127
+ source page. Nothing without a licence goes in a deliverable.
128
+ - Per reference, the one concrete move worth taking, with the number
129
+ (a colour, a size, a spacing) when there is one.
130
+ - What could not be seen (walls, preloaders, failed URLs), said plainly.
131
+
132
+ ## Do not
133
+
134
+ Do not describe an image you have not opened. Do not treat a gallery thumbnail
135
+ as evidence of colour. Do not conclude a site uses a library because the
136
+ effect looks like it - check the bundle. Do not ship an image whose licence
137
+ you have not read, and do not drop the attribution a CC BY licence requires.
@@ -0,0 +1,27 @@
1
+ /* A small flag parser shared by the scripts: `--name value`, `--name=value`
2
+ and bare `--switch`. A value flag with nothing after it is a usage error,
3
+ reported as one line rather than a stack trace. */
4
+ export function parseArgs(argv, { switches = [] } = {}) {
5
+ const positional = [];
6
+ const flags = {};
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const a = argv[i];
9
+ if (!a.startsWith('--') || a === '--') { positional.push(a); continue; }
10
+ const eq = a.indexOf('=');
11
+ const name = a.slice(2, eq === -1 ? undefined : eq);
12
+ if (eq !== -1) { flags[name] = a.slice(eq + 1); continue; }
13
+ if (switches.includes(name)) { flags[name] = true; continue; }
14
+ const next = argv[i + 1];
15
+ if (next === undefined || next.startsWith('--')) throw new Error(`--${name} needs a value`);
16
+ flags[name] = next;
17
+ i++;
18
+ }
19
+ return { positional, flags };
20
+ }
21
+
22
+ export const int = (v, fallback, { min = -Infinity, max = Infinity } = {}) => {
23
+ if (v === undefined) return fallback;
24
+ const n = Number(v);
25
+ if (!Number.isInteger(n) || n < min || n > max) throw new Error(`expected a whole number between ${min} and ${max}, got "${v}"`);
26
+ return n;
27
+ };
@@ -0,0 +1,300 @@
1
+ /* image-deep-research/browser - find, start and drive a headless Chrome,
2
+ Edge or Chromium over the DevTools protocol, with no dependencies.
3
+
4
+ Vendored from Ultimate Frontend Skills scripts/inspect.mjs (MIT, Gev,
5
+ github.com/ridelink0/ultimate-frontend-skills): findBrowser, readPortFile,
6
+ the port handshake in launch() and the Session class are the same code,
7
+ trimmed to what a screenshot needs. The fixes they carry are kept, because
8
+ each one was a real failure there: the half-written DevToolsActivePort file,
9
+ Edge 153 no longer writing that file at all, and the launcher that hands the
10
+ browser to a child and exits.
11
+
12
+ The browser runs --headless=new, so it never opens a window on anyone's
13
+ screen. Needs Node 22+ for the built-in WebSocket. */
14
+
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { mkdtempSync, mkdirSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
17
+ import { join, posix } from 'node:path';
18
+ import { tmpdir, homedir } from 'node:os';
19
+ import { createServer } from 'node:net';
20
+
21
+ function candidates(platform, env) {
22
+ return platform === 'win32'
23
+ ? [
24
+ `${env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
25
+ `${env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
26
+ `${env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
27
+ `${env['PROGRAMFILES(X86)']}\\Microsoft\\Edge\\Application\\msedge.exe`,
28
+ `${env.PROGRAMFILES}\\Microsoft\\Edge\\Application\\msedge.exe`,
29
+ ]
30
+ : platform === 'darwin'
31
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
32
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
33
+ '/Applications/Chromium.app/Contents/MacOS/Chromium']
34
+ : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium',
35
+ '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge', '/snap/bin/chromium'];
36
+ }
37
+
38
+ /* The Chromium builds Playwright downloads, newest revision first in each
39
+ folder: $PLAYWRIGHT_BROWSERS_PATH, the per-user cache, and /opt/pw-browsers,
40
+ where containers and cloud sandboxes (this repo's own test container among
41
+ them, 2026-09-29) keep one and nothing else. Up to Playwright 1.56 the
42
+ binary sits in chrome-linux/; later ones install Chrome for Testing into
43
+ chrome-linux64/ (x64) or chrome-linux-arm64/ (read from playwright-core
44
+ 1.56.1 and 1.63.0's registries). */
45
+ const PW_LAYOUTS = ['chrome-linux', 'chrome-linux64', 'chrome-linux-arm64'];
46
+
47
+ function playwrightChromes({ env, home, readdir }) {
48
+ const bases = [env.PLAYWRIGHT_BROWSERS_PATH, home && posix.join(home, '.cache', 'ms-playwright'), '/opt/pw-browsers'];
49
+ const out = [];
50
+ for (const base of [...new Set(bases.filter(Boolean))]) {
51
+ let names;
52
+ try { names = readdir(base); } catch { continue; }
53
+ const revs = names.map((n) => /^chromium-(\d+)$/.exec(n)).filter(Boolean).sort((a, b) => b[1] - a[1]);
54
+ for (const m of revs) for (const d of PW_LAYOUTS) out.push(posix.join(base, m[0], d, 'chrome'));
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /* IDR_BROWSER names the executable. ATELIER_BROWSER is the variable Ultimate
60
+ Frontend Skills reads, honoured too so one setting serves both. Everything
61
+ the lookup touches can be injected, so a test can stand in a file system. */
62
+ export function findBrowser({ env = process.env, platform = process.platform, home = homedir(),
63
+ exists = existsSync, readdir = (d) => readdirSync(d) } = {}) {
64
+ for (const v of ['IDR_BROWSER', 'ATELIER_BROWSER']) {
65
+ const p = env[v];
66
+ if (p && exists(p)) return p;
67
+ }
68
+ const found = candidates(platform, env).find((p) => p && exists(p));
69
+ if (found) return found;
70
+ if (platform !== 'linux') return null;
71
+ return playwrightChromes({ env, home, readdir }).find((p) => exists(p)) || null;
72
+ }
73
+
74
+ /* Chrome refuses to start as root unless its sandbox is switched off, and
75
+ exits before it opens a debugging port (measured in a root container,
76
+ 2026-09-29). So --no-sandbox goes in for root, or when IDR_NO_SANDBOX=1
77
+ asks for it, and never otherwise. */
78
+ export function launchArgs(udd, port, { uid = process.getuid?.(), env = process.env } = {}) {
79
+ // --disable-component-update: a fresh profile starts Edge's component
80
+ // updater, which leaves msedge_url_fetcher_* folders in TEMP.
81
+ return [
82
+ '--headless=new', '--hide-scrollbars', '--mute-audio',
83
+ '--no-first-run', '--no-default-browser-check', '--disable-extensions',
84
+ '--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-features=Translate',
85
+ ...(uid === 0 || env.IDR_NO_SANDBOX === '1' ? ['--no-sandbox'] : []),
86
+ `--user-data-dir=${udd}`, `--remote-debugging-port=${port}`, 'about:blank',
87
+ ];
88
+ }
89
+
90
+ export const NO_BROWSER = 'no Chrome, Edge or Chromium found.\n'
91
+ + ' Windows: Edge is usually already installed; or winget install Google.Chrome\n'
92
+ + ' macOS: brew install --cask google-chrome\n'
93
+ + ' Linux: apt-get install chromium\n'
94
+ + 'Or set IDR_BROWSER to the browser executable.';
95
+
96
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
97
+
98
+ /* Chrome creates DevToolsActivePort before it writes the port into it, so the
99
+ file can exist and be empty, half-written, or (on Windows) locked. All of
100
+ those mean "not yet". Returns the port, or null to keep waiting. */
101
+ export function readPortFile(path) {
102
+ let first;
103
+ try { first = readFileSync(path, 'utf8').split('\n')[0].trim(); } catch { return null; }
104
+ const port = Number(first);
105
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
106
+ }
107
+
108
+ function freePort() {
109
+ return new Promise((res, rej) => {
110
+ const s = createServer();
111
+ s.once('error', rej);
112
+ s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => res(port)); });
113
+ });
114
+ }
115
+
116
+ async function answers(port) {
117
+ try { return (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500) })).ok; }
118
+ catch { return false; }
119
+ }
120
+
121
+ /* The browser's own temp directory, inside the profile that close() deletes.
122
+ Edge leaves an Importer_0_4 folder and cv_debug.log in the TEMP it inherits
123
+ whenever a page makes a network request, outside the profile where no
124
+ profile delete reaches them; with --disable-component-update missing it also
125
+ left msedge_url_fetcher_* folders (Ultimate Frontend Skills judge, round 3,
126
+ 2026-09-27). Any casing of the variable is replaced: Windows reads TEMP and
127
+ Temp as one name. */
128
+ function browserEnv(dir) {
129
+ const env = {};
130
+ for (const [k, v] of Object.entries(process.env)) if (!/^(TEMP|TMP|TMPDIR)$/i.test(k)) env[k] = v;
131
+ return { ...env, TEMP: dir, TMP: dir, TMPDIR: dir };
132
+ }
133
+
134
+ /* Starts the browser and resolves once DevTools answers. The returned close()
135
+ asks the browser to quit over the protocol first, waits for it to stop
136
+ answering, and only then falls back to ending the processes that carry this
137
+ run's own temporary profile - never any other browser the user has open. */
138
+ export async function launch(bin = findBrowser()) {
139
+ if (!bin) { const e = new Error(NO_BROWSER); e.code = 'no-browser'; throw e; }
140
+ if (typeof WebSocket === 'undefined') throw new Error('image-deep-research needs Node 22 or newer (built-in WebSocket).');
141
+ const udd = mkdtempSync(join(tmpdir(), 'idr-cdp-'));
142
+ const ownTmp = join(udd, 'tmp');
143
+ mkdirSync(ownTmp);
144
+ const asked = await freePort();
145
+ const proc = spawn(bin, launchArgs(udd, asked), { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true, env: browserEnv(ownTmp) });
146
+ let launchError;
147
+ proc.once('error', (err) => { launchError = err; });
148
+
149
+ const portFile = join(udd, 'DevToolsActivePort');
150
+ // 45 s, not 15: the first start of Chrome on a fresh Windows machine (a CI
151
+ // runner, measured 2026-09-25) took longer than 15 s to open its port, while
152
+ // every later start in the same run took about one.
153
+ const deadline = Date.now() + 45000;
154
+ for (let i = 0; Date.now() < deadline; i++) {
155
+ if (launchError || (proc.exitCode !== null && proc.exitCode !== 0)) break;
156
+ const port = readPortFile(portFile) || (i % 3 === 2 && await answers(asked) ? asked : null);
157
+ if (port) return { port, udd, close: () => shutdown(proc, udd, port) };
158
+ await sleep(100);
159
+ }
160
+ await shutdown(proc, udd, asked);
161
+ throw new Error(launchError ? 'browser launch failed: ' + launchError.message : 'browser did not expose a debugging port');
162
+ }
163
+
164
+ async function shutdown(proc, udd, port) {
165
+ try {
166
+ const v = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(1500) })).json();
167
+ await new Promise((res) => {
168
+ const ws = new WebSocket(v.webSocketDebuggerUrl);
169
+ const done = () => { try { ws.close(); } catch {} res(); };
170
+ ws.addEventListener('open', () => { try { ws.send(JSON.stringify({ id: 1, method: 'Browser.close' })); } catch {} setTimeout(done, 300); });
171
+ ws.addEventListener('error', done);
172
+ setTimeout(done, 2000);
173
+ });
174
+ } catch {}
175
+ for (let i = 0; i < 30 && await answers(port); i++) await sleep(100);
176
+ if (proc.exitCode === null) { try { proc.kill(); } catch {} }
177
+ // Helper processes (the crash handler, a utility process) can outlive the
178
+ // browser and hold the profile folder open for seconds. Wait for them first;
179
+ // only if the folder is still locked, end the processes started with this
180
+ // run's own temporary profile on their command line, and no other.
181
+ if (await removeProfile(udd, 3000) || process.platform !== 'win32') return;
182
+ endProfileProcesses(udd);
183
+ await removeProfile(udd, 5000);
184
+ }
185
+
186
+ async function removeProfile(udd, ms) {
187
+ const until = Date.now() + ms;
188
+ for (;;) {
189
+ try { rmSync(udd, { recursive: true, force: true }); } catch {}
190
+ if (!existsSync(udd)) return true;
191
+ if (Date.now() > until) return false;
192
+ await sleep(250);
193
+ }
194
+ }
195
+
196
+ function endProfileProcesses(udd) {
197
+ const q = udd.replace(/'/g, "''");
198
+ spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
199
+ `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${q}') } | ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } catch {} }`],
200
+ { stdio: 'ignore', windowsHide: true, timeout: 15000 });
201
+ }
202
+
203
+ export class Session {
204
+ constructor(ws) { this.ws = ws; this.id = 0; this.waiting = new Map(); this.events = []; }
205
+ static async open(port) {
206
+ let target;
207
+ for (let i = 0; i < 60 && !target; i++) {
208
+ try {
209
+ const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
210
+ target = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
211
+ } catch {}
212
+ if (!target) await sleep(100);
213
+ }
214
+ if (!target) throw new Error('no page target');
215
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
216
+ await new Promise((res, rej) => {
217
+ ws.addEventListener('open', res, { once: true });
218
+ ws.addEventListener('error', () => rej(new Error('cdp socket failed')), { once: true });
219
+ });
220
+ const s = new Session(ws);
221
+ ws.addEventListener('message', (e) => {
222
+ const msg = JSON.parse(e.data);
223
+ if (msg.id && s.waiting.has(msg.id)) {
224
+ const { res, rej, timer } = s.waiting.get(msg.id);
225
+ clearTimeout(timer);
226
+ s.waiting.delete(msg.id);
227
+ msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
228
+ } else if (msg.method) { s.events.push(msg); if (s.events.length > 2000) s.events.shift(); }
229
+ });
230
+ ws.addEventListener('close', () => s.failPending(new Error('CDP connection closed')));
231
+ return s;
232
+ }
233
+ failPending(error) {
234
+ for (const { rej, timer } of this.waiting.values()) { clearTimeout(timer); rej(error); }
235
+ this.waiting.clear();
236
+ }
237
+ send(method, params = {}, ms = 45000) {
238
+ const id = ++this.id;
239
+ return new Promise((res, rej) => {
240
+ const timer = setTimeout(() => {
241
+ if (this.waiting.has(id)) { this.waiting.delete(id); rej(new Error(method + ' timed out')); }
242
+ }, ms);
243
+ this.waiting.set(id, { res, rej, timer });
244
+ try { this.ws.send(JSON.stringify({ id, method, params })); }
245
+ catch (err) { clearTimeout(timer); this.waiting.delete(id); rej(err); }
246
+ });
247
+ }
248
+ async waitForEvent(method, ms = 25000) {
249
+ const t0 = Date.now();
250
+ while (Date.now() - t0 < ms) {
251
+ const i = this.events.findIndex((e) => e.method === method);
252
+ if (i !== -1) return this.events.splice(i, 1)[0];
253
+ await sleep(50);
254
+ }
255
+ return null;
256
+ }
257
+ async evaluate(expression, { awaitPromise = false } = {}) {
258
+ const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise });
259
+ if (r.exceptionDetails) throw new Error('page script failed: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
260
+ return r.result?.value;
261
+ }
262
+ close() { this.failPending(new Error('CDP session closed')); try { this.ws.close(); } catch {} }
263
+ }
264
+
265
+ /* One browser for a whole run: starts it, hands fn a page session, and
266
+ always shuts the browser down afterwards, even when fn throws. */
267
+ export async function withBrowser(fn) {
268
+ const b = await launch();
269
+ let session;
270
+ try {
271
+ session = await Session.open(b.port);
272
+ await session.send('Page.enable');
273
+ await session.send('Runtime.enable');
274
+ return await fn(session);
275
+ } finally {
276
+ if (session) session.close();
277
+ await b.close();
278
+ }
279
+ }
280
+
281
+ /* Loads a URL (or a data: / file: page) at a viewport and waits for it to
282
+ settle: the load event, web fonts, then a fixed pause for entrance motion. */
283
+ export async function load(session, url, { width = 1440, height = 900, wait = 3000, timeout = 30000 } = {}) {
284
+ await session.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 700 });
285
+ session.events.length = 0;
286
+ const nav = await session.send('Page.navigate', { url });
287
+ if (nav.errorText) throw new Error('navigation failed: ' + nav.errorText);
288
+ const loaded = await session.waitForEvent('Page.loadEventFired', timeout);
289
+ await session.evaluate('document.fonts ? document.fonts.ready.then(() => 1) : 1', { awaitPromise: true }).catch(() => {});
290
+ await sleep(wait);
291
+ return { loaded: !!loaded };
292
+ }
293
+
294
+ export async function screenshot(session, { format = 'png', quality, clip } = {}) {
295
+ const params = { format, captureBeyondViewport: !!clip };
296
+ if (format === 'jpeg') params.quality = quality ?? 82;
297
+ if (clip) params.clip = { ...clip, scale: 1 };
298
+ const r = await session.send('Page.captureScreenshot', params);
299
+ return Buffer.from(r.data, 'base64');
300
+ }