@agent-native/creative-context 0.5.11 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/actions/list-context-connections.js +1 -1
  2. package/dist/client/CreativeContextPanel.js +1 -1
  3. package/dist/connectors/index.d.ts +1 -0
  4. package/dist/connectors/index.d.ts.map +1 -1
  5. package/dist/connectors/index.js +1 -0
  6. package/dist/connectors/index.js.map +1 -1
  7. package/dist/connectors/rendered-design.d.ts +68 -0
  8. package/dist/connectors/rendered-design.d.ts.map +1 -0
  9. package/dist/connectors/rendered-design.js +362 -0
  10. package/dist/connectors/rendered-design.js.map +1 -0
  11. package/dist/connectors/rendered-page.d.ts +19 -1
  12. package/dist/connectors/rendered-page.d.ts.map +1 -1
  13. package/dist/connectors/rendered-page.js +385 -129
  14. package/dist/connectors/rendered-page.js.map +1 -1
  15. package/dist/jobs/server-worker.d.ts.map +1 -1
  16. package/dist/jobs/server-worker.js +5 -3
  17. package/dist/jobs/server-worker.js.map +1 -1
  18. package/dist/server/index.d.ts +1 -0
  19. package/dist/server/index.d.ts.map +1 -1
  20. package/dist/server/index.js +5 -2
  21. package/dist/server/index.js.map +1 -1
  22. package/package.json +3 -3
  23. package/src/actions/list-context-connections.ts +1 -1
  24. package/src/client/CreativeContextPanel.tsx +1 -1
  25. package/src/connectors/index.ts +9 -0
  26. package/src/connectors/rendered-design.spec.ts +171 -0
  27. package/src/connectors/rendered-design.ts +498 -0
  28. package/src/connectors/rendered-page.spec.ts +94 -0
  29. package/src/connectors/rendered-page.ts +494 -174
  30. package/src/connectors/website.spec.ts +27 -71
  31. package/src/jobs/server-worker.spec.ts +32 -10
  32. package/src/jobs/server-worker.ts +5 -2
  33. package/src/server/index.spec.ts +15 -0
  34. package/src/server/index.ts +13 -1
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { existsSync } from "node:fs";
2
2
 
3
3
  import {
4
4
  isBlockedExtensionUrlWithDns,
@@ -29,6 +29,10 @@ const MAX_TYPOGRAPHY = 100;
29
29
  const MAX_SPACING = 100;
30
30
  const MAX_RADII = 64;
31
31
  const MAX_CSS_VARIABLES = 128;
32
+ const MAX_COMPONENT_STYLES = 24;
33
+ const FONT_READY_TIMEOUT_MS = 4_000;
34
+ const BROWSER_NAVIGATION_DISABLED_WARNING =
35
+ "Browser rendering is disabled until Chromium navigation has a connect-time SSRF guard.";
32
36
 
33
37
  export interface RenderedPageRequest {
34
38
  url: string;
@@ -88,6 +92,10 @@ interface PlaywrightPageLike {
88
92
  url: string,
89
93
  options: { timeout: number; waitUntil: string },
90
94
  ): Promise<unknown>;
95
+ waitForLoadState?(
96
+ state: "load" | "domcontentloaded" | "networkidle",
97
+ options?: { timeout: number },
98
+ ): Promise<void>;
91
99
  title(): Promise<string>;
92
100
  url(): string;
93
101
  locator(selector: string): { innerText(): Promise<string> };
@@ -99,26 +107,37 @@ interface PlaywrightPageLike {
99
107
  interface PlaywrightContextLike {
100
108
  pages(): PlaywrightPageLike[];
101
109
  newPage(): Promise<PlaywrightPageLike>;
110
+ close?(): Promise<void>;
102
111
  }
103
112
 
104
113
  interface PlaywrightBrowserLike {
105
114
  contexts(): PlaywrightContextLike[];
106
- newContext(): Promise<PlaywrightContextLike>;
115
+ newContext?(): Promise<PlaywrightContextLike>;
107
116
  close(): Promise<void>;
108
117
  }
109
118
 
110
119
  interface PlaywrightLike {
111
120
  chromium: {
112
121
  connectOverCDP(endpoint: string): Promise<PlaywrightBrowserLike>;
113
- launch(options: { headless: boolean }): Promise<PlaywrightBrowserLike>;
122
+ launch(options: {
123
+ headless: boolean;
124
+ args?: string[];
125
+ executablePath?: string;
126
+ }): Promise<PlaywrightBrowserLike>;
114
127
  };
115
128
  }
116
129
 
117
130
  export interface LayeredRenderedPageProviderOptions {
131
+ /**
132
+ * Retained for compatibility with the previous browser renderer. Arbitrary
133
+ * Chromium navigation is disabled until a connect-time SSRF guard exists.
134
+ */
118
135
  requestBuilderBrowserConnection?: (input: {
119
136
  sessionId: string;
120
137
  }) => Promise<Record<string, unknown>>;
138
+ /** @deprecated Browser navigation is disabled until it is connect-time guarded. */
121
139
  loadPlaywright?: () => Promise<PlaywrightLike | null>;
140
+ /** @deprecated Browser navigation is disabled until it is connect-time guarded. */
122
141
  requestAttachedBrowserConnection?: (input: {
123
142
  sessionId: string;
124
143
  url: string;
@@ -127,94 +146,28 @@ export interface LayeredRenderedPageProviderOptions {
127
146
  }
128
147
 
129
148
  export class LayeredRenderedPageProvider implements RenderedPageProvider {
130
- readonly #requestBuilderBrowserConnection: (input: {
131
- sessionId: string;
132
- }) => Promise<Record<string, unknown>>;
133
- readonly #loadPlaywright: () => Promise<PlaywrightLike | null>;
134
- readonly #requestAttachedBrowserConnection?: NonNullable<
135
- LayeredRenderedPageProviderOptions["requestAttachedBrowserConnection"]
136
- >;
137
149
  readonly #staticFetch: typeof ssrfSafeFetch;
138
150
 
139
151
  constructor(options: LayeredRenderedPageProviderOptions = {}) {
140
- this.#requestBuilderBrowserConnection =
141
- options.requestBuilderBrowserConnection ?? defaultBuilderBrowserRequest;
142
- this.#loadPlaywright = options.loadPlaywright ?? loadOptionalPlaywright;
143
- this.#requestAttachedBrowserConnection =
144
- options.requestAttachedBrowserConnection;
145
152
  this.#staticFetch = options.staticFetch ?? ssrfSafeFetch;
146
153
  }
147
154
 
148
155
  async render(request: RenderedPageRequest): Promise<RenderedPageResult> {
149
156
  await assertPublicBrowserUrl(request.url);
150
- const warnings: string[] = [];
151
- const playwright = await this.#loadPlaywright().catch((error) => {
152
- warnings.push(`Playwright unavailable: ${errorMessage(error)}`);
153
- return null;
154
- });
155
-
156
- if (request.preferHosted !== false && playwright) {
157
- try {
158
- const connection = await this.#requestBuilderBrowserConnection({
159
- sessionId: `creative-context-${randomUUID()}`,
160
- });
161
- const wsUrl = stringValue(connection.wsUrl);
162
- if (!wsUrl) throw new Error("Builder Browser did not return wsUrl.");
163
- return await renderWithPlaywright(
164
- playwright,
165
- request,
166
- warnings,
167
- "builder-browser",
168
- wsUrl,
169
- );
170
- } catch (error) {
171
- warnings.push(`Builder Browser unavailable: ${errorMessage(error)}`);
172
- }
173
- }
174
-
175
- if (playwright) {
176
- try {
177
- return await renderWithPlaywright(
178
- playwright,
179
- request,
180
- warnings,
181
- "local-playwright",
182
- );
183
- } catch (error) {
184
- warnings.push(`Local Playwright unavailable: ${errorMessage(error)}`);
185
- }
186
- }
187
-
188
- if (playwright && this.#requestAttachedBrowserConnection) {
189
- try {
190
- const connection = await this.#requestAttachedBrowserConnection({
191
- sessionId: `creative-context-attached-${randomUUID()}`,
192
- url: request.url,
193
- });
194
- if (!connection.wsUrl?.trim()) {
195
- throw new Error("Approved attached browser did not return wsUrl.");
196
- }
197
- return await renderWithPlaywright(
198
- playwright,
199
- request,
200
- warnings,
201
- "attached-chrome",
202
- connection.wsUrl,
203
- );
204
- } catch (error) {
205
- warnings.push(`Attached Chrome unavailable: ${errorMessage(error)}`);
206
- }
207
- } else {
208
- warnings.push(
209
- "Attached Chrome unavailable: no approved browser connection adapter is configured.",
210
- );
211
- }
212
-
213
- return renderStatic(request, warnings, this.#staticFetch);
157
+ return renderStatic(
158
+ request,
159
+ [BROWSER_NAVIGATION_DISABLED_WARNING],
160
+ this.#staticFetch,
161
+ );
214
162
  }
215
163
  }
216
164
 
217
- async function renderWithPlaywright(
165
+ /**
166
+ * Internal browser renderer retained for a future connect-time-guarded
167
+ * adapter. `LayeredRenderedPageProvider` must not call this for arbitrary
168
+ * caller URLs until Chromium's socket connection is guarded as well.
169
+ */
170
+ export async function renderWithPlaywright(
218
171
  playwright: PlaywrightLike,
219
172
  request: RenderedPageRequest,
220
173
  warnings: string[],
@@ -223,9 +176,21 @@ async function renderWithPlaywright(
223
176
  ): Promise<RenderedPageResult> {
224
177
  const browser = wsUrl
225
178
  ? await playwright.chromium.connectOverCDP(wsUrl)
226
- : await playwright.chromium.launch({ headless: true });
179
+ : await launchChromium(playwright.chromium);
180
+ let isolatedContext: PlaywrightContextLike | undefined;
227
181
  try {
228
- const context = browser.contexts()[0] ?? (await browser.newContext());
182
+ // A hosted CDP browser can already contain the user's other tabs. Prefer a
183
+ // fresh context so extraction never reads ambient browser state, cookies,
184
+ // or a page left behind by another workflow. Older browser adapters may not
185
+ // expose newContext, so retain the existing-context fallback.
186
+ try {
187
+ isolatedContext = await browser.newContext?.();
188
+ } catch {
189
+ isolatedContext = undefined;
190
+ }
191
+ const context = isolatedContext ?? browser.contexts()[0];
192
+ if (!context)
193
+ throw new Error("Browser did not provide an isolated context.");
229
194
  const page = context.pages()[0] ?? (await context.newPage());
230
195
  await installNavigationGuard(page);
231
196
  await page.setViewportSize({ width: 1440, height: 900 });
@@ -233,27 +198,77 @@ async function renderWithPlaywright(
233
198
  timeout: boundedTimeout(request.timeoutMs),
234
199
  waitUntil: request.waitUntil ?? "domcontentloaded",
235
200
  });
201
+ await page
202
+ .waitForLoadState?.("load", {
203
+ timeout: Math.min(8_000, boundedTimeout(request.timeoutMs)),
204
+ })
205
+ .catch((error) => {
206
+ warnings.push(
207
+ `Browser load stabilization unavailable: ${errorMessage(error)}`,
208
+ );
209
+ });
210
+ // React hydration, CSS-in-JS insertion, and web fonts commonly finish just
211
+ // after `load`. Give those layers a bounded chance to settle, then capture
212
+ // the computed cascade rather than the server HTML.
213
+ await page
214
+ .waitForLoadState?.("networkidle", { timeout: 4_000 })
215
+ .catch((error) => {
216
+ warnings.push(
217
+ `Browser network-idle stabilization unavailable: ${errorMessage(error)}`,
218
+ );
219
+ });
220
+ await waitForFontReadiness(
221
+ page,
222
+ Math.min(FONT_READY_TIMEOUT_MS, boundedTimeout(request.timeoutMs)),
223
+ ).catch((error) => {
224
+ warnings.push(
225
+ `Browser font readiness unavailable: ${errorMessage(error)}`,
226
+ );
227
+ });
228
+ await new Promise((resolve) => setTimeout(resolve, 150));
229
+ await page.evaluate(dismissConsentOverlays).catch(() => undefined);
236
230
  const finalUrl = page.url();
237
231
  await assertPublicBrowserUrl(finalUrl);
238
232
  const [title, text, desktopScreenshot, extraction] = await Promise.all([
239
- page.title().catch(() => ""),
233
+ page.title().catch((error) => {
234
+ warnings.push(
235
+ `Browser title extraction unavailable: ${errorMessage(error)}`,
236
+ );
237
+ return "";
238
+ }),
240
239
  page
241
240
  .locator("body")
242
241
  .innerText()
243
- .catch(() => ""),
242
+ .catch((error) => {
243
+ warnings.push(
244
+ `Browser text extraction unavailable: ${errorMessage(error)}`,
245
+ );
246
+ return "";
247
+ }),
244
248
  page
245
249
  .screenshot({ type: "png", fullPage: false })
246
250
  .then(boundedScreenshot)
247
- .catch(() => undefined),
248
- page
249
- .evaluate(captureRenderedWebsiteContext)
250
- .catch(() => emptyExtraction()),
251
+ .catch((error) => {
252
+ warnings.push(
253
+ `Desktop screenshot unavailable: ${errorMessage(error)}`,
254
+ );
255
+ return undefined;
256
+ }),
257
+ page.evaluate(captureRenderedWebsiteContext).catch((error) => {
258
+ warnings.push(
259
+ `Browser style extraction unavailable: ${errorMessage(error)}`,
260
+ );
261
+ return emptyExtraction();
262
+ }),
251
263
  ]);
252
264
  await page.setViewportSize({ width: 390, height: 844 });
253
265
  const mobileScreenshot = await page
254
266
  .screenshot({ type: "png", fullPage: false })
255
267
  .then(boundedScreenshot)
256
- .catch(() => undefined);
268
+ .catch((error) => {
269
+ warnings.push(`Mobile screenshot unavailable: ${errorMessage(error)}`);
270
+ return undefined;
271
+ });
257
272
  const unboundedText = normalizeWhitespace(text);
258
273
  const textTruncated = unboundedText.length > MAX_RENDERED_TEXT_CHARS;
259
274
  const normalizedText = unboundedText.slice(0, MAX_RENDERED_TEXT_CHARS);
@@ -317,10 +332,36 @@ async function renderWithPlaywright(
317
332
  },
318
333
  };
319
334
  } finally {
335
+ await isolatedContext?.close?.().catch((error) => {
336
+ warnings.push(
337
+ `Browser context cleanup unavailable: ${errorMessage(error)}`,
338
+ );
339
+ });
320
340
  await browser.close().catch(() => undefined);
321
341
  }
322
342
  }
323
343
 
344
+ async function waitForFontReadiness(
345
+ page: PlaywrightPageLike,
346
+ timeoutMs: number,
347
+ ): Promise<void> {
348
+ let timeout: ReturnType<typeof setTimeout> | undefined;
349
+ try {
350
+ await Promise.race([
351
+ page.evaluate(async () => {
352
+ if (document.fonts?.ready) await document.fonts.ready;
353
+ }),
354
+ new Promise<never>((_, reject) => {
355
+ timeout = setTimeout(() => {
356
+ reject(new Error(`font readiness timed out after ${timeoutMs}ms`));
357
+ }, timeoutMs);
358
+ }),
359
+ ]);
360
+ } finally {
361
+ if (timeout !== undefined) clearTimeout(timeout);
362
+ }
363
+ }
364
+
324
365
  async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
325
366
  await page.route("**/*", async (route) => {
326
367
  const request = route.request();
@@ -349,6 +390,74 @@ async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
349
390
  });
350
391
  }
351
392
 
393
+ const SYSTEM_CHROME_EXECUTABLES = [
394
+ "/usr/bin/google-chrome-stable",
395
+ "/usr/bin/google-chrome",
396
+ "/usr/bin/chromium",
397
+ "/usr/bin/chromium-browser",
398
+ ];
399
+
400
+ async function launchChromium(
401
+ chromium: PlaywrightLike["chromium"],
402
+ ): Promise<PlaywrightBrowserLike> {
403
+ const launchOptions = {
404
+ headless: true,
405
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
406
+ };
407
+ try {
408
+ return await chromium.launch(launchOptions);
409
+ } catch (error) {
410
+ if (!isMissingBrowserError(error)) throw error;
411
+ for (const executablePath of SYSTEM_CHROME_EXECUTABLES) {
412
+ if (!existsSync(executablePath)) continue;
413
+ try {
414
+ return await chromium.launch({ ...launchOptions, executablePath });
415
+ } catch {
416
+ continue;
417
+ }
418
+ }
419
+ throw error;
420
+ }
421
+ }
422
+
423
+ function isMissingBrowserError(error: unknown): boolean {
424
+ const message = error instanceof Error ? error.message : String(error);
425
+ return /Executable doesn't exist|playwright install|browser.*not found|chromium.*not found/i.test(
426
+ message,
427
+ );
428
+ }
429
+
430
+ /** Close common consent banners without accepting tracking or changing page data. */
431
+ function dismissConsentOverlays(): void {
432
+ const selectors = [
433
+ '[aria-label*="reject" i]',
434
+ '[aria-label*="decline" i]',
435
+ '[aria-label*="close" i]',
436
+ '[data-testid*="reject" i]',
437
+ '[data-testid*="decline" i]',
438
+ '[data-testid*="close" i]',
439
+ 'button[id*="reject" i]',
440
+ 'button[id*="decline" i]',
441
+ 'button[class*="reject" i]',
442
+ 'button[class*="decline" i]',
443
+ ];
444
+ for (const selector of selectors) {
445
+ const element = document.querySelector<HTMLElement>(selector);
446
+ if (!element) continue;
447
+ const rect = element.getBoundingClientRect();
448
+ const style = getComputedStyle(element);
449
+ if (
450
+ rect.width > 0 &&
451
+ rect.height > 0 &&
452
+ style.visibility !== "hidden" &&
453
+ style.display !== "none"
454
+ ) {
455
+ element.click();
456
+ return;
457
+ }
458
+ }
459
+ }
460
+
352
461
  async function renderStatic(
353
462
  request: RenderedPageRequest,
354
463
  warnings: string[],
@@ -423,31 +532,6 @@ async function assertPublicBrowserUrl(value: string): Promise<void> {
423
532
  }
424
533
  }
425
534
 
426
- async function defaultBuilderBrowserRequest(input: {
427
- sessionId: string;
428
- }): Promise<Record<string, unknown>> {
429
- const server = (await import("@agent-native/core/server")) as unknown as {
430
- requestBuilderBrowserConnection?: (value: {
431
- sessionId: string;
432
- }) => Promise<Record<string, unknown>>;
433
- };
434
- if (!server.requestBuilderBrowserConnection) {
435
- throw new Error(
436
- "@agent-native/core/server does not export requestBuilderBrowserConnection.",
437
- );
438
- }
439
- return server.requestBuilderBrowserConnection(input);
440
- }
441
-
442
- async function loadOptionalPlaywright(): Promise<PlaywrightLike | null> {
443
- const specifier = "playwright";
444
- try {
445
- return (await import(specifier)) as PlaywrightLike;
446
- } catch {
447
- return null;
448
- }
449
- }
450
-
451
535
  function captureRenderedWebsiteContext(): WebsiteExtraction {
452
536
  const MAX_ASSETS = 500;
453
537
  const MAX_LINKS = 500;
@@ -455,8 +539,11 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
455
539
  const MAX_TYPE_STYLES = 100;
456
540
  const MAX_SPACING_VALUES = 100;
457
541
  const MAX_RADIUS_VALUES = 64;
542
+ const MAX_SHADOWS = 32;
543
+ const MAX_BACKGROUNDS = 32;
458
544
  const MAX_VARIABLES = 128;
459
545
  const MAX_TEXT = 2_000_000;
546
+ const MAX_COMPONENT_STYLES = 24;
460
547
  const assets = new Map<string, WebsiteExtraction["assets"][number]>();
461
548
  const links = new Set<string>();
462
549
  const colors: string[] = [];
@@ -466,7 +553,161 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
466
553
  >();
467
554
  const spacing = new Set<string>();
468
555
  const radii = new Set<string>();
556
+ const shadows = new Set<string>();
557
+ const backgrounds = new Set<string>();
558
+ const components: NonNullable<
559
+ WebsiteExtraction["designTokens"]["components"]
560
+ > = [];
469
561
  const cssVariables: Record<string, string> = {};
562
+ const semanticColors: NonNullable<
563
+ WebsiteExtraction["designTokens"]["semanticColors"]
564
+ > = {};
565
+
566
+ const isOpaque = (value: string): boolean => {
567
+ const normalized = value.trim().toLowerCase();
568
+ if (!normalized || normalized === "transparent") return false;
569
+ const functionBody = normalized.match(/^[a-z-]+\((.*)\)$/)?.[1];
570
+ if (!functionBody) return true;
571
+ const alphaValue = functionBody.includes("/")
572
+ ? functionBody.split("/").at(-1)?.trim()
573
+ : functionBody.split(",").length === 4
574
+ ? functionBody.split(",").at(-1)?.trim()
575
+ : undefined;
576
+ if (!alphaValue) return true;
577
+ const alpha = alphaValue.endsWith("%")
578
+ ? Number.parseFloat(alphaValue) / 100
579
+ : Number.parseFloat(alphaValue);
580
+ return Number.isNaN(alpha) || alpha > 0.02;
581
+ };
582
+
583
+ const addColor = (value: string): void => {
584
+ if (colors.length >= MAX_COLOR_VALUES || !isOpaque(value)) return;
585
+ const normalized = value.trim();
586
+ if (!colors.includes(normalized)) colors.push(normalized);
587
+ };
588
+
589
+ const visible = (element: Element): boolean => {
590
+ const style = getComputedStyle(element);
591
+ const rect = element.getBoundingClientRect();
592
+ return (
593
+ rect.width > 0 &&
594
+ rect.height > 0 &&
595
+ style.display !== "none" &&
596
+ style.visibility !== "hidden" &&
597
+ Number(style.opacity || 1) > 0.02
598
+ );
599
+ };
600
+
601
+ const firstVisible = (selector: string): Element | undefined =>
602
+ Array.from(document.querySelectorAll(selector)).find(visible);
603
+
604
+ const opaqueValue = (value: string): string | undefined =>
605
+ isOpaque(value) ? value.trim() : undefined;
606
+
607
+ const recordComputedStyle = (
608
+ element: Element,
609
+ role?: NonNullable<
610
+ WebsiteExtraction["designTokens"]["components"]
611
+ >[number]["role"],
612
+ ) => {
613
+ const style = getComputedStyle(element);
614
+ const values = [
615
+ style.color,
616
+ style.backgroundColor,
617
+ style.borderTopColor,
618
+ style.borderRightColor,
619
+ style.borderBottomColor,
620
+ style.borderLeftColor,
621
+ ];
622
+ values.forEach(addColor);
623
+ if (style.boxShadow && style.boxShadow !== "none") {
624
+ if (shadows.size < MAX_SHADOWS) shadows.add(style.boxShadow);
625
+ }
626
+ if (style.backgroundImage && style.backgroundImage !== "none") {
627
+ if (backgrounds.size < MAX_BACKGROUNDS) {
628
+ backgrounds.add(style.backgroundImage);
629
+ }
630
+ }
631
+ for (const value of [
632
+ style.marginTop,
633
+ style.marginRight,
634
+ style.marginBottom,
635
+ style.marginLeft,
636
+ style.paddingTop,
637
+ style.paddingRight,
638
+ style.paddingBottom,
639
+ style.paddingLeft,
640
+ style.gap,
641
+ style.rowGap,
642
+ style.columnGap,
643
+ ]) {
644
+ if (
645
+ spacing.size < MAX_SPACING_VALUES &&
646
+ value &&
647
+ value !== "0px" &&
648
+ value !== "normal"
649
+ ) {
650
+ spacing.add(value);
651
+ }
652
+ }
653
+ for (const value of [
654
+ style.borderTopLeftRadius,
655
+ style.borderTopRightRadius,
656
+ style.borderBottomRightRadius,
657
+ style.borderBottomLeftRadius,
658
+ ]) {
659
+ if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
660
+ radii.add(value);
661
+ }
662
+ }
663
+ const type = {
664
+ family: style.fontFamily,
665
+ size: style.fontSize,
666
+ weight: style.fontWeight,
667
+ lineHeight: style.lineHeight,
668
+ letterSpacing: style.letterSpacing,
669
+ };
670
+ if (typography.size < MAX_TYPE_STYLES && type.family) {
671
+ typography.set(JSON.stringify(type), type);
672
+ }
673
+ if (!role || components.length >= MAX_COMPONENT_STYLES) return style;
674
+ components.push({
675
+ role,
676
+ fontFamily: style.fontFamily,
677
+ fontSize: style.fontSize,
678
+ fontWeight: style.fontWeight,
679
+ lineHeight: style.lineHeight,
680
+ letterSpacing: style.letterSpacing,
681
+ color: opaqueValue(style.color),
682
+ backgroundColor: opaqueValue(style.backgroundColor),
683
+ backgroundImage:
684
+ style.backgroundImage !== "none" ? style.backgroundImage : undefined,
685
+ border:
686
+ style.borderStyle !== "none" && style.borderWidth !== "0px"
687
+ ? style.border
688
+ : undefined,
689
+ borderRadius:
690
+ style.borderTopLeftRadius !== "0px" ? style.borderRadius : undefined,
691
+ boxShadow: style.boxShadow !== "none" ? style.boxShadow : undefined,
692
+ padding: style.padding !== "0px" ? style.padding : undefined,
693
+ gap: style.gap !== "normal" ? style.gap : undefined,
694
+ textTransform:
695
+ style.textTransform !== "none" ? style.textTransform : undefined,
696
+ });
697
+ return style;
698
+ };
699
+
700
+ const styleFor = (
701
+ selector: string,
702
+ role?: NonNullable<
703
+ WebsiteExtraction["designTokens"]["components"]
704
+ >[number]["role"],
705
+ ): CSSStyleDeclaration | undefined => {
706
+ const element = firstVisible(selector);
707
+ if (!element) return undefined;
708
+ return recordComputedStyle(element, role);
709
+ };
710
+
470
711
  const addAsset = (
471
712
  raw: string | null | undefined,
472
713
  kind: WebsiteExtraction["assets"][number]["kind"],
@@ -548,64 +789,99 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
548
789
  }
549
790
  }
550
791
  }
551
- const elements = Array.from(document.querySelectorAll("body *")).slice(
552
- 0,
553
- 500,
792
+
793
+ const bodyStyle = document.body
794
+ ? recordComputedStyle(document.body)
795
+ : undefined;
796
+ const rootBackground = opaqueValue(rootStyle.backgroundColor);
797
+ const bodyBackground = bodyStyle
798
+ ? opaqueValue(bodyStyle.backgroundColor)
799
+ : undefined;
800
+ const headingStyle = styleFor("h1, h2, h3", "heading");
801
+ const textStyle = styleFor("p, li, label, body", "body");
802
+ const buttonStyle = styleFor(
803
+ 'button, [role="button"], input[type="submit"], a[class*="button" i], a[class*="cta" i]',
804
+ "button",
805
+ );
806
+ const linkStyle = styleFor("a[href]", "link");
807
+ const cardStyle = styleFor(
808
+ 'article, [class*="card" i], [class*="panel" i], [class*="surface" i], section',
809
+ "card",
810
+ );
811
+ styleFor("input, textarea, select", "input");
812
+ styleFor("nav, header", "nav");
813
+ styleFor('main, [class*="hero" i]', "hero");
814
+
815
+ const setSemantic = (
816
+ role: keyof NonNullable<
817
+ WebsiteExtraction["designTokens"]["semanticColors"]
818
+ >,
819
+ value: string | undefined,
820
+ ): void => {
821
+ if (value) semanticColors[role] = value;
822
+ };
823
+ setSemantic("background", bodyBackground ?? rootBackground);
824
+ setSemantic(
825
+ "surface",
826
+ cardStyle ? opaqueValue(cardStyle.backgroundColor) : undefined,
827
+ );
828
+ setSemantic("text", textStyle ? opaqueValue(textStyle.color) : undefined);
829
+ const mutedElement = firstVisible("small, figcaption, [class*='muted' i]");
830
+ setSemantic(
831
+ "textMuted",
832
+ mutedElement
833
+ ? opaqueValue(getComputedStyle(mutedElement).color)
834
+ : undefined,
835
+ );
836
+ setSemantic(
837
+ "accent",
838
+ linkStyle
839
+ ? opaqueValue(linkStyle.color)
840
+ : buttonStyle
841
+ ? opaqueValue(buttonStyle.backgroundColor)
842
+ : undefined,
554
843
  );
844
+ setSemantic(
845
+ "primary",
846
+ buttonStyle
847
+ ? (opaqueValue(buttonStyle.backgroundColor) ??
848
+ opaqueValue(buttonStyle.color))
849
+ : colors[0],
850
+ );
851
+ setSemantic(
852
+ "secondary",
853
+ semanticColors.surface ?? colors[1] ?? semanticColors.background,
854
+ );
855
+
856
+ const layoutElement = firstVisible("main, [role='main'], body > div");
857
+ const layoutStyle = layoutElement
858
+ ? getComputedStyle(layoutElement)
859
+ : undefined;
860
+ const layoutRect = layoutElement?.getBoundingClientRect();
861
+ const layout = {
862
+ contentWidth:
863
+ layoutStyle?.maxWidth && layoutStyle.maxWidth !== "none"
864
+ ? layoutStyle.maxWidth
865
+ : layoutRect && layoutRect.width > 0
866
+ ? `${Math.round(layoutRect.width)}px`
867
+ : undefined,
868
+ pagePadding:
869
+ bodyStyle?.padding && bodyStyle.padding !== "0px"
870
+ ? bodyStyle.padding
871
+ : undefined,
872
+ sectionGap:
873
+ firstVisible("section, article") &&
874
+ getComputedStyle(firstVisible("section, article")!).gap !== "normal" &&
875
+ getComputedStyle(firstVisible("section, article")!).gap !== "0px"
876
+ ? getComputedStyle(firstVisible("section, article")!).gap
877
+ : undefined,
878
+ };
879
+
880
+ const elements = Array.from(document.querySelectorAll("body *"))
881
+ .filter(visible)
882
+ .slice(0, 700);
555
883
  for (const element of elements) {
556
- const style = getComputedStyle(element);
557
- if (colors.length < MAX_COLOR_VALUES) {
558
- colors.push(
559
- style.color,
560
- style.backgroundColor,
561
- style.borderTopColor,
562
- style.borderRightColor,
563
- style.borderBottomColor,
564
- style.borderLeftColor,
565
- );
566
- }
567
- const type = {
568
- family: style.fontFamily,
569
- size: style.fontSize,
570
- weight: style.fontWeight,
571
- lineHeight: style.lineHeight,
572
- letterSpacing: style.letterSpacing,
573
- };
574
- if (typography.size < MAX_TYPE_STYLES) {
575
- typography.set(JSON.stringify(type), type);
576
- }
577
- for (const value of [
578
- style.marginTop,
579
- style.marginRight,
580
- style.marginBottom,
581
- style.marginLeft,
582
- style.paddingTop,
583
- style.paddingRight,
584
- style.paddingBottom,
585
- style.paddingLeft,
586
- style.gap,
587
- style.rowGap,
588
- style.columnGap,
589
- ]) {
590
- if (
591
- spacing.size < MAX_SPACING_VALUES &&
592
- value &&
593
- value !== "0px" &&
594
- value !== "normal"
595
- ) {
596
- spacing.add(value);
597
- }
598
- }
599
- for (const value of [
600
- style.borderTopLeftRadius,
601
- style.borderTopRightRadius,
602
- style.borderBottomRightRadius,
603
- style.borderBottomLeftRadius,
604
- ]) {
605
- if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
606
- radii.add(value);
607
- }
608
- }
884
+ recordComputedStyle(element);
609
885
  }
610
886
  return {
611
887
  title: document.title,
@@ -618,6 +894,11 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
618
894
  spacing: [...spacing],
619
895
  radii: [...radii],
620
896
  cssVariables,
897
+ semanticColors,
898
+ shadows: [...shadows],
899
+ backgrounds: [...backgrounds],
900
+ components,
901
+ layout,
621
902
  },
622
903
  };
623
904
  }
@@ -625,6 +906,16 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
625
906
  export function boundWebsiteExtraction(
626
907
  extraction: WebsiteExtraction,
627
908
  ): WebsiteExtraction {
909
+ const boundedComponents = extraction.designTokens.components
910
+ ?.slice(0, MAX_COMPONENT_STYLES)
911
+ .map((component) =>
912
+ Object.fromEntries(
913
+ Object.entries(component).map(([key, value]) => [
914
+ key,
915
+ typeof value === "string" ? value.slice(0, 500) : value,
916
+ ]),
917
+ ),
918
+ ) as WebsiteExtraction["designTokens"]["components"];
628
919
  return {
629
920
  title: normalizeWhitespace(extraction.title).slice(0, 500),
630
921
  text: normalizeWhitespace(extraction.text).slice(
@@ -648,6 +939,39 @@ export function boundWebsiteExtraction(
648
939
  .slice(0, MAX_CSS_VARIABLES)
649
940
  .map(([name, value]) => [name.slice(0, 500), value.slice(0, 4_096)]),
650
941
  ),
942
+ ...(extraction.designTokens.semanticColors
943
+ ? {
944
+ semanticColors: Object.fromEntries(
945
+ Object.entries(extraction.designTokens.semanticColors)
946
+ .filter(([, value]) => typeof value === "string" && value)
947
+ .map(([name, value]) => [name, value.slice(0, 200)]),
948
+ ),
949
+ }
950
+ : {}),
951
+ ...(extraction.designTokens.shadows
952
+ ? {
953
+ shadows: extraction.designTokens.shadows
954
+ .slice(0, 32)
955
+ .map((value) => value.slice(0, 500)),
956
+ }
957
+ : {}),
958
+ ...(extraction.designTokens.backgrounds
959
+ ? {
960
+ backgrounds: extraction.designTokens.backgrounds
961
+ .slice(0, 32)
962
+ .map((value) => value.slice(0, 500)),
963
+ }
964
+ : {}),
965
+ ...(boundedComponents ? { components: boundedComponents } : {}),
966
+ ...(extraction.designTokens.layout
967
+ ? {
968
+ layout: Object.fromEntries(
969
+ Object.entries(extraction.designTokens.layout)
970
+ .filter(([, value]) => typeof value === "string" && value)
971
+ .map(([name, value]) => [name, value.slice(0, 200)]),
972
+ ),
973
+ }
974
+ : {}),
651
975
  },
652
976
  };
653
977
  }
@@ -696,10 +1020,6 @@ function boundedScreenshot(value: Uint8Array): Uint8Array | undefined {
696
1020
  return value.byteLength <= MAX_SCREENSHOT_BYTES ? value : undefined;
697
1021
  }
698
1022
 
699
- function stringValue(value: unknown): string | undefined {
700
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
701
- }
702
-
703
1023
  function errorMessage(error: unknown): string {
704
1024
  return error instanceof Error ? error.message : String(error);
705
1025
  }