autokap 1.9.0 → 1.9.2

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,85 @@
1
+ import type { BrowserContext } from 'playwright';
2
+ /**
3
+ * Privacy-signal request headers attached to every capture context.
4
+ *
5
+ * These are standard, widely-sent browser headers (Firefox sends `DNT`, Brave
6
+ * sends `Sec-GPC`), so they're invisible to origin anti-bot defenses — they
7
+ * only ever observe a normal first-party page load. Privacy-first analytics
8
+ * (Plausible, Fathom, …) honor them and suppress the pageview, which
9
+ * complements the network-level blocking below for the providers that respect
10
+ * the signal. Free, zero-risk belt-and-suspenders.
11
+ */
12
+ export declare const PRIVACY_HEADERS: Record<string, string>;
13
+ /**
14
+ * Hostnames of DEDICATED web-analytics / product-telemetry / session-replay
15
+ * endpoints. These domains serve ONLY analytics, so aborting requests to them
16
+ * during a capture has zero functional impact on the page being screenshotted —
17
+ * it only prevents AutoKap's automated visit from registering as a phantom
18
+ * "visitor" in the site owner's analytics (AUT-234).
19
+ *
20
+ * Matched by exact host OR sub-domain suffix
21
+ * (`host === h || host.endsWith('.' + h)`), so regional shards
22
+ * (`eu.i.posthog.com`, `region1.google-analytics.com`, `*.matomo.cloud`, …) are
23
+ * covered without enumerating each one.
24
+ *
25
+ * Deliberately ABSENT: `googletagmanager.com`. GTM can inject functional tags,
26
+ * and loading `gtm.js` does NOT itself record a visit — the GA *beacon* to
27
+ * `google-analytics.com` does, and that host IS blocked. So we neutralize the
28
+ * GA visit without risking a broken page.
29
+ *
30
+ * Self-hosted / first-party-proxied analytics (e.g. Plausible reverse-proxied
31
+ * on the site's own domain) is intentionally NOT covered: it reads as
32
+ * first-party (see the `isFirstPartyUrl` guard in {@link installAnalyticsBlock})
33
+ * and is impossible to detect universally. That edge case is out of scope by
34
+ * design — catching it would depend on per-site configuration.
35
+ */
36
+ export declare const ANALYTICS_HOSTS: readonly string[];
37
+ /**
38
+ * True when `url` targets a dedicated web-analytics *ingestion* endpoint (see
39
+ * {@link ANALYTICS_HOSTS}). Host-suffix aware so regional/sub-domain shards
40
+ * match their parent. Fail-CLOSED on unparseable or non-http(s) URLs (returns
41
+ * `false`): we never abort a request we can't confidently classify.
42
+ *
43
+ * For hosts that co-serve functional config on the same domain as their
44
+ * analytics (PostHog), only the event-capture paths count — feature-flag /
45
+ * config / library paths are preserved so the captured UI never changes
46
+ * (see {@link POSTHOG_FUNCTIONAL_PATH_RE}).
47
+ */
48
+ export declare function isAnalyticsRequest(url: string): boolean;
49
+ /**
50
+ * The per-request block decision, factored out of {@link installAnalyticsBlock}
51
+ * so the guard composition is unit-testable without a real browser context.
52
+ *
53
+ * Blocks ONLY a third-party analytics request. A first-party one — analytics
54
+ * self-hosted or reverse-proxied on the captured site's OWN domain — is
55
+ * preserved so we can never break the site's own functionality.
56
+ *
57
+ * `pageUrl` is the request's frame URL. When it's empty/unknown (a request in
58
+ * flight before the first navigation commits, a detached/teardown frame, some
59
+ * worker-originated requests) `isFirstPartyUrl` fail-OPENS to first-party, so
60
+ * the beacon is NOT aborted. That's the deliberate safe direction — never break
61
+ * a page — at the cost of a rare phantom-visit leak in that narrow window; real
62
+ * analytics beacons fire after navigation commits, so the frame URL is present.
63
+ */
64
+ export declare function shouldBlockAnalyticsRequest(pageUrl: string, requestUrl: string): boolean;
65
+ /**
66
+ * Install a context-level route that aborts outgoing requests to dedicated
67
+ * third-party analytics endpoints, so capturing a site never registers a
68
+ * phantom "visit" in its analytics (AUT-234).
69
+ *
70
+ * Only THIRD-party analytics is blocked (the `!isFirstPartyUrl` guard): a
71
+ * first-party request is never aborted, so we can never break the captured
72
+ * site's own functionality. Aborting a third-party beacon is invisible to the
73
+ * origin's anti-bot (it only ever sees a normal first-party page load) — exactly
74
+ * what an ad-blocker does — so this carries no risk of tripping bot defenses.
75
+ *
76
+ * Registered at the CONTEXT level so it (a) covers every page/frame in the
77
+ * context, (b) survives `page.unrouteAll()` from `clearRouteInterception()`
78
+ * (which only clears page-level routes), and (c) composes with the page-level
79
+ * mock routes from `setupRouteInterception()` — page routes run first; this
80
+ * catch-all `fallback()`s every non-analytics request back to the network (or
81
+ * the next handler). Aborting also lowers in-flight count, which only helps
82
+ * `networkidle` settle. The adaptive-wait progress signal already ignores
83
+ * third-party traffic (AUT-240), so there's no interaction there.
84
+ */
85
+ export declare function installAnalyticsBlock(context: BrowserContext): Promise<void>;
@@ -0,0 +1,201 @@
1
+ import { isFirstPartyUrl } from './security.js';
2
+ /**
3
+ * Privacy-signal request headers attached to every capture context.
4
+ *
5
+ * These are standard, widely-sent browser headers (Firefox sends `DNT`, Brave
6
+ * sends `Sec-GPC`), so they're invisible to origin anti-bot defenses — they
7
+ * only ever observe a normal first-party page load. Privacy-first analytics
8
+ * (Plausible, Fathom, …) honor them and suppress the pageview, which
9
+ * complements the network-level blocking below for the providers that respect
10
+ * the signal. Free, zero-risk belt-and-suspenders.
11
+ */
12
+ export const PRIVACY_HEADERS = {
13
+ DNT: '1',
14
+ 'Sec-GPC': '1',
15
+ };
16
+ /**
17
+ * Hostnames of DEDICATED web-analytics / product-telemetry / session-replay
18
+ * endpoints. These domains serve ONLY analytics, so aborting requests to them
19
+ * during a capture has zero functional impact on the page being screenshotted —
20
+ * it only prevents AutoKap's automated visit from registering as a phantom
21
+ * "visitor" in the site owner's analytics (AUT-234).
22
+ *
23
+ * Matched by exact host OR sub-domain suffix
24
+ * (`host === h || host.endsWith('.' + h)`), so regional shards
25
+ * (`eu.i.posthog.com`, `region1.google-analytics.com`, `*.matomo.cloud`, …) are
26
+ * covered without enumerating each one.
27
+ *
28
+ * Deliberately ABSENT: `googletagmanager.com`. GTM can inject functional tags,
29
+ * and loading `gtm.js` does NOT itself record a visit — the GA *beacon* to
30
+ * `google-analytics.com` does, and that host IS blocked. So we neutralize the
31
+ * GA visit without risking a broken page.
32
+ *
33
+ * Self-hosted / first-party-proxied analytics (e.g. Plausible reverse-proxied
34
+ * on the site's own domain) is intentionally NOT covered: it reads as
35
+ * first-party (see the `isFirstPartyUrl` guard in {@link installAnalyticsBlock})
36
+ * and is impossible to detect universally. That edge case is out of scope by
37
+ * design — catching it would depend on per-site configuration.
38
+ */
39
+ export const ANALYTICS_HOSTS = [
40
+ // Google Analytics / GA4 / Universal Analytics collection endpoints
41
+ 'google-analytics.com',
42
+ 'analytics.google.com',
43
+ 'ssl.google-analytics.com',
44
+ 'region1.google-analytics.com',
45
+ 'stats.g.doubleclick.net',
46
+ // Plausible
47
+ 'plausible.io',
48
+ // PostHog — only its event-ingestion paths are blocked; feature-flag / config
49
+ // / library paths (/decide, /flags, /static, /array) are preserved so a
50
+ // flag-gated app never renders with default flags (see isAnalyticsRequest).
51
+ 'posthog.com',
52
+ 'i.posthog.com',
53
+ // Matomo / Piwik (cloud)
54
+ 'matomo.cloud',
55
+ 'matomo.org',
56
+ // Segment
57
+ 'segment.io',
58
+ 'segment.com',
59
+ // Mixpanel
60
+ 'mixpanel.com',
61
+ 'mxpnl.com',
62
+ // Amplitude
63
+ 'amplitude.com',
64
+ // Heap
65
+ 'heapanalytics.com',
66
+ 'heap.io',
67
+ // Hotjar (heatmaps / session replay)
68
+ 'hotjar.com',
69
+ 'hotjar.io',
70
+ // Microsoft Clarity (session replay)
71
+ 'clarity.ms',
72
+ // Cloudflare Web Analytics
73
+ 'cloudflareinsights.com',
74
+ // Vercel Analytics / Speed Insights
75
+ 'vercel-insights.com',
76
+ 'va.vercel-scripts.com',
77
+ // Fathom
78
+ 'usefathom.com',
79
+ // Session replay / heatmaps
80
+ 'fullstory.com',
81
+ 'mouseflow.com',
82
+ 'crazyegg.com',
83
+ // Yandex Metrica
84
+ 'mc.yandex.ru',
85
+ // Quantcast
86
+ 'quantserve.com',
87
+ 'quantcount.com',
88
+ // Adobe Analytics (Omniture)
89
+ 'omtrdc.net',
90
+ '2o7.net',
91
+ // Misc product analytics
92
+ 'pendo.io',
93
+ 'woopra.com',
94
+ 'kissmetrics.io',
95
+ ];
96
+ const ANALYTICS_HOST_SET = new Set(ANALYTICS_HOSTS.map((h) => h.toLowerCase()));
97
+ /**
98
+ * Path prefixes on PostHog hosts that are NOT analytics ingestion: feature
99
+ * flags (`/decide`, `/flags`), the JS library and its remote config
100
+ * (`/static`, `/array`). PostHog co-serves these from the SAME host as its
101
+ * event capture, so blocking them would make a flag-gated app render with
102
+ * default flags during capture — a visible change to the captured UI. We only
103
+ * block the ingestion paths (`/e/`, `/i/v0/e/`, `/batch/`, `/capture/`, `/s/`),
104
+ * which is what records the phantom visit.
105
+ */
106
+ const POSTHOG_FUNCTIONAL_PATH_RE = /^\/(decide|flags|static|array)\b/i;
107
+ function matchesAnalyticsHost(host) {
108
+ if (ANALYTICS_HOST_SET.has(host))
109
+ return true;
110
+ for (const h of ANALYTICS_HOST_SET) {
111
+ if (host.endsWith(`.${h}`))
112
+ return true;
113
+ }
114
+ return false;
115
+ }
116
+ function isPosthogHost(host) {
117
+ return host === 'posthog.com' || host.endsWith('.posthog.com');
118
+ }
119
+ /**
120
+ * True when `url` targets a dedicated web-analytics *ingestion* endpoint (see
121
+ * {@link ANALYTICS_HOSTS}). Host-suffix aware so regional/sub-domain shards
122
+ * match their parent. Fail-CLOSED on unparseable or non-http(s) URLs (returns
123
+ * `false`): we never abort a request we can't confidently classify.
124
+ *
125
+ * For hosts that co-serve functional config on the same domain as their
126
+ * analytics (PostHog), only the event-capture paths count — feature-flag /
127
+ * config / library paths are preserved so the captured UI never changes
128
+ * (see {@link POSTHOG_FUNCTIONAL_PATH_RE}).
129
+ */
130
+ export function isAnalyticsRequest(url) {
131
+ let parsed;
132
+ try {
133
+ parsed = new URL(url);
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
139
+ return false;
140
+ const host = parsed.hostname.toLowerCase();
141
+ if (!matchesAnalyticsHost(host))
142
+ return false;
143
+ if (isPosthogHost(host) && POSTHOG_FUNCTIONAL_PATH_RE.test(parsed.pathname)) {
144
+ return false;
145
+ }
146
+ return true;
147
+ }
148
+ /**
149
+ * The per-request block decision, factored out of {@link installAnalyticsBlock}
150
+ * so the guard composition is unit-testable without a real browser context.
151
+ *
152
+ * Blocks ONLY a third-party analytics request. A first-party one — analytics
153
+ * self-hosted or reverse-proxied on the captured site's OWN domain — is
154
+ * preserved so we can never break the site's own functionality.
155
+ *
156
+ * `pageUrl` is the request's frame URL. When it's empty/unknown (a request in
157
+ * flight before the first navigation commits, a detached/teardown frame, some
158
+ * worker-originated requests) `isFirstPartyUrl` fail-OPENS to first-party, so
159
+ * the beacon is NOT aborted. That's the deliberate safe direction — never break
160
+ * a page — at the cost of a rare phantom-visit leak in that narrow window; real
161
+ * analytics beacons fire after navigation commits, so the frame URL is present.
162
+ */
163
+ export function shouldBlockAnalyticsRequest(pageUrl, requestUrl) {
164
+ return isAnalyticsRequest(requestUrl) && !isFirstPartyUrl(pageUrl, requestUrl);
165
+ }
166
+ /**
167
+ * Install a context-level route that aborts outgoing requests to dedicated
168
+ * third-party analytics endpoints, so capturing a site never registers a
169
+ * phantom "visit" in its analytics (AUT-234).
170
+ *
171
+ * Only THIRD-party analytics is blocked (the `!isFirstPartyUrl` guard): a
172
+ * first-party request is never aborted, so we can never break the captured
173
+ * site's own functionality. Aborting a third-party beacon is invisible to the
174
+ * origin's anti-bot (it only ever sees a normal first-party page load) — exactly
175
+ * what an ad-blocker does — so this carries no risk of tripping bot defenses.
176
+ *
177
+ * Registered at the CONTEXT level so it (a) covers every page/frame in the
178
+ * context, (b) survives `page.unrouteAll()` from `clearRouteInterception()`
179
+ * (which only clears page-level routes), and (c) composes with the page-level
180
+ * mock routes from `setupRouteInterception()` — page routes run first; this
181
+ * catch-all `fallback()`s every non-analytics request back to the network (or
182
+ * the next handler). Aborting also lowers in-flight count, which only helps
183
+ * `networkidle` settle. The adaptive-wait progress signal already ignores
184
+ * third-party traffic (AUT-240), so there's no interaction there.
185
+ */
186
+ export async function installAnalyticsBlock(context) {
187
+ await context.route('**/*', (route) => {
188
+ const request = route.request();
189
+ const url = request.url();
190
+ // Derive the page origin from the request's own frame so analytics
191
+ // self-hosted on the captured site's domain reads as first-party and is
192
+ // left untouched. `'blockedbyclient'` mimics an ad-blocker (the page just
193
+ // sees a failed beacon, like every uBlock user).
194
+ const pageUrl = request.frame()?.url() || '';
195
+ if (shouldBlockAnalyticsRequest(pageUrl, url)) {
196
+ return route.abort('blockedbyclient').catch(() => undefined);
197
+ }
198
+ return route.fallback();
199
+ });
200
+ }
201
+ //# sourceMappingURL=analytics-blocklist.js.map
@@ -20,6 +20,7 @@ declare class BrowserPool {
20
20
  colorScheme?: 'light' | 'dark';
21
21
  storageState?: BrowserStorageState;
22
22
  extraHttpHeaders?: Record<string, string>;
23
+ blockAnalytics?: boolean;
23
24
  }): Promise<BrowserContext>;
24
25
  /**
25
26
  * Release a context back to the pool. Closes the context and unblocks
@@ -1,4 +1,5 @@
1
1
  import { chromium } from 'playwright';
2
+ import { installAnalyticsBlock, PRIVACY_HEADERS } from './analytics-blocklist.js';
2
3
  /** Chromium flags for server-side headless operation (used by pool and standalone launches). */
3
4
  export const CHROMIUM_ARGS = [
4
5
  // Linux/Docker-only: required when running Chromium as root or with limited /dev/shm
@@ -74,8 +75,16 @@ class BrowserPool {
74
75
  locale: options?.lang ? options.lang : 'en-US',
75
76
  colorScheme: options?.colorScheme ?? 'light',
76
77
  storageState: options?.storageState,
77
- ...(extra && Object.keys(extra).length > 0 ? { extraHTTPHeaders: extra } : {}),
78
+ // Privacy signals first, then merge user/env auth headers (which win on
79
+ // any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
80
+ extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(extra ?? {}) },
78
81
  });
82
+ // Block third-party analytics so pooled (server-side) captures don't
83
+ // register a phantom visit in the captured site's analytics (AUT-234).
84
+ // Skippable per-project via blockAnalytics === false.
85
+ if (options?.blockAnalytics !== false) {
86
+ await installAnalyticsBlock(context);
87
+ }
79
88
  this.activeContexts++;
80
89
  this.captureCount++;
81
90
  return context;
package/dist/browser.js CHANGED
@@ -6,6 +6,7 @@ import { join } from 'path';
6
6
  import { DOM_QUIET_WINDOW_MS, GLOBAL_WAIT_CAP_MS, PIXEL_FALLBACK_DIFF_THRESHOLD, PIXEL_FALLBACK_MAX_PASSES, } from './wait-contract.js';
7
7
  import { buildAKNodeRuntimeIndex, deriveInteractiveElementsFromAKTree, disambiguateFingerprint, focusAKTree, fingerprintAKNode, serializeAKTree, } from './ak-tree.js';
8
8
  import { isFirstPartyUrl } from './security.js';
9
+ import { installAnalyticsBlock, PRIVACY_HEADERS } from './analytics-blocklist.js';
9
10
  /**
10
11
  * Set-of-Marks (SoM) annotation: overlays colored [N] badges on each visible
11
12
  * interactive element so the vision model can reference elements by their badge index.
@@ -949,6 +950,7 @@ export class Browser {
949
950
  colorScheme: options.colorScheme ?? 'light',
950
951
  storageState: options.storageState,
951
952
  extraHttpHeaders: options.extraHttpHeaders,
953
+ blockAnalytics: options.blockAnalytics,
952
954
  });
953
955
  instance.page = await instance.context.newPage();
954
956
  instance.poolContext = true;
@@ -1068,9 +1070,9 @@ export class Browser {
1068
1070
  locale: langToLocale(options.lang ?? 'en'),
1069
1071
  colorScheme: options.colorScheme ?? 'light',
1070
1072
  storageState: options.storageState,
1071
- ...(options.extraHttpHeaders && Object.keys(options.extraHttpHeaders).length > 0
1072
- ? { extraHTTPHeaders: options.extraHttpHeaders }
1073
- : {}),
1073
+ // Privacy signals first, then merge user/env auth headers (which win on
1074
+ // any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
1075
+ extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(options.extraHttpHeaders ?? {}) },
1074
1076
  };
1075
1077
  // Dedicated browser process for clip capture. Not pooled because clip
1076
1078
  // capture installs context-level init scripts (cursor overlay). Cloud Run
@@ -1109,6 +1111,13 @@ export class Browser {
1109
1111
  });
1110
1112
  instance.context = await instance.browser.newContext(contextOptions);
1111
1113
  }
1114
+ // Block third-party analytics beacons so clip/video capture doesn't
1115
+ // register a phantom visit either (AUT-234). Context-level so it covers
1116
+ // every page in both the persistent (cloud) and incognito (local) paths.
1117
+ // Skippable per-project via blockAnalytics === false.
1118
+ if (options.blockAnalytics !== false) {
1119
+ await installAnalyticsBlock(instance.context);
1120
+ }
1112
1121
  // Cloud Run only: inject the notranslate meta on every navigation so
1113
1122
  // Chromium's translate UI never prompts. The --disable-features=Translate*
1114
1123
  // launch flags are unreliable across Chromium versions (some translate
@@ -1244,6 +1253,9 @@ export class Browser {
1244
1253
  args: CHROMIUM_ARGS,
1245
1254
  });
1246
1255
  this.context = await this.browser.newContext(this.buildContextOptions());
1256
+ if (this.options.blockAnalytics !== false) {
1257
+ await installAnalyticsBlock(this.context);
1258
+ }
1247
1259
  this.page = await this.context.newPage();
1248
1260
  this.attachDebugLifecycleListeners();
1249
1261
  }
@@ -1333,6 +1345,9 @@ export class Browser {
1333
1345
  this.context = null;
1334
1346
  }
1335
1347
  this.context = await this.browser.newContext(this.buildContextOptions());
1348
+ if (this.options.blockAnalytics !== false) {
1349
+ await installAnalyticsBlock(this.context);
1350
+ }
1336
1351
  this.page = await this.context.newPage();
1337
1352
  this.elementMap.clear();
1338
1353
  this.attachDebugLifecycleListeners();
@@ -5631,10 +5646,11 @@ export class Browser {
5631
5646
  async setLanguage(lang) {
5632
5647
  const context = this.ensureContext();
5633
5648
  const page = this.ensurePage();
5634
- // `setExtraHTTPHeaders` REPLACES the header map — merge with the
5635
- // environment-level auth headers so a SET_LOCALE opcode doesn't strip
5636
- // them mid-run.
5649
+ // `setExtraHTTPHeaders` REPLACES the header map — merge with the privacy
5650
+ // signals and the environment-level auth headers so a SET_LOCALE opcode
5651
+ // doesn't strip them mid-run.
5637
5652
  await context.setExtraHTTPHeaders({
5653
+ ...PRIVACY_HEADERS,
5638
5654
  ...(this.options.extraHttpHeaders ?? {}),
5639
5655
  'Accept-Language': lang,
5640
5656
  });
@@ -5761,7 +5777,9 @@ export class Browser {
5761
5777
  locale: langToLocale(this.options.lang ?? 'en'),
5762
5778
  colorScheme: this.options.colorScheme ?? 'light',
5763
5779
  storageState: this.options.storageState,
5764
- ...(extra && Object.keys(extra).length > 0 ? { extraHTTPHeaders: extra } : {}),
5780
+ // Privacy signals first, then merge user/env auth headers (which win on
5781
+ // any conflict). See PRIVACY_HEADERS / installAnalyticsBlock (AUT-234).
5782
+ extraHTTPHeaders: { ...PRIVACY_HEADERS, ...(extra ?? {}) },
5765
5783
  };
5766
5784
  }
5767
5785
  }
@@ -258,6 +258,7 @@ export async function runCapture(options) {
258
258
  colorScheme: variant.theme,
259
259
  storageState: program.preconditions.storageState,
260
260
  extraHttpHeaders: program.environmentHttpHeaders,
261
+ blockAnalytics: program.blockAnalytics,
261
262
  };
262
263
  let recordingDir;
263
264
  let browser;
@@ -2233,6 +2233,7 @@ export declare const ExecutionProgramSchema: z.ZodObject<{
2233
2233
  deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
2234
2234
  publicUrl: z.ZodOptional<z.ZodString>;
2235
2235
  environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
2236
+ blockAnalytics: z.ZodOptional<z.ZodBoolean>;
2236
2237
  }, z.core.$strict>;
2237
2238
  export declare const HealerPatchSchema: z.ZodObject<{
2238
2239
  opcodeIndex: z.ZodNumber;
@@ -4936,6 +4937,7 @@ export declare function safeParseProgramResult(data: unknown): z.ZodSafeParseRes
4936
4937
  deviceConfigs?: Record<string, Record<string, unknown>> | undefined;
4937
4938
  publicUrl?: string | undefined;
4938
4939
  environmentHttpHeaders?: Record<string, string> | undefined;
4940
+ blockAnalytics?: boolean | undefined;
4939
4941
  }>;
4940
4942
  export interface ClipNavigationViolation {
4941
4943
  /** Index of the offending NAVIGATE opcode in `program.steps`. */
@@ -682,6 +682,12 @@ export const ExecutionProgramSchema = z.object({
682
682
  // pairs that Playwright will inject as `extraHTTPHeaders` on the
683
683
  // BrowserContext so protected staging/preview URLs load successfully.
684
684
  environmentHttpHeaders: z.record(z.string().min(1), z.string().min(1)).optional(),
685
+ // Per-project opt-out for analytics blocking (AUT-234). Optional and WITHOUT a
686
+ // Zod default for the same signing reason as `programSchemaVersion` above:
687
+ // this schema is reused in signature verification, so a default would mutate
688
+ // the signed payload and break symmetry for programs signed without the field.
689
+ // Absent / true ⇒ block (engine default); only an explicit false disables.
690
+ blockAnalytics: z.boolean().optional(),
685
691
  }).strict().superRefine((value, ctx) => {
686
692
  if (value.mediaMode !== value.artifactPlan.mediaMode) {
687
693
  ctx.addIssue({
@@ -614,6 +614,14 @@ export interface ExecutionProgram {
614
614
  * and embedded in the signed program envelope.
615
615
  */
616
616
  environmentHttpHeaders?: Record<string, string>;
617
+ /**
618
+ * Per-project opt-out for third-party analytics blocking (AUT-234). Default
619
+ * behavior (field absent / `true`) blocks analytics beacons during capture so
620
+ * a run never registers a phantom "visit". Set to `false` only when the
621
+ * project disabled it (`projects.block_analytics_enabled = false`). Server-set
622
+ * BEFORE signing, so it lives inside the signed envelope.
623
+ */
624
+ blockAnalytics?: boolean;
617
625
  }
618
626
  export interface CircuitBreakerConfig {
619
627
  /** Max recovery attempts per opcode. Default: 3 */
package/dist/mockup.d.ts CHANGED
@@ -204,18 +204,27 @@ export interface MockupOptions {
204
204
  height: number;
205
205
  };
206
206
  }
207
+ /**
208
+ * Status bar default: shown only on phone mockups. Tablet, laptop, and browser
209
+ * categories default to off (browser is additionally excluded at render time).
210
+ * An explicit user choice (`mockupOptions.showStatusBar`) always overrides this.
211
+ */
212
+ export declare function defaultShowStatusBar(category: DeviceCategory): boolean;
207
213
  /**
208
214
  * Resolve the two per-variant frame decisions shared by both render paths (CLI direct-upload
209
215
  * framing and the cloud legacy-multipart route): a variant's own `mockupOptions` wins, falling
210
216
  * back to the viewport-inferred orientation and the deprecated program-level `applyStatusBar`.
211
217
  * Pure + exported so the precedence is tested once instead of in two duplicated call sites.
218
+ *
219
+ * `showStatusBar` is left `undefined` when neither the variant nor the legacy fallback set it,
220
+ * so `applyDeviceFrame` can apply the category-aware default (phones on, everything else off).
212
221
  */
213
222
  export declare function resolveVariantFrameOptions(mockupOptions: MockupOptions | undefined, fallback: {
214
223
  orientation?: MockupOrientation;
215
224
  showStatusBar?: boolean;
216
225
  }): {
217
226
  orientation?: MockupOrientation;
218
- showStatusBar: boolean;
227
+ showStatusBar?: boolean;
219
228
  };
220
229
  export interface ResolvedDeviceFrameDescriptor {
221
230
  id: string;
package/dist/mockup.js CHANGED
@@ -17,21 +17,33 @@ function getSupabaseMockupConfig() {
17
17
  serviceKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
18
18
  };
19
19
  }
20
+ /**
21
+ * Status bar default: shown only on phone mockups. Tablet, laptop, and browser
22
+ * categories default to off (browser is additionally excluded at render time).
23
+ * An explicit user choice (`mockupOptions.showStatusBar`) always overrides this.
24
+ */
25
+ export function defaultShowStatusBar(category) {
26
+ return category === 'phone';
27
+ }
20
28
  /**
21
29
  * Resolve the two per-variant frame decisions shared by both render paths (CLI direct-upload
22
30
  * framing and the cloud legacy-multipart route): a variant's own `mockupOptions` wins, falling
23
31
  * back to the viewport-inferred orientation and the deprecated program-level `applyStatusBar`.
24
32
  * Pure + exported so the precedence is tested once instead of in two duplicated call sites.
33
+ *
34
+ * `showStatusBar` is left `undefined` when neither the variant nor the legacy fallback set it,
35
+ * so `applyDeviceFrame` can apply the category-aware default (phones on, everything else off).
25
36
  */
26
37
  export function resolveVariantFrameOptions(mockupOptions, fallback) {
27
38
  return {
28
39
  orientation: mockupOptions?.orientation ?? fallback.orientation,
29
- showStatusBar: mockupOptions?.showStatusBar ?? fallback.showStatusBar ?? false,
40
+ showStatusBar: mockupOptions?.showStatusBar ?? fallback.showStatusBar,
30
41
  };
31
42
  }
32
43
  const DEFAULT_MOCKUP_OPTIONS = {
33
44
  orientation: 'portrait',
34
45
  outputScale: 2,
46
+ // Placeholder only — applyDeviceFrame re-resolves this per device category (see defaultShowStatusBar).
35
47
  showStatusBar: true,
36
48
  showSafeAreaTop: true,
37
49
  showSafeAreaBottom: true,
@@ -523,6 +535,9 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
523
535
  if (!config)
524
536
  throw new Error(`Unknown device frame: ${deviceId}`);
525
537
  const opts = { ...DEFAULT_MOCKUP_OPTIONS, ...options };
538
+ // Status bar defaults to on for phones only; tablet/laptop/browser default to off.
539
+ // An explicit user choice wins; browser is additionally excluded at render time below.
540
+ opts.showStatusBar = options?.showStatusBar ?? defaultShowStatusBar(config.category);
526
541
  const requestedOrientation = opts.orientation ?? 'portrait';
527
542
  // Normalize against supported orientations so stale extra configs in Supabase
528
543
  // do not override landscape-only desktop/tablet/browser frames.
@@ -1139,6 +1139,7 @@ export declare const SignedExecutionProgramEnvelopeSchema: z.ZodObject<{
1139
1139
  deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
1140
1140
  publicUrl: z.ZodOptional<z.ZodString>;
1141
1141
  environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
1142
+ blockAnalytics: z.ZodOptional<z.ZodBoolean>;
1142
1143
  }, z.core.$strict>;
1143
1144
  signature: z.ZodString;
1144
1145
  meta: z.ZodOptional<z.ZodObject<{
@@ -1,10 +1,18 @@
1
1
  /**
2
- * Safari macOS browser bar — uses the Figma-exported asset directly.
2
+ * Safari macOS browser bar — RECONSTRUCTED layout (not a stretched asset).
3
3
  *
4
- * Reference SVG: assets/frames/Safari tool bar.svg (1299×88, single-row toolbar).
5
- * The asset is embedded as a string constant via `safari-toolbar-asset.ts`
6
- * (auto-generated). All icons are baked as paths only the URL inside
7
- * the address pill is dynamic.
4
+ * The Figma export (1280×52 visible toolbar) used to be drawn with
5
+ * `preserveAspectRatio="none"` on a FIXED viewBox, so any target width whose
6
+ * aspect ratio differed from 1280:52 horizontally squished every icon worse
7
+ * the wider the mockup got. This now mirrors the Chrome generator
8
+ * (`browser-bar.ts`): a height-driven UNIFORM scale + a DYNAMIC-width viewBox,
9
+ * with the toolbar's icon GLYPHS reused verbatim from the asset but re-anchored
10
+ * - left group (traffic lights, sidebar, back/forward) pinned left,
11
+ * - right group (share, open, new tab, tab overview) pinned right via `dx`,
12
+ * - center address pill: dynamic width, centered, inner icons follow its edges.
13
+ * Only the container and pill backgrounds are redrawn; the Figma blur/blend
14
+ * layers are dropped (resvg ignores them anyway and they are invisible on the
15
+ * white/dark toolbar). Colors and glyph shapes are preserved from the asset.
8
16
  *
9
17
  * Two outputs:
10
18
  * - HTML (Playwright preview / React) — inline SVG inside a scaled wrapper
@@ -1,17 +1,25 @@
1
1
  /**
2
- * Safari macOS browser bar — uses the Figma-exported asset directly.
2
+ * Safari macOS browser bar — RECONSTRUCTED layout (not a stretched asset).
3
3
  *
4
- * Reference SVG: assets/frames/Safari tool bar.svg (1299×88, single-row toolbar).
5
- * The asset is embedded as a string constant via `safari-toolbar-asset.ts`
6
- * (auto-generated). All icons are baked as paths only the URL inside
7
- * the address pill is dynamic.
4
+ * The Figma export (1280×52 visible toolbar) used to be drawn with
5
+ * `preserveAspectRatio="none"` on a FIXED viewBox, so any target width whose
6
+ * aspect ratio differed from 1280:52 horizontally squished every icon worse
7
+ * the wider the mockup got. This now mirrors the Chrome generator
8
+ * (`browser-bar.ts`): a height-driven UNIFORM scale + a DYNAMIC-width viewBox,
9
+ * with the toolbar's icon GLYPHS reused verbatim from the asset but re-anchored
10
+ * - left group (traffic lights, sidebar, back/forward) pinned left,
11
+ * - right group (share, open, new tab, tab overview) pinned right via `dx`,
12
+ * - center address pill: dynamic width, centered, inner icons follow its edges.
13
+ * Only the container and pill backgrounds are redrawn; the Figma blur/blend
14
+ * layers are dropped (resvg ignores them anyway and they are invisible on the
15
+ * white/dark toolbar). Colors and glyph shapes are preserved from the asset.
8
16
  *
9
17
  * Two outputs:
10
18
  * - HTML (Playwright preview / React) — inline SVG inside a scaled wrapper
11
19
  * - SVG (sharp / resvg server compositing)
12
20
  */
13
21
  import { SF_PRO_TEXT_REGULAR, SF_PRO_TEXT_SEMIBOLD, } from './sf-pro-fonts.js';
14
- import { SAFARI_TOOLBAR_SVG, SAFARI_TOOLBAR_VIEWBOX, SAFARI_URL_PILL, } from './safari-toolbar-asset.js';
22
+ import { SAFARI_TOOLBAR_SVG, SAFARI_TOOLBAR_VIEWBOX, SAFARI_TOOLBAR_REF_W, SAFARI_TOOLBAR_REF_H, SAFARI_URL_PILL, } from './safari-toolbar-asset.js';
15
23
  // ── Fonts ────────────────────────────────────────────────────────────────
16
24
  const FONT_CSS_HTML = `<style>
17
25
  @font-face{font-family:'SF Pro Text';src:local('SF Pro Text'),local('.SFNSText'),url('${SF_PRO_TEXT_REGULAR}') format('woff2');font-weight:400;font-style:normal}
@@ -19,12 +27,71 @@ const FONT_CSS_HTML = `<style>
19
27
  </style>`;
20
28
  // SVG output: fonts are NOT embedded inline. Resvg-js 2.6.2 does not honor
21
29
  // `@font-face url(data:font/woff2;base64,…)` declarations inside SVG style
22
- // blocks; fonts must instead be supplied via `font.fontFiles` to the Resvg
23
- // constructor. The browser-bar pipeline (mockup.ts::rasterizeSvg) loads SF
24
- // Pro Text from disk via `sf-pro-resvg-fonts.ts`. The `font-family` hint on
25
- // the URL <text> element below remains for any consumer that DOES honor
26
- // CSS @font-face (a future Resvg release, a different SVG renderer, etc.).
30
+ // blocks; fonts are supplied via `font.fontFiles` to the Resvg constructor by
31
+ // `mockup.ts::rasterizeSvg` (see `sf-pro-resvg-fonts.ts`). The `font-family`
32
+ // hint on the URL <text> element remains for renderers that DO honor it.
27
33
  const FF = "'SF Pro Text',-apple-system,BlinkMacSystemFont,system-ui,sans-serif";
34
+ // ── Reference geometry (asset coordinate space, original 88-tall viewBox) ──
35
+ // The cropped viewBox is `0 18 1280 52`; pills/glyphs keep their native y so
36
+ // the vertical layout is identical to the asset — only X is reflowed.
37
+ const REF_W = SAFARI_TOOLBAR_REF_W; // 1280
38
+ const REF_H = SAFARI_TOOLBAR_REF_H; // 52
39
+ const VB_Y = SAFARI_TOOLBAR_VIEWBOX.y; // 18
40
+ const PILL_Y = SAFARI_URL_PILL.y; // 26
41
+ const PILL_H = SAFARI_URL_PILL.height; // 36
42
+ const PILL_RX = 18;
43
+ const SIDEBAR_PILL = { x: 95, w: 54 };
44
+ const NAV_PILL = { x: 163, w: 67 };
45
+ const RIGHT_PILL = { x: 1134, w: 139 };
46
+ const REF_URL_PILL = { x: SAFARI_URL_PILL.x, w: SAFARI_URL_PILL.width }; // 447 / 385
47
+ // macOS traffic lights (cx derived from x + r), y=37 d=14 → cy=44 r=7.
48
+ const TRAFFIC = [
49
+ { x: 17, color: '#FF736A' },
50
+ { x: 40, color: '#FEBC2E' },
51
+ { x: 63, color: '#19C332' },
52
+ ];
53
+ // End of the fixed left cluster (back/forward pill right edge).
54
+ const LEFT_BOUND = NAV_PILL.x + NAV_PILL.w; // 230
55
+ // Horizontal breathing room between the address pill and the side clusters.
56
+ const PILL_GAP = 28;
57
+ // ── Icon glyph extraction (reuse the asset's vector paths verbatim) ────────
58
+ // We pull only the icon glyphs (specific fills) out of the baked asset and
59
+ // drop the pill-background / blur / blend layers, which we redraw ourselves.
60
+ const ICON_FILLS = new Set(['#4C4C4C', '#808080', '#C6C6C6', '#E6E6E6']);
61
+ // Light → dark recolor for the reused glyphs (mirrors the asset's dark intent).
62
+ const DARK_ICON = {
63
+ '#4C4C4C': '#E4E4E4',
64
+ '#808080': '#8E8E93',
65
+ '#C6C6C6': '#6E6E73',
66
+ '#E6E6E6': '#4A4A4C',
67
+ };
68
+ let cachedGlyphs = null;
69
+ function assetGlyphs() {
70
+ if (cachedGlyphs)
71
+ return cachedGlyphs;
72
+ const glyphs = [];
73
+ const tagRe = /<path\b[^>]*?\/>/g;
74
+ let m;
75
+ while ((m = tagRe.exec(SAFARI_TOOLBAR_SVG)) !== null) {
76
+ const tag = m[0];
77
+ const dM = tag.match(/\sd="([^"]+)"/);
78
+ const fM = tag.match(/\sfill="([^"]+)"/);
79
+ if (!dM || !fM || !ICON_FILLS.has(fM[1]))
80
+ continue;
81
+ const xM = dM[1].match(/M\s*(-?[\d.]+)/);
82
+ glyphs.push({ d: dM[1], fill: fM[1], x0: xM ? parseFloat(xM[1]) : 0 });
83
+ }
84
+ cachedGlyphs = glyphs;
85
+ return glyphs;
86
+ }
87
+ const r = (n) => Math.round(n * 100) / 100;
88
+ function emitGlyph(g, isDark, tx = 0) {
89
+ if (!g)
90
+ return '';
91
+ const fill = isDark ? (DARK_ICON[g.fill] ?? g.fill) : g.fill;
92
+ const t = tx ? ` transform="translate(${r(tx)} 0)"` : '';
93
+ return `<path d="${g.d}" fill="${fill}"${t}/>`;
94
+ }
28
95
  // ── Helpers ──────────────────────────────────────────────────────────────
29
96
  function escText(s) {
30
97
  return s
@@ -36,62 +103,76 @@ function escText(s) {
36
103
  function cleanUrl(raw) {
37
104
  return (raw || 'apple.com').replace(/^https?:\/\//, '').replace(/\/$/, '');
38
105
  }
39
- /** Build the URL <text> overlay placed inside the address pill. */
40
- function urlTextSvg(url, color) {
41
- const cx = SAFARI_URL_PILL.x + SAFARI_URL_PILL.width / 2;
42
- const cy = SAFARI_URL_PILL.y + SAFARI_URL_PILL.height / 2;
43
- return `<text x="${cx}" y="${cy}" font-family="${FF}" font-size="14" font-weight="510" fill="${color}" text-anchor="middle" dominant-baseline="central">${escText(url)}</text>`;
44
- }
45
- /**
46
- * Strip the asset's outer `<svg ...>` opening tag so we can re-emit it with
47
- * our own width/height/viewBox, then append a URL text element before `</svg>`.
48
- */
49
- function buildSafariSvgInner(url, isDark) {
50
- // Asset begins with `<svg width="1299" height="88" viewBox="0 0 1299 88" fill="none" xmlns="...">`
51
- // Drop everything up to the end of that opening tag.
52
- const openEnd = SAFARI_TOOLBAR_SVG.indexOf('>');
53
- const closeStart = SAFARI_TOOLBAR_SVG.lastIndexOf('</svg>');
54
- let inner = SAFARI_TOOLBAR_SVG.slice(openEnd + 1, closeStart);
55
- if (isDark) {
56
- // Minimal dark-mode token swap keeps the asset usable until a dedicated
57
- // dark variant ships. Hardcoded colors map to Safari's dark palette.
58
- inner = inner
59
- .replace(/fill="white"/g, 'fill="#2C2C2E"')
60
- .replace(/fill="#FFFFFF"/gi, 'fill="#2C2C2E"')
61
- .replace(/fill="#F7F7F7"/gi, 'fill="#3A3A3C"')
62
- .replace(/fill="#4C4C4C"/gi, 'fill="#E4E4E4"')
63
- .replace(/fill="#808080"/gi, 'fill="#8E8E93"')
64
- .replace(/fill="#C6C6C6"/gi, 'fill="#6E6E73"')
65
- .replace(/fill="#E6E6E6"/gi, 'fill="#4A4A4C"');
66
- }
106
+ // ── Core: reflowed toolbar SVG at the requested pixel dimensions ───────────
107
+ function buildSafariBarSvg(svgW, svgH, isDark, url) {
108
+ // Uniform scale driven by height; the viewBox width grows with the target so
109
+ // icons never stretch only spacing and the address pill width adapt.
110
+ const s = svgH / REF_H;
111
+ const iw = Math.max(REF_W, Math.round(svgW / s));
112
+ const dx = iw - REF_W; // right-cluster shift
113
+ // Address pill: centered, dynamic width, clamped so it never collides with
114
+ // the fixed side clusters.
115
+ const rightBound = RIGHT_PILL.x + dx; // left edge of the right cluster
116
+ const maxPillW = Math.max(120, (rightBound - PILL_GAP) - (LEFT_BOUND + PILL_GAP));
117
+ let pillW = Math.min(720, Math.max(360, Math.round(iw * 0.36)));
118
+ pillW = Math.min(pillW, maxPillW);
119
+ let pillX = Math.round(iw / 2 - pillW / 2);
120
+ pillX = Math.max(LEFT_BOUND + PILL_GAP, Math.min(pillX, rightBound - PILL_GAP - pillW));
121
+ const pillRight = pillX + pillW;
122
+ // Tokens
123
+ const barBg = isDark ? '#2C2C2E' : '#FFFFFF';
124
+ const pillBg = isDark ? '#3A3A3C' : '#F0F0F0';
125
+ const border = isDark ? '#1F1F22' : '#E4E4E4';
67
126
  const urlColor = isDark ? '#E4E4E4' : '#4C4C4C';
68
- return inner + urlTextSvg(url, urlColor);
127
+ const gl = assetGlyphs();
128
+ const leftGlyphs = gl.filter(g => g.x0 < 240);
129
+ const readerGlyph = gl.find(g => g.fill === '#808080' && g.x0 < 640);
130
+ const reloadGlyph = gl.find(g => g.fill === '#808080' && g.x0 >= 640);
131
+ const rightGlyphs = gl.filter(g => g.x0 >= 1000);
132
+ const cy = PILL_Y + PILL_H / 2; // 44
133
+ const trafficLights = TRAFFIC.map(t => `<circle cx="${t.x + 7}" cy="${cy}" r="7" fill="${t.color}"/>`
134
+ + `<circle cx="${t.x + 7}" cy="${cy}" r="6.75" fill="none" stroke="#000000" stroke-opacity="0.1" stroke-width="0.5"/>`).join('');
135
+ const pill = (x, w) => `<rect x="${r(x)}" y="${PILL_Y}" width="${r(w)}" height="${PILL_H}" rx="${PILL_RX}" fill="${pillBg}"/>`;
136
+ const leftLayer = leftGlyphs.map(g => emitGlyph(g, isDark)).join('');
137
+ const rightLayer = rightGlyphs.map(g => emitGlyph(g, isDark, dx)).join('');
138
+ const body = [
139
+ // Toolbar background (top corners are rounded by the wrapper's clip).
140
+ `<rect x="0" y="${VB_Y}" width="${iw}" height="${REF_H + 1}" fill="${barBg}"/>`,
141
+ // Bottom hairline separator.
142
+ `<rect x="0" y="${VB_Y + REF_H - 0.5}" width="${iw}" height="0.5" fill="${border}"/>`,
143
+ // Pill backgrounds
144
+ pill(SIDEBAR_PILL.x, SIDEBAR_PILL.w),
145
+ pill(NAV_PILL.x, NAV_PILL.w),
146
+ pill(pillX, pillW),
147
+ pill(RIGHT_PILL.x + dx, RIGHT_PILL.w),
148
+ // Glyphs
149
+ trafficLights,
150
+ leftLayer,
151
+ emitGlyph(readerGlyph, isDark, pillX - REF_URL_PILL.x),
152
+ emitGlyph(reloadGlyph, isDark, pillRight - (REF_URL_PILL.x + REF_URL_PILL.w)),
153
+ rightLayer,
154
+ // URL text (centered in the address pill)
155
+ `<text x="${r(pillX + pillW / 2)}" y="${cy}" font-family="${FF}" font-size="14" font-weight="510" fill="${urlColor}" text-anchor="middle" dominant-baseline="central">${escText(url)}</text>`,
156
+ ].join('\n');
157
+ return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${svgW}" height="${svgH}" viewBox="0 ${VB_Y} ${iw} ${REF_H}" preserveAspectRatio="none" fill="none">
158
+ ${body}
159
+ </svg>`;
69
160
  }
70
161
  // ── Generator (HTML, for Playwright client preview) ──────────────────────
71
162
  export function generateSafariBrowserBarHtml(options) {
72
163
  const { config, width, height, pixelScale = 1 } = options;
73
164
  const url = cleanUrl(config.url);
74
165
  const isDark = config.colorScheme === 'dark';
75
- const inner = buildSafariSvgInner(url, isDark);
76
- // The asset's reference is 1299×88. We render it at the target width×height
77
- // by setting the SVG width/height directly — viewBox handles the scaling.
78
- // pixelScale lets the server multiply for high-DPI raster output.
79
166
  const w = width * pixelScale;
80
167
  const h = height * pixelScale;
81
- const vb = SAFARI_TOOLBAR_VIEWBOX;
82
- return `${FONT_CSS_HTML}<div style="width:${w}px;height:${h}px;position:relative;overflow:hidden;line-height:0;font-size:0">
83
- <svg width="${w}" height="${h}" viewBox="${vb.x} ${vb.y} ${vb.width} ${vb.height}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" fill="none" style="display:block">${inner}</svg>
84
- </div>`;
168
+ const svg = buildSafariBarSvg(w, h, isDark, url);
169
+ return `${FONT_CSS_HTML}<div style="width:${w}px;height:${h}px;position:relative;overflow:hidden;line-height:0;font-size:0">${svg}</div>`;
85
170
  }
86
171
  // ── Generator (SVG, for sharp / resvg server compositing) ────────────────
87
172
  export function generateSafariBrowserBarSvg(options) {
88
173
  const { config, width, height } = options;
89
174
  const url = cleanUrl(config.url);
90
175
  const isDark = config.colorScheme === 'dark';
91
- const inner = buildSafariSvgInner(url, isDark);
92
- const vb = SAFARI_TOOLBAR_VIEWBOX;
93
- return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" viewBox="${vb.x} ${vb.y} ${vb.width} ${vb.height}" preserveAspectRatio="none" fill="none">
94
- ${inner}
95
- </svg>`;
176
+ return buildSafariBarSvg(width, height, isDark, url);
96
177
  }
97
178
  //# sourceMappingURL=safari-browser-bar.js.map
package/dist/types.d.ts CHANGED
@@ -257,6 +257,13 @@ export interface BrowserOptions {
257
257
  * secrets here.
258
258
  */
259
259
  extraHttpHeaders?: Record<string, string>;
260
+ /**
261
+ * When `false`, the engine does NOT block third-party web-analytics beacons
262
+ * during capture (AUT-234). Default behavior (`undefined` / `true`) blocks
263
+ * them so a capture never registers a phantom "visit" in the site's
264
+ * analytics. Opt-out is a per-project setting (`projects.block_analytics_enabled`).
265
+ */
266
+ blockAnalytics?: boolean;
260
267
  }
261
268
  export interface OutscaleConfig {
262
269
  /** Uniform padding on all 4 sides (pixels). */
@@ -1158,6 +1158,7 @@ export declare const VideoIngestPayloadSchema: z.ZodObject<{
1158
1158
  deviceConfigs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
1159
1159
  publicUrl: z.ZodOptional<z.ZodString>;
1160
1160
  environmentHttpHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
1161
+ blockAnalytics: z.ZodOptional<z.ZodBoolean>;
1161
1162
  }, z.core.$strict>;
1162
1163
  narration: z.ZodOptional<z.ZodObject<{
1163
1164
  voice: z.ZodString;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",