heroshot 0.11.4 → 0.12.1

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
@@ -28,22 +28,7 @@ npx heroshot
28
28
 
29
29
  First run opens a browser with a visual picker. Click what you want, name it, done. Screenshots land in `heroshots/`, config saves to `.heroshot/config.json`. Next run regenerates everything headlessly.
30
30
 
31
- <table align="center">
32
- <tr>
33
- <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-light.png?raw=true" alt="Desktop Light"></td>
34
- <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-dark.png?raw=true" alt="Desktop Dark"></td>
35
- </tr>
36
- <tr>
37
- <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-light.png?raw=true" alt="Tablet Light"></td>
38
- <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-dark.png?raw=true" alt="Tablet Dark"></td>
39
- </tr>
40
- <tr>
41
- <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-light.png?raw=true" alt="Mobile Light"></td>
42
- <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-dark.png?raw=true" alt="Mobile Dark"></td>
43
- </tr>
44
- </table>
45
-
46
- <p align="center"><em>6 screenshots from one config entry - always in sync with the live site.</em></p>
31
+ https://github.com/user-attachments/assets/f35600a6-9220-4bd2-a8c6-a6b4ee8a33d9
47
32
 
48
33
  ## Use in Your Docs
49
34
 
@@ -90,6 +75,25 @@ plugins:
90
75
 
91
76
  One component/macro, all variants - light/dark mode switches automatically, responsive sizes via srcset.
92
77
 
78
+ ## One Screenshot - All Variants
79
+
80
+ <table align="center">
81
+ <tr>
82
+ <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-light.png?raw=true" alt="Desktop Light"></td>
83
+ <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-desktop-dark.png?raw=true" alt="Desktop Dark"></td>
84
+ </tr>
85
+ <tr>
86
+ <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-light.png?raw=true" alt="Tablet Light"></td>
87
+ <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-tablet-dark.png?raw=true" alt="Tablet Dark"></td>
88
+ </tr>
89
+ <tr>
90
+ <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-light.png?raw=true" alt="Mobile Light"></td>
91
+ <td><img src="https://github.com/omachala/heroshot/blob/main/docs/public/screenshots/hero-mobile-dark.png?raw=true" alt="Mobile Dark"></td>
92
+ </tr>
93
+ </table>
94
+
95
+ <p align="center"><em>6 screenshots from one config entry - always in sync with the live site.</em></p>
96
+
93
97
  ## Learn More
94
98
 
95
99
  | | |
package/dist/cli/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as intro, D as setVerbose, E as outro, O as spinner, S as error, T as note, _ as loadConfig, a as launchBrowser, c as generateSessionKey, d as loadSession, f as saveLocalKey, g as getConfigPath, h as ensureHeroshotDirectory, k as verbose, l as getSessionPath, m as sessionExists, n as snippetAction, o as DEFAULT_VIEWPORT, p as saveSession, r as sync, s as EDITOR_DIR, u as loadLocalKey, v as saveConfig, w as log, x as generateUid, y as VIEWPORT_PRESETS } from "../snippet-B1n5AWKH.js";
2
+ import { C as intro, D as setVerbose, E as outro, O as spinner, S as error, T as note, _ as loadConfig, a as launchBrowser, c as generateSessionKey, d as loadSession, f as saveLocalKey, g as getConfigPath, h as ensureHeroshotDirectory, k as verbose, l as getSessionPath, m as sessionExists, n as snippetAction, o as DEFAULT_VIEWPORT, p as saveSession, r as sync, s as EDITOR_DIR, u as loadLocalKey, v as saveConfig, w as log, x as generateUid, y as VIEWPORT_PRESETS } from "../snippet-3pBmpUTB.js";
3
3
  import { existsSync, readFileSync, rmSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { Command } from "commander";
@@ -518,7 +518,9 @@ function buildShotConfig(url, options, existingConfig) {
518
518
  browser: {
519
519
  viewport: getViewport(options, existingConfig),
520
520
  colorScheme: getColorScheme(options, false),
521
- deviceScaleFactor: getDeviceScaleFactor(options, existingConfig)
521
+ deviceScaleFactor: getDeviceScaleFactor(options, existingConfig),
522
+ reducedMotion: options?.reducedMotion ? "reduce" : existingConfig?.browser?.reducedMotion,
523
+ userAgent: options?.userAgent ?? existingConfig?.browser?.userAgent
522
524
  },
523
525
  screenshots: [screenshot]
524
526
  };
@@ -703,7 +705,7 @@ program.name("heroshot").description("Define your screenshots once, update them
703
705
  setVerbose(program.opts().verbose ?? false);
704
706
  intro(version);
705
707
  });
706
- program.command("oneshot [url]", { isDefault: true }).description("Capture URL directly, or sync all screenshots from config").option("--selector <selector...>", "CSS selector(s) to capture").option("-o, --output <file>", "Output filename").option("-p, --padding <pixels>", "Padding around element", Number.parseInt).option("-w, --width <pixels>", "Viewport width", Number.parseInt).option("--height <pixels>", "Viewport height", Number.parseInt).option("--mobile", "Use mobile viewport (430x932)").option("--tablet", "Use tablet viewport (768x1024)").option("--desktop", "Use desktop viewport (1280x800)").option("--dark", "Force dark color scheme").option("--light", "Force light color scheme").option("--scale <factor>", "Device scale factor (1, 2, 3)", Number.parseInt).option("--retina", "Use retina scale (2x)").option("-q, --quality <percent>", "JPEG quality (1-100), outputs JPEG", Number.parseInt).option("--viewport-only", "Capture only viewport (not full page)").option("--save", "Save screenshot definition to config").option("--clean", "Delete stale files in output directory").option("--workers <count>", "Number of parallel capture workers", Number.parseInt).option("--headed", "Run browser in headed mode (visible window) for debugging").action(async (url, options) => {
708
+ program.command("oneshot [url]", { isDefault: true }).description("Capture URL directly, or sync all screenshots from config").option("--selector <selector...>", "CSS selector(s) to capture").option("-o, --output <file>", "Output filename").option("-p, --padding <pixels>", "Padding around element", Number.parseInt).option("-w, --width <pixels>", "Viewport width", Number.parseInt).option("--height <pixels>", "Viewport height", Number.parseInt).option("--mobile", "Use mobile viewport (430x932)").option("--tablet", "Use tablet viewport (768x1024)").option("--desktop", "Use desktop viewport (1280x800)").option("--dark", "Force dark color scheme").option("--light", "Force light color scheme").option("--scale <factor>", "Device scale factor (1, 2, 3)", Number.parseInt).option("--retina", "Use retina scale (2x)").option("-q, --quality <percent>", "JPEG quality (1-100), outputs JPEG", Number.parseInt).option("--viewport-only", "Capture only viewport (not full page)").option("--reduced-motion", "Emulate prefers-reduced-motion: reduce (disables animations)").option("--user-agent <string>", "Custom user agent string").option("--save", "Save screenshot definition to config").option("--clean", "Delete stale files in output directory").option("--workers <count>", "Number of parallel capture workers", Number.parseInt).option("--headed", "Run browser in headed mode (visible window) for debugging").action(async (url, options) => {
707
709
  if (!await shotAction(url, options, program.opts())) process.exitCode = 1;
708
710
  });
709
711
  program.command("config").description("Open browser to add/edit screenshot definitions").option("--reset", "Clear existing session and start fresh").option("--only", "Only run config, skip sync afterwards").option("--light", "Force light mode (prefers-color-scheme: light)").option("--dark", "Force dark mode (prefers-color-scheme: dark)").action(async (options) => {
package/dist/mcp/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { _ as loadConfig, b as screenshotSchema, g as getConfigPath, i as filterScreenshots, r as sync, t as generateSnippets, v as saveConfig, x as generateUid } from "../snippet-B1n5AWKH.js";
2
+ import { _ as loadConfig, b as screenshotSchema, g as getConfigPath, i as filterScreenshots, r as sync, t as generateSnippets, v as saveConfig, x as generateUid } from "../snippet-3pBmpUTB.js";
3
3
  import { z } from "zod";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -87,7 +87,7 @@ function log$1(text) {
87
87
  //#region src/actionSchema.ts
88
88
  /**
89
89
  * Action Schemas — Pre-screenshot actions that run before capturing.
90
- * Vocabulary aligned with Playwright MCP but uses CSS selectors instead of ephemeral accessibility refs.
90
+ * Uses Playwright locator API, supporting all selector formats: CSS, XPath, text, role, shadow DOM.
91
91
  * Reference: https://github.com/microsoft/playwright-mcp
92
92
  *
93
93
  * ROADMAP extensions (not in Playwright MCP):
@@ -96,7 +96,7 @@ function log$1(text) {
96
96
  */
97
97
  const clickActionSchema = z.object({
98
98
  type: z.literal("click"),
99
- selector: z.string().describe("CSS selector of the element to click"),
99
+ selector: z.string().describe("Element selector (CSS, XPath, text, role, or shadow DOM)"),
100
100
  doubleClick: z.boolean().optional().describe("Whether to perform a double click"),
101
101
  button: z.enum([
102
102
  "left",
@@ -114,7 +114,7 @@ const clickActionSchema = z.object({
114
114
  }).describe("Click an element. Use to dismiss cookie banners, open menus, expand dropdowns.");
115
115
  const typeActionSchema = z.object({
116
116
  type: z.literal("type"),
117
- selector: z.string().describe("CSS selector of the input element"),
117
+ selector: z.string().describe("Input element selector (CSS, XPath, text, role, or shadow DOM)"),
118
118
  text: z.string().describe("Text to type into the element"),
119
119
  submit: z.boolean().optional().describe("Whether to press Enter after typing (submit form)"),
120
120
  slowly: z.boolean().optional().describe("Type one character at a time for key handlers"),
@@ -122,12 +122,12 @@ const typeActionSchema = z.object({
122
122
  }).describe("Type text into an input, textarea, or contenteditable element.");
123
123
  const hoverActionSchema = z.object({
124
124
  type: z.literal("hover"),
125
- selector: z.string().describe("CSS selector of the element to hover over"),
125
+ selector: z.string().describe("Element selector (CSS, XPath, text, role, or shadow DOM)"),
126
126
  timeout: z.number().int().positive().optional().describe("Timeout in milliseconds")
127
127
  }).describe("Hover over an element to trigger :hover states, show tooltips, or reveal menus.");
128
128
  const selectOptionActionSchema = z.object({
129
129
  type: z.literal("select_option"),
130
- selector: z.string().describe("CSS selector of the <select> element"),
130
+ selector: z.string().describe("Select element selector (CSS, XPath, text, role, or shadow DOM)"),
131
131
  values: z.array(z.string()).describe("Option values to select. Supports multiple."),
132
132
  timeout: z.number().int().positive().optional().describe("Timeout in milliseconds")
133
133
  }).describe("Select one or more options in a native <select> dropdown.");
@@ -137,8 +137,8 @@ const pressKeyActionSchema = z.object({
137
137
  }).describe("Press a keyboard key or combination. Use to close modals, submit forms, etc.");
138
138
  const dragActionSchema = z.object({
139
139
  type: z.literal("drag"),
140
- from: z.string().describe("CSS selector of the element to drag"),
141
- to: z.string().describe("CSS selector of the drop target"),
140
+ from: z.string().describe("Selector of the element to drag"),
141
+ to: z.string().describe("Selector of the drop target"),
142
142
  timeout: z.number().int().positive().optional().describe("Timeout in milliseconds")
143
143
  }).describe("Drag an element and drop it onto another.");
144
144
  const waitActionSchema = z.object({
@@ -155,12 +155,12 @@ const navigateActionSchema = z.object({
155
155
  const evaluateActionSchema = z.object({
156
156
  type: z.literal("evaluate"),
157
157
  function: z.string().describe("JavaScript function: () => { ... } or (el) => { ... }"),
158
- selector: z.string().optional().describe("CSS selector of element to pass to function")
158
+ selector: z.string().optional().describe("Element selector to pass to function")
159
159
  }).describe("Run arbitrary JavaScript in the browser context. Escape hatch for DOM manipulation.");
160
160
  const fillFormActionSchema = z.object({
161
161
  type: z.literal("fill_form"),
162
162
  fields: z.array(z.object({
163
- selector: z.string().describe("CSS selector of the form field"),
163
+ selector: z.string().describe("Form field selector"),
164
164
  value: z.string().describe("Value to fill. Checkboxes: \"true\"/\"false\""),
165
165
  fieldType: z.enum([
166
166
  "textbox",
@@ -179,7 +179,7 @@ const handleDialogActionSchema = z.object({
179
179
  }).describe("Set up handler for browser dialog. Place BEFORE action that triggers dialog.");
180
180
  const fileUploadActionSchema = z.object({
181
181
  type: z.literal("file_upload"),
182
- selector: z.string().describe("CSS selector of the file input element"),
182
+ selector: z.string().describe("File input element selector"),
183
183
  paths: z.array(z.string()).describe("File paths to upload (absolute or relative to config)")
184
184
  }).describe("Upload one or more files through a file input element.");
185
185
  const resizeActionSchema = z.object({
@@ -189,7 +189,7 @@ const resizeActionSchema = z.object({
189
189
  }).describe("Resize the browser viewport mid-flow.");
190
190
  const hideActionSchema = z.object({
191
191
  type: z.literal("hide"),
192
- selectors: z.array(z.string()).describe("CSS selectors of elements to hide (display: none)")
192
+ selectors: z.array(z.string()).describe("Element selectors to hide (display: none)")
193
193
  }).describe("Hide elements from screenshot. Use to remove cookie banners, chat widgets, ads.");
194
194
  /** Union of all supported action types. Actions execute sequentially before screenshot. */
195
195
  const actionSchema = z.discriminatedUnion("type", [
@@ -260,7 +260,11 @@ const paddingSchema = z.object({
260
260
  bottom: z.number().int().min(0).default(0).describe("Bottom padding in pixels"),
261
261
  left: z.number().int().min(0).default(0).describe("Left padding in pixels")
262
262
  });
263
- /** Scroll position to restore when capturing */
263
+ /**
264
+ * Scroll position saved from editor.
265
+ * NOTE: Currently not used during capture - we use scrollIntoView instead.
266
+ * Kept for potential future use (e.g., precise scroll offset from element).
267
+ */
264
268
  const scrollPositionSchema = z.object({
265
269
  x: z.number().int().min(0).default(0).describe("Horizontal scroll offset in pixels"),
266
270
  y: z.number().int().min(0).default(0).describe("Vertical scroll offset in pixels")
@@ -301,9 +305,9 @@ const screenshotSchema = z.object({
301
305
  id: z.string().min(1).default(generateUid).describe("Unique identifier (auto-generated if omitted)"),
302
306
  name: z.string().min(1).describe("Display name, also used to derive the output filename"),
303
307
  url: z.url().describe("Full URL of the page to capture"),
304
- selector: z.string().optional().describe("CSS selector for element capture (omit for full-page)"),
308
+ selector: z.string().optional().describe("Element selector for capture (omit for full-page). Supports Playwright selector formats: CSS (.class, #id), shadow DOM (host >> child), XPath (xpath=...), text (text=...), role (role=button[name=\"OK\"]), and chained selectors."),
305
309
  padding: paddingSchema.optional().describe("Expand capture area beyond element bounds"),
306
- scroll: scrollPositionSchema.optional().describe("Scroll position to restore before capturing"),
310
+ scroll: scrollPositionSchema.optional().describe("Saved scroll position (not used during capture - scrollIntoView is used instead)"),
307
311
  paddingFill: paddingFillSchema.optional().describe("Background fill for padding area: \"inherit\" (default) shows page content, \"solid\" fills with detected background color"),
308
312
  elementFill: elementFillSchema.optional().describe("Background fill for element area: \"original\" (default) keeps actual background, \"solid\" replaces with detected color"),
309
313
  viewports: z.array(viewportVariantSchema).optional().describe("Viewport variants to generate — preset names (\"desktop\", \"tablet\", \"mobile\") or custom \"WIDTHxHEIGHT\""),
@@ -334,7 +338,9 @@ const shotCliOptionsSchema = z.object({
334
338
  scale: z.number().min(1).max(3).optional(),
335
339
  retina: z.boolean().optional(),
336
340
  quality: z.number().int().min(1).max(100).optional(),
337
- viewportOnly: z.boolean().optional()
341
+ viewportOnly: z.boolean().optional(),
342
+ reducedMotion: z.boolean().optional(),
343
+ userAgent: z.string().optional()
338
344
  });
339
345
  /** CLI command options for URL capture (includes --save and --clean flags) */
340
346
  const shotCommandOptionsSchema = shotCliOptionsSchema.extend({
@@ -1040,64 +1046,30 @@ async function executeActions(page, actions) {
1040
1046
  }
1041
1047
 
1042
1048
  //#endregion
1043
- //#region src/sync/browserFunctions.ts
1044
- /**
1045
- * Browser context functions for DOM manipulation.
1046
- * These functions execute in the browser via page.evaluate(fn, ...args).
1047
- *
1048
- * IMPORTANT: These functions run in browser context, not Node.js.
1049
- * They cannot access Node modules or closures - all data must be passed as arguments.
1050
- *
1051
- * NOTE: Most functions that need nested helpers or module-level constants have been
1052
- * moved to use string-based evaluation in their respective wrapper files to avoid
1053
- * tsx __name wrapper issues. Only simple functions are exported here.
1054
- */
1055
- /**
1056
- * Apply or remove dark mode class on document element.
1057
- */
1058
- function applyColorScheme(isDark) {
1059
- document.documentElement.classList.toggle("dark", isDark);
1060
- }
1049
+ //#region src/sync/elementFinder.ts
1061
1050
  /**
1062
- * Scroll the window to a specific position.
1051
+ * Normalize selector for Playwright compatibility.
1052
+ * Converts legacy `>>>` shadow-piercing syntax to Playwright's `>>`.
1063
1053
  */
1064
- function scrollTo({ x, y }) {
1065
- window.scrollTo(x, y);
1054
+ function normalizeSelector(selector) {
1055
+ return selector.replaceAll(">>>", ">>").replaceAll(" ", " ").trim();
1066
1056
  }
1067
-
1068
- //#endregion
1069
- //#region src/sync/elementFinder.ts
1070
1057
  /**
1071
- * Find element using shadow-piercing selector with retries.
1072
- * The >>> syntax pierces shadow DOM boundaries.
1058
+ * Find element using Playwright's locator API with retries.
1059
+ * Supports all Playwright selector formats including shadow DOM piercing.
1060
+ * Automatically scrolls the element into view once found.
1073
1061
  */
1074
1062
  async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
1063
+ const normalizedSelector = normalizeSelector(selector);
1075
1064
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1076
- const handle = await page.evaluateHandle(`
1077
- (() => {
1078
- const selector = ${JSON.stringify(selector)};
1079
- const parts = selector.split('>>>').map((part) => part.trim());
1080
- let current = document;
1081
-
1082
- for (const part of parts) {
1083
- if (!part) continue;
1084
-
1085
- const root = current instanceof Element
1086
- ? (current.shadowRoot ?? current)
1087
- : current;
1088
-
1089
- const found = root.querySelector(part);
1090
- if (!found) return null;
1091
-
1092
- current = found;
1093
- }
1094
-
1095
- return current instanceof Element ? current : null;
1096
- })()
1097
- `);
1098
- const element = handle.asElement();
1099
- if (element) return element;
1100
- await handle.dispose();
1065
+ try {
1066
+ const locator = page.locator(normalizedSelector);
1067
+ const element = await locator.elementHandle({ timeout: intervalMs });
1068
+ if (element) {
1069
+ await locator.scrollIntoViewIfNeeded({ timeout: 5e3 });
1070
+ return element;
1071
+ }
1072
+ } catch {}
1101
1073
  if (attempt < maxAttempts) await page.waitForTimeout(intervalMs);
1102
1074
  }
1103
1075
  return null;
@@ -1169,126 +1141,116 @@ async function removePaddingMask(page) {
1169
1141
  }
1170
1142
 
1171
1143
  //#endregion
1172
- //#region src/sync/pageScripts.ts
1144
+ //#region src/sync/browserFunctions.ts
1173
1145
  /**
1174
- * Shared helper function for shadow DOM piercing selectors.
1175
- * Defined as a string to be inlined into page.evaluate() calls.
1146
+ * Browser context functions for DOM manipulation.
1147
+ * These functions execute in the browser via page.evaluate(fn, ...args).
1148
+ *
1149
+ * IMPORTANT: These functions run in browser context, not Node.js.
1150
+ * They cannot access Node modules or closures - all data must be passed as arguments.
1176
1151
  *
1177
- * WHY STRING-BASED: If this were a regular function inlined into an exported
1178
- * function, esbuild/tsx would wrap it with __name() which doesn't exist in
1179
- * the browser, causing "ReferenceError: __name is not defined".
1180
- */
1181
- const QUERY_SELECTOR_DEEP = `
1182
- function querySelectorDeep(selector) {
1183
- var parts = selector.split('>>>').map(function(p) { return p.trim(); });
1184
- var current = document;
1185
- for (var i = 0; i < parts.length; i++) {
1186
- var part = parts[i];
1187
- if (!part) continue;
1188
- var root = (current instanceof Element && current.shadowRoot) ? current.shadowRoot : current;
1189
- var found = root.querySelector(part);
1190
- if (!found) return null;
1191
- current = found;
1192
- }
1193
- return current instanceof Element ? current : null;
1194
- }
1195
- `;
1152
+ * NOTE: Most functions that need nested helpers or module-level constants have been
1153
+ * moved to use string-based evaluation in their respective wrapper files to avoid
1154
+ * tsx __name wrapper issues. Only simple functions are exported here.
1155
+ */
1156
+ /**
1157
+ * Apply or remove dark mode class on document element.
1158
+ */
1159
+ function applyColorScheme(isDark) {
1160
+ document.documentElement.classList.toggle("dark", isDark);
1161
+ }
1162
+
1163
+ //#endregion
1164
+ //#region src/sync/pageScripts.ts
1196
1165
  /**
1197
1166
  * Store original background and apply new background color to element.
1167
+ * Uses Playwright's locator API to support all selector formats.
1198
1168
  */
1199
1169
  async function applyElementBackground(page, selector, bgColor) {
1200
- await page.evaluate(`(function(selector, bgColor) {
1201
- ${QUERY_SELECTOR_DEEP}
1202
- var element = querySelectorDeep(selector);
1203
- if (!element || !(element instanceof HTMLElement)) return;
1204
- element.dataset.heroshotOriginalBg = element.style.backgroundColor;
1205
- element.style.backgroundColor = bgColor;
1206
- })(${JSON.stringify(selector)}, ${JSON.stringify(bgColor)})`);
1170
+ const locator = page.locator(normalizeSelector(selector));
1171
+ try {
1172
+ await locator.evaluate((element, color) => {
1173
+ if (element instanceof HTMLElement) {
1174
+ element.dataset["heroshotOriginalBg"] = element.style.backgroundColor;
1175
+ element.style.backgroundColor = color;
1176
+ }
1177
+ }, bgColor, { timeout: 5e3 });
1178
+ } catch {}
1207
1179
  }
1208
1180
  /**
1209
1181
  * Restore original background on element.
1182
+ * Uses Playwright's locator API to support all selector formats.
1210
1183
  */
1211
1184
  async function restoreElementBackground(page, selector) {
1212
- await page.evaluate(`(function(selector) {
1213
- ${QUERY_SELECTOR_DEEP}
1214
- var element = querySelectorDeep(selector);
1215
- if (!element || !(element instanceof HTMLElement)) return;
1216
- var originalBg = element.dataset.heroshotOriginalBg;
1217
- if (originalBg !== undefined) {
1218
- element.style.backgroundColor = originalBg;
1219
- delete element.dataset.heroshotOriginalBg;
1220
- }
1221
- })(${JSON.stringify(selector)})`);
1185
+ const locator = page.locator(normalizeSelector(selector));
1186
+ try {
1187
+ await locator.evaluate((element) => {
1188
+ if (element instanceof HTMLElement) {
1189
+ const originalBg = element.dataset["heroshotOriginalBg"];
1190
+ if (originalBg !== void 0) {
1191
+ element.style.backgroundColor = originalBg;
1192
+ delete element.dataset["heroshotOriginalBg"];
1193
+ }
1194
+ }
1195
+ }, { timeout: 5e3 });
1196
+ } catch {}
1222
1197
  }
1223
1198
  /**
1224
1199
  * Apply text overrides to elements on the page.
1200
+ * Uses Playwright's locator API for the container, then CSS selectors for relative paths.
1225
1201
  */
1226
1202
  async function applyTextOverrides(page, selector, textOverrides) {
1227
- await page.evaluate(`(function(containerSelector, overrides) {
1228
- ${QUERY_SELECTOR_DEEP}
1229
- var container = querySelectorDeep(containerSelector);
1230
- if (!container) return;
1231
- var keys = Object.keys(overrides);
1232
- for (var i = 0; i < keys.length; i++) {
1233
- var relativeSelector = keys[i];
1234
- var newText = overrides[relativeSelector];
1235
- var textElement = container.querySelector(relativeSelector);
1236
- if (textElement) {
1237
- textElement.textContent = newText;
1238
- }
1239
- }
1240
- })(${JSON.stringify(selector)}, ${JSON.stringify(textOverrides)})`);
1203
+ const locator = page.locator(normalizeSelector(selector));
1204
+ try {
1205
+ await locator.evaluate((container, overrides) => {
1206
+ for (const [relativeSelector, newText] of Object.entries(overrides)) {
1207
+ const textElement = container.querySelector(relativeSelector);
1208
+ if (textElement) textElement.textContent = newText;
1209
+ }
1210
+ }, textOverrides, { timeout: 5e3 });
1211
+ } catch {}
1241
1212
  await page.waitForTimeout(50);
1242
1213
  }
1243
1214
  /**
1244
- * Get background color for an element via page.evaluate.
1215
+ * Get background color for an element.
1216
+ * Uses Playwright's locator API to support all selector formats.
1245
1217
  */
1246
1218
  async function getElementBackgroundColor(page, selector) {
1247
- const script = `(function(selector) {
1248
- ${QUERY_SELECTOR_DEEP}
1249
-
1250
- function rgbToHex(bgColor) {
1251
- var rgbMatch = ${"/rgba?" + String.raw`\((\d+),\s*(\d+),\s*(\d+)/`}.exec(bgColor);
1252
- if (rgbMatch && rgbMatch[1] && rgbMatch[2] && rgbMatch[3]) {
1253
- var red = parseInt(rgbMatch[1], 10);
1254
- var green = parseInt(rgbMatch[2], 10);
1255
- var blue = parseInt(rgbMatch[3], 10);
1256
- return '#' +
1257
- red.toString(16).padStart(2, '0') +
1258
- green.toString(16).padStart(2, '0') +
1259
- blue.toString(16).padStart(2, '0');
1260
- }
1261
- return bgColor;
1262
- }
1263
-
1264
- function isOpaqueColor(bgColor) {
1265
- return Boolean(bgColor && bgColor !== 'transparent' && !bgColor.startsWith('rgba(0, 0, 0, 0)'));
1266
- }
1267
-
1268
- var element = querySelectorDeep(selector);
1269
- if (!element) return '#ffffff';
1270
-
1271
- var current = element;
1272
- while (current) {
1273
- var backgroundColor = getComputedStyle(current).backgroundColor;
1274
- if (isOpaqueColor(backgroundColor)) {
1275
- return rgbToHex(backgroundColor);
1276
- }
1277
- var root = current.getRootNode();
1278
- current = (root instanceof ShadowRoot) ? root.host : current.parentElement;
1279
- }
1280
-
1281
- var bodyBg = getComputedStyle(document.body).backgroundColor;
1282
- if (isOpaqueColor(bodyBg)) return rgbToHex(bodyBg);
1283
-
1284
- var htmlBg = getComputedStyle(document.documentElement).backgroundColor;
1285
- if (isOpaqueColor(htmlBg)) return rgbToHex(htmlBg);
1286
-
1287
- return '#ffffff';
1288
- })(${JSON.stringify(selector)})`;
1289
- const bgColor = await page.evaluate(script);
1290
- verbose(`Detected background color: ${bgColor}`);
1291
- return bgColor;
1219
+ const locator = page.locator(normalizeSelector(selector));
1220
+ try {
1221
+ const bgColor = await locator.evaluate((element) => {
1222
+ function rgbToHex(color) {
1223
+ const rgbMatch = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color);
1224
+ if (rgbMatch?.[1] && rgbMatch[2] && rgbMatch[3]) {
1225
+ const red = Number.parseInt(rgbMatch[1], 10);
1226
+ const green = Number.parseInt(rgbMatch[2], 10);
1227
+ const blue = Number.parseInt(rgbMatch[3], 10);
1228
+ return "#" + red.toString(16).padStart(2, "0") + green.toString(16).padStart(2, "0") + blue.toString(16).padStart(2, "0");
1229
+ }
1230
+ return color;
1231
+ }
1232
+ function isOpaqueColor(color) {
1233
+ return Boolean(color && color !== "transparent" && !color.startsWith("rgba(0, 0, 0, 0)"));
1234
+ }
1235
+ let current = element;
1236
+ while (current) {
1237
+ const { backgroundColor } = getComputedStyle(current);
1238
+ if (isOpaqueColor(backgroundColor)) return rgbToHex(backgroundColor);
1239
+ const root = current.getRootNode();
1240
+ current = root instanceof ShadowRoot ? root.host : current.parentElement;
1241
+ }
1242
+ const { backgroundColor: bodyBg } = getComputedStyle(document.body);
1243
+ if (isOpaqueColor(bodyBg)) return rgbToHex(bodyBg);
1244
+ const { backgroundColor: htmlBg } = getComputedStyle(document.documentElement);
1245
+ if (isOpaqueColor(htmlBg)) return rgbToHex(htmlBg);
1246
+ return "#ffffff";
1247
+ }, { timeout: 5e3 });
1248
+ verbose(`Detected background color: ${bgColor}`);
1249
+ return bgColor;
1250
+ } catch {
1251
+ verbose("Could not detect background color, using default #ffffff");
1252
+ return "#ffffff";
1253
+ }
1292
1254
  }
1293
1255
  /**
1294
1256
  * Apply dark mode class to document based on color scheme.
@@ -1494,8 +1456,9 @@ const RETRY_DELAYS = [
1494
1456
  ];
1495
1457
  /**
1496
1458
  * Navigate to URL and prepare page for screenshot.
1459
+ * Note: Scroll position is handled by findElement which scrolls the element into view.
1497
1460
  */
1498
- async function navigateAndPrepare(page, url, colorScheme, scroll) {
1461
+ async function navigateAndPrepare(page, url, colorScheme) {
1499
1462
  if (colorScheme) await page.emulateMedia({ colorScheme });
1500
1463
  try {
1501
1464
  await page.goto(url, {
@@ -1510,13 +1473,6 @@ async function navigateAndPrepare(page, url, colorScheme, scroll) {
1510
1473
  }
1511
1474
  if (colorScheme) await applyColorSchemeClass(page, colorScheme);
1512
1475
  await page.waitForTimeout(2e3);
1513
- if (scroll) {
1514
- await page.evaluate(scrollTo, {
1515
- x: scroll.x,
1516
- y: scroll.y
1517
- });
1518
- await page.waitForTimeout(100);
1519
- }
1520
1476
  return { success: true };
1521
1477
  }
1522
1478
  /**
@@ -1543,7 +1499,7 @@ async function capturePageScreenshot(page, outputPath, format, quality, fullPage
1543
1499
  * Capture a single screenshot.
1544
1500
  */
1545
1501
  async function captureScreenshot(page, screenshot, outputDirectory, captureOptions, variant = {}) {
1546
- const { name, url, selector, padding, scroll, paddingFill, elementFill, textOverrides } = screenshot;
1502
+ const { name, url, selector, padding, paddingFill, elementFill, textOverrides } = screenshot;
1547
1503
  const { format, quality, fullPage } = captureOptions;
1548
1504
  const filename = generateScreenshotFilename({
1549
1505
  name,
@@ -1553,7 +1509,7 @@ async function captureScreenshot(page, screenshot, outputDirectory, captureOptio
1553
1509
  });
1554
1510
  const suffix = buildVariantSuffix(variant.viewportName, variant.colorScheme);
1555
1511
  verbose(`Capturing: ${name}${suffix ? ` (${suffix})` : ""}`);
1556
- const navResult = await navigateAndPrepare(page, url, variant.colorScheme, scroll);
1512
+ const navResult = await navigateAndPrepare(page, url, variant.colorScheme);
1557
1513
  if (!navResult.success) return {
1558
1514
  ...navResult,
1559
1515
  filename
@@ -1740,8 +1696,8 @@ async function captureParallel(options) {
1740
1696
  captureSpinner.message(`Capturing ${progress.captured}/${progress.total}: ${result.name}`);
1741
1697
  };
1742
1698
  const batchPromises = batches.map(async (batch) => limit(async () => executeBatch(batch, outputDirectory, captureOptions, browserOptions, onProgress)));
1743
- const batchResults = await Promise.all(batchPromises);
1744
- for (const results of batchResults) allResults.push(...results);
1699
+ const settledResults = await Promise.allSettled(batchPromises);
1700
+ for (const settled of settledResults) if (settled.status === "fulfilled") allResults.push(...settled.value);
1745
1701
  return allResults;
1746
1702
  }
1747
1703
 
@@ -1918,6 +1874,7 @@ async function executeCapture(context) {
1918
1874
  */
1919
1875
  async function sync(options = {}) {
1920
1876
  const configPath = options.configPath ?? getConfigPath();
1877
+ if (options.configPath && !existsSync(options.configPath)) throw new Error(`Config file not found: ${options.configPath}`);
1921
1878
  const config = options.config ?? loadConfig(configPath);
1922
1879
  if (config.screenshots.length === 0) {
1923
1880
  warn("No screenshots defined.");