@agent-native/creative-context 0.5.12 → 0.6.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.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
  import { isBlockedExtensionUrlWithDns, ssrfSafeFetch, } from "@agent-native/core/extensions/url-safety";
3
4
  import { extractStaticWebsiteContext, rankColorSamples, readBoundedResponseBytes, } from "@agent-native/core/ingestion";
4
5
  import { normalizeWhitespace } from "./normalize.js";
@@ -12,6 +13,36 @@ const MAX_TYPOGRAPHY = 100;
12
13
  const MAX_SPACING = 100;
13
14
  const MAX_RADII = 64;
14
15
  const MAX_CSS_VARIABLES = 128;
16
+ const MAX_COMPONENT_STYLES = 24;
17
+ const FONT_READY_TIMEOUT_MS = 4_000;
18
+ const MAX_BROWSER_RESOURCE_BYTES = 12 * 1024 * 1024;
19
+ const MAX_BROWSER_RESOURCE_COUNT = 400;
20
+ const MAX_BROWSER_RESOURCE_BYTES_TOTAL = 64 * 1024 * 1024;
21
+ const BROWSER_RESOURCE_TIMEOUT_MS = 15_000;
22
+ const BROWSER_REQUEST_HEADERS = new Set([
23
+ "accept",
24
+ "accept-language",
25
+ "if-modified-since",
26
+ "if-none-match",
27
+ "origin",
28
+ "range",
29
+ "referer",
30
+ "user-agent",
31
+ ]);
32
+ const BROWSER_RESPONSE_HEADERS = new Set([
33
+ "connection",
34
+ "content-encoding",
35
+ "content-length",
36
+ "keep-alive",
37
+ "proxy-authenticate",
38
+ "proxy-authorization",
39
+ "set-cookie",
40
+ "set-cookie2",
41
+ "te",
42
+ "trailer",
43
+ "transfer-encoding",
44
+ "upgrade",
45
+ ]);
15
46
  export class LayeredRenderedPageProvider {
16
47
  #requestBuilderBrowserConnection;
17
48
  #loadPlaywright;
@@ -37,7 +68,7 @@ export class LayeredRenderedPageProvider {
37
68
  const connection = await this.#requestBuilderBrowserConnection({
38
69
  sessionId: `creative-context-${randomUUID()}`,
39
70
  });
40
- const wsUrl = stringValue(connection.wsUrl);
71
+ const wsUrl = typeof connection.wsUrl === "string" ? connection.wsUrl.trim() : "";
41
72
  if (!wsUrl)
42
73
  throw new Error("Builder Browser did not return wsUrl.");
43
74
  return await renderWithPlaywright(playwright, request, warnings, "builder-browser", wsUrl);
@@ -75,40 +106,80 @@ export class LayeredRenderedPageProvider {
75
106
  return renderStatic(request, warnings, this.#staticFetch);
76
107
  }
77
108
  }
78
- async function renderWithPlaywright(playwright, request, warnings, method, wsUrl) {
109
+ export async function renderWithPlaywright(playwright, request, warnings, method, wsUrl) {
79
110
  const browser = wsUrl
80
111
  ? await playwright.chromium.connectOverCDP(wsUrl)
81
- : await playwright.chromium.launch({ headless: true });
112
+ : await launchChromium(playwright.chromium);
113
+ let isolatedContext;
82
114
  try {
83
- const context = browser.contexts()[0] ?? (await browser.newContext());
84
- const page = context.pages()[0] ?? (await context.newPage());
85
- await installNavigationGuard(page);
115
+ // Never reuse a connected browser's ambient context: it can carry cookies,
116
+ // extensions, or tabs from another workflow. The safe proxy below is only
117
+ // useful when the page itself is isolated from that state.
118
+ if (!browser.newContext) {
119
+ throw new Error("Browser did not provide isolated context support.");
120
+ }
121
+ isolatedContext = await browser.newContext();
122
+ const page = await isolatedContext.newPage();
123
+ const getFinalNavigationUrl = await installNavigationGuard(page, request, warnings);
86
124
  await page.setViewportSize({ width: 1440, height: 900 });
87
125
  await page.goto(request.url, {
88
126
  timeout: boundedTimeout(request.timeoutMs),
89
127
  waitUntil: request.waitUntil ?? "domcontentloaded",
90
128
  });
91
- const finalUrl = page.url();
129
+ await page
130
+ .waitForLoadState?.("load", {
131
+ timeout: Math.min(8_000, boundedTimeout(request.timeoutMs)),
132
+ })
133
+ .catch((error) => {
134
+ warnings.push(`Browser load stabilization unavailable: ${errorMessage(error)}`);
135
+ });
136
+ // React hydration, CSS-in-JS insertion, and web fonts commonly finish just
137
+ // after `load`. Give those layers a bounded chance to settle, then capture
138
+ // the computed cascade rather than the server HTML.
139
+ await page
140
+ .waitForLoadState?.("networkidle", { timeout: 4_000 })
141
+ .catch((error) => {
142
+ warnings.push(`Browser network-idle stabilization unavailable: ${errorMessage(error)}`);
143
+ });
144
+ await waitForFontReadiness(page, Math.min(FONT_READY_TIMEOUT_MS, boundedTimeout(request.timeoutMs))).catch((error) => {
145
+ warnings.push(`Browser font readiness unavailable: ${errorMessage(error)}`);
146
+ });
147
+ await new Promise((resolve) => setTimeout(resolve, 150));
148
+ await page.evaluate(dismissConsentOverlays).catch(() => undefined);
149
+ const finalUrl = getFinalNavigationUrl() ?? page.url();
92
150
  await assertPublicBrowserUrl(finalUrl);
93
151
  const [title, text, desktopScreenshot, extraction] = await Promise.all([
94
- page.title().catch(() => ""),
152
+ page.title().catch((error) => {
153
+ warnings.push(`Browser title extraction unavailable: ${errorMessage(error)}`);
154
+ return "";
155
+ }),
95
156
  page
96
157
  .locator("body")
97
158
  .innerText()
98
- .catch(() => ""),
159
+ .catch((error) => {
160
+ warnings.push(`Browser text extraction unavailable: ${errorMessage(error)}`);
161
+ return "";
162
+ }),
99
163
  page
100
164
  .screenshot({ type: "png", fullPage: false })
101
165
  .then(boundedScreenshot)
102
- .catch(() => undefined),
103
- page
104
- .evaluate(captureRenderedWebsiteContext)
105
- .catch(() => emptyExtraction()),
166
+ .catch((error) => {
167
+ warnings.push(`Desktop screenshot unavailable: ${errorMessage(error)}`);
168
+ return undefined;
169
+ }),
170
+ page.evaluate(captureRenderedWebsiteContext).catch((error) => {
171
+ warnings.push(`Browser style extraction unavailable: ${errorMessage(error)}`);
172
+ return emptyExtraction();
173
+ }),
106
174
  ]);
107
175
  await page.setViewportSize({ width: 390, height: 844 });
108
176
  const mobileScreenshot = await page
109
177
  .screenshot({ type: "png", fullPage: false })
110
178
  .then(boundedScreenshot)
111
- .catch(() => undefined);
179
+ .catch((error) => {
180
+ warnings.push(`Mobile screenshot unavailable: ${errorMessage(error)}`);
181
+ return undefined;
182
+ });
112
183
  const unboundedText = normalizeWhitespace(text);
113
184
  const textTruncated = unboundedText.length > MAX_RENDERED_TEXT_CHARS;
114
185
  const normalizedText = unboundedText.slice(0, MAX_RENDERED_TEXT_CHARS);
@@ -173,21 +244,76 @@ async function renderWithPlaywright(playwright, request, warnings, method, wsUrl
173
244
  };
174
245
  }
175
246
  finally {
247
+ await isolatedContext?.close?.().catch((error) => {
248
+ warnings.push(`Browser context cleanup unavailable: ${errorMessage(error)}`);
249
+ });
176
250
  await browser.close().catch(() => undefined);
177
251
  }
178
252
  }
179
- async function installNavigationGuard(page) {
253
+ async function waitForFontReadiness(page, timeoutMs) {
254
+ let timeout;
255
+ try {
256
+ await Promise.race([
257
+ page.evaluate(async () => {
258
+ if (document.fonts?.ready)
259
+ await document.fonts.ready;
260
+ }),
261
+ new Promise((_, reject) => {
262
+ timeout = setTimeout(() => {
263
+ reject(new Error(`font readiness timed out after ${timeoutMs}ms`));
264
+ }, timeoutMs);
265
+ }),
266
+ ]);
267
+ }
268
+ finally {
269
+ if (timeout !== undefined)
270
+ clearTimeout(timeout);
271
+ }
272
+ }
273
+ async function installNavigationGuard(page, renderRequest, warnings) {
274
+ let finalNavigationUrl;
275
+ let resourceCount = 0;
276
+ let resourceBytes = 0;
277
+ let reservedResourceBytes = 0;
278
+ const bodyBudgetWaiters = [];
279
+ let resourceLimitWarningAdded = false;
280
+ let blockedResourceWarningAdded = false;
281
+ let failedResourceWarningAdded = false;
282
+ const reserveBodyBudget = async () => {
283
+ while (reservedResourceBytes + MAX_BROWSER_RESOURCE_BYTES >
284
+ MAX_BROWSER_RESOURCE_BYTES_TOTAL) {
285
+ await new Promise((resolve) => {
286
+ bodyBudgetWaiters.push(resolve);
287
+ });
288
+ }
289
+ reservedResourceBytes += MAX_BROWSER_RESOURCE_BYTES;
290
+ let released = false;
291
+ return () => {
292
+ if (released)
293
+ return;
294
+ released = true;
295
+ reservedResourceBytes -= MAX_BROWSER_RESOURCE_BYTES;
296
+ bodyBudgetWaiters.shift()?.();
297
+ };
298
+ };
299
+ const addWarning = (value) => {
300
+ if (!warnings.includes(value))
301
+ warnings.push(value);
302
+ };
180
303
  await page.route("**/*", async (route) => {
181
- const request = route.request();
304
+ const browserRequest = route.request();
182
305
  let parsed;
183
306
  try {
184
- parsed = new URL(request.url());
307
+ parsed = new URL(browserRequest.url());
185
308
  }
186
309
  catch {
310
+ // coercion-ok: an absent optional package means the next compatible probe should run.
187
311
  await route.abort("blockedbyclient");
188
312
  return;
189
313
  }
190
- if (parsed.protocol === "data:" || parsed.protocol === "blob:") {
314
+ if (parsed.protocol === "about:" ||
315
+ parsed.protocol === "data:" ||
316
+ parsed.protocol === "blob:") {
191
317
  await route.continue();
192
318
  return;
193
319
  }
@@ -195,15 +321,237 @@ async function installNavigationGuard(page) {
195
321
  await route.abort("blockedbyclient");
196
322
  return;
197
323
  }
324
+ const method = (browserRequest.method?.() ?? "GET").toUpperCase();
325
+ if (method !== "GET" && method !== "HEAD") {
326
+ await route.abort("blockedbyclient");
327
+ if (!resourceLimitWarningAdded) {
328
+ resourceLimitWarningAdded = true;
329
+ addWarning("Browser blocked a non-read-only resource request during extraction.");
330
+ }
331
+ return;
332
+ }
333
+ if (resourceCount >= MAX_BROWSER_RESOURCE_COUNT) {
334
+ await route.abort("blockedbyclient");
335
+ if (!resourceLimitWarningAdded) {
336
+ resourceLimitWarningAdded = true;
337
+ addWarning(`Browser resource budget reached (${MAX_BROWSER_RESOURCE_COUNT} requests).`);
338
+ }
339
+ return;
340
+ }
341
+ // Reserve the request slot before the first await. Browser route handlers
342
+ // overlap, so incrementing only after the proxy response arrives lets a
343
+ // burst of requests all pass the limit check.
344
+ resourceCount += 1;
345
+ let bodyBudgetRelease;
346
+ let committedBytes = 0;
347
+ let fulfilled = false;
198
348
  try {
199
349
  await assertPublicBrowserUrl(parsed.href);
350
+ const response = await ssrfSafeFetch(parsed.href, {
351
+ method,
352
+ headers: browserRequestHeaders(browserRequest),
353
+ signal: AbortSignal.timeout(Math.min(BROWSER_RESOURCE_TIMEOUT_MS, boundedTimeout(renderRequest.timeoutMs))),
354
+ }, { maxRedirects: 5 });
355
+ bodyBudgetRelease = await reserveBodyBudget();
356
+ const body = await readBoundedResponseBytes(response, MAX_BROWSER_RESOURCE_BYTES);
357
+ bodyBudgetRelease();
358
+ bodyBudgetRelease = undefined;
359
+ if (resourceBytes + body.byteLength > MAX_BROWSER_RESOURCE_BYTES_TOTAL) {
360
+ await route.abort("blockedbyclient");
361
+ if (!resourceLimitWarningAdded) {
362
+ resourceLimitWarningAdded = true;
363
+ addWarning(`Browser resource budget reached (${MAX_BROWSER_RESOURCE_BYTES_TOTAL} bytes).`);
364
+ }
365
+ return;
366
+ }
367
+ resourceBytes += body.byteLength;
368
+ committedBytes = body.byteLength;
369
+ if (browserRequest.isNavigationRequest()) {
370
+ finalNavigationUrl = response.url || parsed.href;
371
+ }
372
+ await route.fulfill({
373
+ status: response.status,
374
+ headers: browserResponseHeaders(response),
375
+ body: Buffer.from(body),
376
+ });
377
+ fulfilled = true;
200
378
  }
201
- catch {
379
+ catch (error) {
380
+ if (committedBytes > 0) {
381
+ resourceBytes -= committedBytes;
382
+ committedBytes = 0;
383
+ }
202
384
  await route.abort("blockedbyclient");
203
- return;
385
+ if (isSsrfError(error)) {
386
+ if (!blockedResourceWarningAdded) {
387
+ blockedResourceWarningAdded = true;
388
+ addWarning("Some browser resources were blocked by the SSRF safety policy.");
389
+ }
390
+ }
391
+ else if (!failedResourceWarningAdded) {
392
+ failedResourceWarningAdded = true;
393
+ addWarning("Some browser resources could not be fetched through the safe network proxy.");
394
+ }
395
+ }
396
+ finally {
397
+ bodyBudgetRelease?.();
398
+ if (!fulfilled)
399
+ resourceCount -= 1;
400
+ }
401
+ });
402
+ return () => finalNavigationUrl;
403
+ }
404
+ function browserRequestHeaders(request) {
405
+ const source = request.headers?.() ?? {};
406
+ return Object.fromEntries(Object.entries(source).filter(([name, value]) => {
407
+ return BROWSER_REQUEST_HEADERS.has(name.toLowerCase()) && Boolean(value);
408
+ }));
409
+ }
410
+ function browserResponseHeaders(response) {
411
+ const headers = {};
412
+ response.headers.forEach((value, name) => {
413
+ if (!BROWSER_RESPONSE_HEADERS.has(name.toLowerCase())) {
414
+ headers[name] = value;
204
415
  }
205
- await route.continue();
206
416
  });
417
+ return headers;
418
+ }
419
+ function isSsrfError(error) {
420
+ return /ssrf blocked|private\/internal|connect blocked/i.test(errorMessage(error));
421
+ }
422
+ const SYSTEM_CHROME_EXECUTABLES = [
423
+ "/usr/bin/google-chrome-stable",
424
+ "/usr/bin/google-chrome",
425
+ "/usr/bin/chromium",
426
+ "/usr/bin/chromium-browser",
427
+ ];
428
+ async function launchChromium(chromium) {
429
+ const launchOptions = {
430
+ headless: true,
431
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
432
+ };
433
+ let missingBrowserError;
434
+ try {
435
+ return await chromium.launch(launchOptions);
436
+ }
437
+ catch (error) {
438
+ if (!isMissingBrowserError(error))
439
+ throw error;
440
+ missingBrowserError = error;
441
+ }
442
+ const serverlessChromium = await loadOptionalServerlessChromium();
443
+ if (serverlessChromium) {
444
+ try {
445
+ const executablePath = await serverlessChromium.executablePath();
446
+ if (executablePath) {
447
+ return await chromium.launch({
448
+ ...launchOptions,
449
+ args: [...launchOptions.args, ...(serverlessChromium.args ?? [])],
450
+ executablePath,
451
+ });
452
+ }
453
+ }
454
+ catch (error) {
455
+ if (!isMissingBrowserError(error))
456
+ throw error;
457
+ missingBrowserError = error;
458
+ }
459
+ }
460
+ for (const executablePath of SYSTEM_CHROME_EXECUTABLES) {
461
+ if (!existsSync(executablePath))
462
+ continue;
463
+ try {
464
+ return await chromium.launch({ ...launchOptions, executablePath });
465
+ }
466
+ catch {
467
+ continue;
468
+ }
469
+ }
470
+ if (missingBrowserError) {
471
+ throw missingBrowserError;
472
+ }
473
+ throw new Error("No Chromium executable is available for browser extraction.");
474
+ }
475
+ async function loadOptionalServerlessChromium() {
476
+ const specifier = "@sparticuz/chromium";
477
+ try {
478
+ const module = (await import(/* @vite-ignore */ specifier));
479
+ const chromium = module.default ?? module;
480
+ return typeof chromium.executablePath === "function"
481
+ ? chromium
482
+ : null;
483
+ }
484
+ catch {
485
+ // coercion-ok: this optional capability is absent in non-serverless installs.
486
+ return null;
487
+ }
488
+ }
489
+ /*
490
+ * Kept separate from Playwright loading so a deployment can omit the large
491
+ * serverless Chromium package and still use Builder Browser or system Chrome.
492
+ */
493
+ async function loadOptionalPlaywright() {
494
+ for (const specifier of [
495
+ "playwright",
496
+ "@playwright/test",
497
+ "playwright-core",
498
+ ]) {
499
+ try {
500
+ const module = (await import(
501
+ /* @vite-ignore */ specifier));
502
+ for (const candidate of [module.default, module]) {
503
+ if (typeof candidate?.chromium?.launch === "function") {
504
+ return candidate;
505
+ }
506
+ }
507
+ // coercion-ok: an absent optional package means the next compatible probe should run.
508
+ }
509
+ catch {
510
+ // Try the next compatible browser package before falling back to HTML.
511
+ }
512
+ }
513
+ // coercion-ok: browser packages are optional in non-Node runtimes.
514
+ return null;
515
+ }
516
+ async function defaultBuilderBrowserRequest(input) {
517
+ const server = (await import("@agent-native/core/server"));
518
+ if (!server.requestBuilderBrowserConnection) {
519
+ throw new Error("@agent-native/core/server does not export requestBuilderBrowserConnection.");
520
+ }
521
+ return server.requestBuilderBrowserConnection(input);
522
+ }
523
+ function isMissingBrowserError(error) {
524
+ const message = error instanceof Error ? error.message : String(error);
525
+ return /Executable doesn't exist|playwright install|browser.*not found|chromium.*not found/i.test(message);
526
+ }
527
+ /** Close common consent banners without accepting tracking or changing page data. */
528
+ function dismissConsentOverlays() {
529
+ const selectors = [
530
+ '[aria-label*="reject" i]',
531
+ '[aria-label*="decline" i]',
532
+ '[aria-label*="close" i]',
533
+ '[data-testid*="reject" i]',
534
+ '[data-testid*="decline" i]',
535
+ '[data-testid*="close" i]',
536
+ 'button[id*="reject" i]',
537
+ 'button[id*="decline" i]',
538
+ 'button[class*="reject" i]',
539
+ 'button[class*="decline" i]',
540
+ ];
541
+ for (const selector of selectors) {
542
+ const element = document.querySelector(selector);
543
+ if (!element)
544
+ continue;
545
+ const rect = element.getBoundingClientRect();
546
+ const style = getComputedStyle(element);
547
+ if (rect.width > 0 &&
548
+ rect.height > 0 &&
549
+ style.visibility !== "hidden" &&
550
+ style.display !== "none") {
551
+ element.click();
552
+ return;
553
+ }
554
+ }
207
555
  }
208
556
  async function renderStatic(request, warnings, fetcher) {
209
557
  const response = await fetcher(request.url, {
@@ -264,22 +612,6 @@ async function assertPublicBrowserUrl(value) {
264
612
  throw new Error("SSRF blocked: website resolved to a private/internal host.");
265
613
  }
266
614
  }
267
- async function defaultBuilderBrowserRequest(input) {
268
- const server = (await import("@agent-native/core/server"));
269
- if (!server.requestBuilderBrowserConnection) {
270
- throw new Error("@agent-native/core/server does not export requestBuilderBrowserConnection.");
271
- }
272
- return server.requestBuilderBrowserConnection(input);
273
- }
274
- async function loadOptionalPlaywright() {
275
- const specifier = "playwright";
276
- try {
277
- return (await import(specifier));
278
- }
279
- catch {
280
- return null;
281
- }
282
- }
283
615
  function captureRenderedWebsiteContext() {
284
616
  const MAX_ASSETS = 500;
285
617
  const MAX_LINKS = 500;
@@ -287,15 +619,148 @@ function captureRenderedWebsiteContext() {
287
619
  const MAX_TYPE_STYLES = 100;
288
620
  const MAX_SPACING_VALUES = 100;
289
621
  const MAX_RADIUS_VALUES = 64;
622
+ const MAX_SHADOWS = 32;
623
+ const MAX_BACKGROUNDS = 32;
290
624
  const MAX_VARIABLES = 128;
291
625
  const MAX_TEXT = 2_000_000;
626
+ const MAX_COMPONENT_STYLES = 24;
292
627
  const assets = new Map();
293
628
  const links = new Set();
294
629
  const colors = [];
295
630
  const typography = new Map();
296
631
  const spacing = new Set();
297
632
  const radii = new Set();
633
+ const shadows = new Set();
634
+ const backgrounds = new Set();
635
+ const components = [];
298
636
  const cssVariables = {};
637
+ const semanticColors = {};
638
+ const isOpaque = (value) => {
639
+ const normalized = value.trim().toLowerCase();
640
+ if (!normalized || normalized === "transparent")
641
+ return false;
642
+ const functionBody = normalized.match(/^[a-z-]+\((.*)\)$/)?.[1];
643
+ if (!functionBody)
644
+ return true;
645
+ const alphaValue = functionBody.includes("/")
646
+ ? functionBody.split("/").at(-1)?.trim()
647
+ : functionBody.split(",").length === 4
648
+ ? functionBody.split(",").at(-1)?.trim()
649
+ : undefined;
650
+ if (!alphaValue)
651
+ return true;
652
+ const alpha = alphaValue.endsWith("%")
653
+ ? Number.parseFloat(alphaValue) / 100
654
+ : Number.parseFloat(alphaValue);
655
+ return Number.isNaN(alpha) || alpha > 0.02;
656
+ };
657
+ const addColor = (value) => {
658
+ if (colors.length >= MAX_COLOR_VALUES || !isOpaque(value))
659
+ return;
660
+ const normalized = value.trim();
661
+ if (!colors.includes(normalized))
662
+ colors.push(normalized);
663
+ };
664
+ const visible = (element) => {
665
+ const style = getComputedStyle(element);
666
+ const rect = element.getBoundingClientRect();
667
+ return (rect.width > 0 &&
668
+ rect.height > 0 &&
669
+ style.display !== "none" &&
670
+ style.visibility !== "hidden" &&
671
+ Number(style.opacity || 1) > 0.02);
672
+ };
673
+ const firstVisible = (selector) => Array.from(document.querySelectorAll(selector)).find(visible);
674
+ const opaqueValue = (value) => isOpaque(value) ? value.trim() : undefined;
675
+ const recordComputedStyle = (element, role) => {
676
+ const style = getComputedStyle(element);
677
+ const values = [
678
+ style.color,
679
+ style.backgroundColor,
680
+ style.borderTopColor,
681
+ style.borderRightColor,
682
+ style.borderBottomColor,
683
+ style.borderLeftColor,
684
+ ];
685
+ values.forEach(addColor);
686
+ if (style.boxShadow && style.boxShadow !== "none") {
687
+ if (shadows.size < MAX_SHADOWS)
688
+ shadows.add(style.boxShadow);
689
+ }
690
+ if (style.backgroundImage && style.backgroundImage !== "none") {
691
+ if (backgrounds.size < MAX_BACKGROUNDS) {
692
+ backgrounds.add(style.backgroundImage);
693
+ }
694
+ }
695
+ for (const value of [
696
+ style.marginTop,
697
+ style.marginRight,
698
+ style.marginBottom,
699
+ style.marginLeft,
700
+ style.paddingTop,
701
+ style.paddingRight,
702
+ style.paddingBottom,
703
+ style.paddingLeft,
704
+ style.gap,
705
+ style.rowGap,
706
+ style.columnGap,
707
+ ]) {
708
+ if (spacing.size < MAX_SPACING_VALUES &&
709
+ value &&
710
+ value !== "0px" &&
711
+ value !== "normal") {
712
+ spacing.add(value);
713
+ }
714
+ }
715
+ for (const value of [
716
+ style.borderTopLeftRadius,
717
+ style.borderTopRightRadius,
718
+ style.borderBottomRightRadius,
719
+ style.borderBottomLeftRadius,
720
+ ]) {
721
+ if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
722
+ radii.add(value);
723
+ }
724
+ }
725
+ const type = {
726
+ family: style.fontFamily,
727
+ size: style.fontSize,
728
+ weight: style.fontWeight,
729
+ lineHeight: style.lineHeight,
730
+ letterSpacing: style.letterSpacing,
731
+ };
732
+ if (typography.size < MAX_TYPE_STYLES && type.family) {
733
+ typography.set(JSON.stringify(type), type);
734
+ }
735
+ if (!role || components.length >= MAX_COMPONENT_STYLES)
736
+ return style;
737
+ components.push({
738
+ role,
739
+ fontFamily: style.fontFamily,
740
+ fontSize: style.fontSize,
741
+ fontWeight: style.fontWeight,
742
+ lineHeight: style.lineHeight,
743
+ letterSpacing: style.letterSpacing,
744
+ color: opaqueValue(style.color),
745
+ backgroundColor: opaqueValue(style.backgroundColor),
746
+ backgroundImage: style.backgroundImage !== "none" ? style.backgroundImage : undefined,
747
+ border: style.borderStyle !== "none" && style.borderWidth !== "0px"
748
+ ? style.border
749
+ : undefined,
750
+ borderRadius: style.borderTopLeftRadius !== "0px" ? style.borderRadius : undefined,
751
+ boxShadow: style.boxShadow !== "none" ? style.boxShadow : undefined,
752
+ padding: style.padding !== "0px" ? style.padding : undefined,
753
+ gap: style.gap !== "normal" ? style.gap : undefined,
754
+ textTransform: style.textTransform !== "none" ? style.textTransform : undefined,
755
+ });
756
+ return style;
757
+ };
758
+ const styleFor = (selector, role) => {
759
+ const element = firstVisible(selector);
760
+ if (!element)
761
+ return undefined;
762
+ return recordComputedStyle(element, role);
763
+ };
299
764
  const addAsset = (raw, kind, role) => {
300
765
  if (!raw || assets.size >= MAX_ASSETS)
301
766
  return;
@@ -374,52 +839,67 @@ function captureRenderedWebsiteContext() {
374
839
  }
375
840
  }
376
841
  }
377
- const elements = Array.from(document.querySelectorAll("body *")).slice(0, 500);
842
+ const bodyStyle = document.body
843
+ ? recordComputedStyle(document.body)
844
+ : undefined;
845
+ const rootBackground = opaqueValue(rootStyle.backgroundColor);
846
+ const bodyBackground = bodyStyle
847
+ ? opaqueValue(bodyStyle.backgroundColor)
848
+ : undefined;
849
+ const headingStyle = styleFor("h1, h2, h3", "heading");
850
+ const textStyle = styleFor("p, li, label, body", "body");
851
+ const buttonStyle = styleFor('button, [role="button"], input[type="submit"], a[class*="button" i], a[class*="cta" i]', "button");
852
+ const linkStyle = styleFor("a[href]", "link");
853
+ const cardStyle = styleFor('article, [class*="card" i], [class*="panel" i], [class*="surface" i], section', "card");
854
+ styleFor("input, textarea, select", "input");
855
+ styleFor("nav, header", "nav");
856
+ styleFor('main, [class*="hero" i]', "hero");
857
+ const setSemantic = (role, value) => {
858
+ if (value)
859
+ semanticColors[role] = value;
860
+ };
861
+ setSemantic("background", bodyBackground ?? rootBackground);
862
+ setSemantic("surface", cardStyle ? opaqueValue(cardStyle.backgroundColor) : undefined);
863
+ setSemantic("text", textStyle ? opaqueValue(textStyle.color) : undefined);
864
+ const mutedElement = firstVisible("small, figcaption, [class*='muted' i]");
865
+ setSemantic("textMuted", mutedElement
866
+ ? opaqueValue(getComputedStyle(mutedElement).color)
867
+ : undefined);
868
+ setSemantic("accent", linkStyle
869
+ ? opaqueValue(linkStyle.color)
870
+ : buttonStyle
871
+ ? opaqueValue(buttonStyle.backgroundColor)
872
+ : undefined);
873
+ setSemantic("primary", buttonStyle
874
+ ? (opaqueValue(buttonStyle.backgroundColor) ??
875
+ opaqueValue(buttonStyle.color))
876
+ : colors[0]);
877
+ setSemantic("secondary", semanticColors.surface ?? colors[1] ?? semanticColors.background);
878
+ const layoutElement = firstVisible("main, [role='main'], body > div");
879
+ const layoutStyle = layoutElement
880
+ ? getComputedStyle(layoutElement)
881
+ : undefined;
882
+ const layoutRect = layoutElement?.getBoundingClientRect();
883
+ const layout = {
884
+ contentWidth: layoutStyle?.maxWidth && layoutStyle.maxWidth !== "none"
885
+ ? layoutStyle.maxWidth
886
+ : layoutRect && layoutRect.width > 0
887
+ ? `${Math.round(layoutRect.width)}px`
888
+ : undefined,
889
+ pagePadding: bodyStyle?.padding && bodyStyle.padding !== "0px"
890
+ ? bodyStyle.padding
891
+ : undefined,
892
+ sectionGap: firstVisible("section, article") &&
893
+ getComputedStyle(firstVisible("section, article")).gap !== "normal" &&
894
+ getComputedStyle(firstVisible("section, article")).gap !== "0px"
895
+ ? getComputedStyle(firstVisible("section, article")).gap
896
+ : undefined,
897
+ };
898
+ const elements = Array.from(document.querySelectorAll("body *"))
899
+ .filter(visible)
900
+ .slice(0, 700);
378
901
  for (const element of elements) {
379
- const style = getComputedStyle(element);
380
- if (colors.length < MAX_COLOR_VALUES) {
381
- colors.push(style.color, style.backgroundColor, style.borderTopColor, style.borderRightColor, style.borderBottomColor, style.borderLeftColor);
382
- }
383
- const type = {
384
- family: style.fontFamily,
385
- size: style.fontSize,
386
- weight: style.fontWeight,
387
- lineHeight: style.lineHeight,
388
- letterSpacing: style.letterSpacing,
389
- };
390
- if (typography.size < MAX_TYPE_STYLES) {
391
- typography.set(JSON.stringify(type), type);
392
- }
393
- for (const value of [
394
- style.marginTop,
395
- style.marginRight,
396
- style.marginBottom,
397
- style.marginLeft,
398
- style.paddingTop,
399
- style.paddingRight,
400
- style.paddingBottom,
401
- style.paddingLeft,
402
- style.gap,
403
- style.rowGap,
404
- style.columnGap,
405
- ]) {
406
- if (spacing.size < MAX_SPACING_VALUES &&
407
- value &&
408
- value !== "0px" &&
409
- value !== "normal") {
410
- spacing.add(value);
411
- }
412
- }
413
- for (const value of [
414
- style.borderTopLeftRadius,
415
- style.borderTopRightRadius,
416
- style.borderBottomRightRadius,
417
- style.borderBottomLeftRadius,
418
- ]) {
419
- if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
420
- radii.add(value);
421
- }
422
- }
902
+ recordComputedStyle(element);
423
903
  }
424
904
  return {
425
905
  title: document.title,
@@ -432,10 +912,21 @@ function captureRenderedWebsiteContext() {
432
912
  spacing: [...spacing],
433
913
  radii: [...radii],
434
914
  cssVariables,
915
+ semanticColors,
916
+ shadows: [...shadows],
917
+ backgrounds: [...backgrounds],
918
+ components,
919
+ layout,
435
920
  },
436
921
  };
437
922
  }
438
923
  export function boundWebsiteExtraction(extraction) {
924
+ const boundedComponents = extraction.designTokens.components
925
+ ?.slice(0, MAX_COMPONENT_STYLES)
926
+ .map((component) => Object.fromEntries(Object.entries(component).map(([key, value]) => [
927
+ key,
928
+ typeof value === "string" ? value.slice(0, 500) : value,
929
+ ])));
439
930
  return {
440
931
  title: normalizeWhitespace(extraction.title).slice(0, 500),
441
932
  text: normalizeWhitespace(extraction.text).slice(0, MAX_RENDERED_TEXT_CHARS),
@@ -454,6 +945,35 @@ export function boundWebsiteExtraction(extraction) {
454
945
  cssVariables: Object.fromEntries(Object.entries(extraction.designTokens.cssVariables)
455
946
  .slice(0, MAX_CSS_VARIABLES)
456
947
  .map(([name, value]) => [name.slice(0, 500), value.slice(0, 4_096)])),
948
+ ...(extraction.designTokens.semanticColors
949
+ ? {
950
+ semanticColors: Object.fromEntries(Object.entries(extraction.designTokens.semanticColors)
951
+ .filter(([, value]) => typeof value === "string" && value)
952
+ .map(([name, value]) => [name, value.slice(0, 200)])),
953
+ }
954
+ : {}),
955
+ ...(extraction.designTokens.shadows
956
+ ? {
957
+ shadows: extraction.designTokens.shadows
958
+ .slice(0, 32)
959
+ .map((value) => value.slice(0, 500)),
960
+ }
961
+ : {}),
962
+ ...(extraction.designTokens.backgrounds
963
+ ? {
964
+ backgrounds: extraction.designTokens.backgrounds
965
+ .slice(0, 32)
966
+ .map((value) => value.slice(0, 500)),
967
+ }
968
+ : {}),
969
+ ...(boundedComponents ? { components: boundedComponents } : {}),
970
+ ...(extraction.designTokens.layout
971
+ ? {
972
+ layout: Object.fromEntries(Object.entries(extraction.designTokens.layout)
973
+ .filter(([, value]) => typeof value === "string" && value)
974
+ .map(([name, value]) => [name, value.slice(0, 200)])),
975
+ }
976
+ : {}),
457
977
  },
458
978
  };
459
979
  }
@@ -495,9 +1015,6 @@ function boundedTimeout(value) {
495
1015
  function boundedScreenshot(value) {
496
1016
  return value.byteLength <= MAX_SCREENSHOT_BYTES ? value : undefined;
497
1017
  }
498
- function stringValue(value) {
499
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
500
- }
501
1018
  function errorMessage(error) {
502
1019
  return error instanceof Error ? error.message : String(error);
503
1020
  }