@agent-native/creative-context 0.5.12 → 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.
@@ -0,0 +1,498 @@
1
+ import type {
2
+ BrandKitData,
3
+ BrandKitToken,
4
+ BrandKitTokenType,
5
+ } from "@agent-native/core/brand-kit";
6
+ import {
7
+ classifyBrandKitToken,
8
+ friendlyTokenName,
9
+ normalizeBrandWebsiteUrl,
10
+ } from "@agent-native/core/brand-kit";
11
+ import type {
12
+ WebsiteDesignTokens,
13
+ WebsiteExtraction,
14
+ } from "@agent-native/core/ingestion";
15
+
16
+ import {
17
+ LayeredRenderedPageProvider,
18
+ type RenderedPageProvider,
19
+ type RenderedPageRequest,
20
+ type RenderedPageResult,
21
+ } from "./rendered-page.js";
22
+
23
+ const MAX_DESIGN_MD_CHARS = 20_000;
24
+ const MAX_TOKENS = 500;
25
+ const FALLBACK_COLORS = {
26
+ // guard:allow-raw-color - fallback design-token data, not rendered UI color
27
+ primary: "#111827",
28
+ // guard:allow-raw-color - fallback design-token data, not rendered UI color
29
+ secondary: "#374151",
30
+ // guard:allow-raw-color - fallback design-token data, not rendered UI color
31
+ accent: "#2563EB",
32
+ // guard:allow-raw-color - fallback design-token data, not rendered UI color
33
+ background: "#FFFFFF",
34
+ // guard:allow-raw-color - fallback design-token data, not rendered UI color
35
+ surface: "#F9FAFB",
36
+ // guard:allow-raw-color - fallback design-token data, not rendered UI color
37
+ text: "#111827",
38
+ // guard:allow-raw-color - fallback design-token data, not rendered UI color
39
+ textMuted: "#6B7280",
40
+ } as const;
41
+
42
+ export type RenderedDesignExtractionStatus = "complete" | "partial" | "failed";
43
+
44
+ export interface RenderedDesignExtraction {
45
+ status: RenderedDesignExtractionStatus;
46
+ url: string;
47
+ finalUrl?: string;
48
+ title?: string;
49
+ rendered: boolean;
50
+ method?: RenderedPageResult["method"];
51
+ confidence?: number;
52
+ designTokens?: WebsiteDesignTokens;
53
+ designMd?: string;
54
+ brandKit?: BrandKitData;
55
+ /** Back-compat projections for callers that consumed the old URL action. */
56
+ pageTitle?: string;
57
+ cssCustomProperties?: Record<string, string>;
58
+ colors?: string[];
59
+ fonts?: string[];
60
+ fontFaces?: Array<{ family: string; weight?: string }>;
61
+ googleFonts?: string[];
62
+ stylesheetUrls?: string[];
63
+ assets?: RenderedPageResult["extraction"]["assets"];
64
+ screenshotEvidence?: Array<{
65
+ viewport: "desktop" | "mobile";
66
+ width: number;
67
+ height: number;
68
+ bytes: number;
69
+ }>;
70
+ warnings: string[];
71
+ diagnostics: string[];
72
+ error?: string;
73
+ }
74
+
75
+ export interface ExtractRenderedDesignOptions extends Pick<
76
+ RenderedPageRequest,
77
+ "timeoutMs" | "preferHosted"
78
+ > {
79
+ provider?: RenderedPageProvider;
80
+ }
81
+
82
+ /**
83
+ * Extract a bounded visual language from a public website. Browser rendering
84
+ * is deliberately delegated to the layered provider so this works with
85
+ * Builder Browser, local Playwright, an approved attached browser, or the
86
+ * existing SSRF-safe static fallback without changing app code.
87
+ */
88
+ export async function extractRenderedDesignSystemFromUrl(
89
+ websiteUrl: string,
90
+ options: ExtractRenderedDesignOptions = {},
91
+ ): Promise<RenderedDesignExtraction> {
92
+ let url: string;
93
+ try {
94
+ url = normalizeBrandWebsiteUrl(websiteUrl);
95
+ } catch (error) {
96
+ return failedExtraction(websiteUrl, error);
97
+ }
98
+
99
+ const provider = options.provider ?? new LayeredRenderedPageProvider();
100
+ let page: RenderedPageResult;
101
+ try {
102
+ page = await provider.render({
103
+ url,
104
+ timeoutMs: options.timeoutMs,
105
+ preferHosted: options.preferHosted,
106
+ waitUntil: "load",
107
+ });
108
+ } catch (error) {
109
+ return failedExtraction(url, error);
110
+ }
111
+
112
+ const designTokens = page.extraction.designTokens;
113
+ const designMd = buildDesignMarkdown({
114
+ url,
115
+ finalUrl: page.finalUrl,
116
+ title: page.title,
117
+ rendered: page.rendered,
118
+ method: page.method,
119
+ confidence: page.confidence,
120
+ designTokens,
121
+ warnings: page.warnings,
122
+ });
123
+ const status: RenderedDesignExtractionStatus = page.rendered
124
+ ? page.warnings.length > 0 || page.screenshots.length < 2
125
+ ? "partial"
126
+ : "complete"
127
+ : "partial";
128
+
129
+ return {
130
+ status,
131
+ url,
132
+ finalUrl: page.finalUrl,
133
+ title: page.title,
134
+ rendered: page.rendered,
135
+ method: page.method,
136
+ confidence: page.confidence,
137
+ designTokens,
138
+ designMd,
139
+ brandKit: brandKitDataFromExtraction({
140
+ url,
141
+ finalUrl: page.finalUrl,
142
+ title: page.title,
143
+ designTokens,
144
+ designMd,
145
+ assets: page.extraction.assets,
146
+ }),
147
+ pageTitle: page.title,
148
+ cssCustomProperties: designTokens.cssVariables,
149
+ colors: designTokens.colors,
150
+ fonts: uniqueStrings(
151
+ designTokens.typography.map((style) => firstFontFamily(style.family)),
152
+ ),
153
+ fontFaces: designTokens.typography.slice(0, 24).map((style) => ({
154
+ family: firstFontFamily(style.family),
155
+ weight: style.weight,
156
+ })),
157
+ googleFonts: uniqueStrings(
158
+ designTokens.typography.map((style) => firstFontFamily(style.family)),
159
+ ),
160
+ stylesheetUrls: page.extraction.assets
161
+ .filter((asset) => asset.kind === "stylesheet")
162
+ .map((asset) => asset.url)
163
+ .slice(0, 64),
164
+ assets: page.extraction.assets,
165
+ screenshotEvidence: page.screenshots.map((screenshot) => ({
166
+ viewport: screenshot.viewport,
167
+ width: screenshot.width,
168
+ height: screenshot.height,
169
+ bytes: screenshot.data.byteLength,
170
+ })),
171
+ warnings: page.warnings,
172
+ diagnostics: page.diagnostics,
173
+ };
174
+ }
175
+
176
+ /** Convert the shared browser result into the Assets style-brief vocabulary. */
177
+ export function styleBriefFromRenderedDesign(
178
+ extraction: RenderedDesignExtraction,
179
+ ): Record<string, unknown> {
180
+ const tokens = extraction.designTokens;
181
+ const typography = tokens?.typography ?? [];
182
+ const componentStyles = tokens?.components ?? [];
183
+ const semanticColors = tokens?.semanticColors ?? {};
184
+ const bodyTypography = componentStyles.find((style) => style.role === "body");
185
+ const headingTypography = componentStyles.find(
186
+ (style) => style.role === "heading",
187
+ );
188
+
189
+ return {
190
+ description: extraction.finalUrl
191
+ ? `Rendered visual language extracted from ${extraction.finalUrl}.`
192
+ : "Rendered visual language extracted from a website.",
193
+ palette: tokens?.colors?.slice(0, 12) ?? [],
194
+ fontFamilies: uniqueStrings(
195
+ typography.map((style) => firstFontFamily(style.family)),
196
+ ),
197
+ fontWeights: uniqueStrings(
198
+ typography
199
+ .map((style) => style.weight)
200
+ .concat(componentStyles.map((style) => style.fontWeight ?? "")),
201
+ ),
202
+ typographyPolicy: typographyPolicy(headingTypography, bodyTypography),
203
+ composition: tokens?.layout
204
+ ? `Content width ${tokens.layout.contentWidth ?? "fluid"}; page padding ${tokens.layout.pagePadding ?? "not observed"}; section gap ${tokens.layout.sectionGap ?? "not observed"}.`
205
+ : undefined,
206
+ sourceUrl: extraction.finalUrl ?? extraction.url,
207
+ rendered: extraction.rendered,
208
+ designMd: extraction.designMd,
209
+ semanticColors,
210
+ spacing: tokens?.spacing?.slice(0, 24),
211
+ radii: tokens?.radii?.slice(0, 16),
212
+ shadows: tokens?.shadows?.slice(0, 12),
213
+ backgrounds: tokens?.backgrounds?.slice(0, 12),
214
+ componentStyles: componentStyles.slice(0, 12),
215
+ cssVariables: tokens?.cssVariables,
216
+ warnings: extraction.warnings,
217
+ };
218
+ }
219
+
220
+ export function brandKitDataFromExtraction(input: {
221
+ url: string;
222
+ finalUrl?: string;
223
+ title?: string;
224
+ designTokens: WebsiteDesignTokens;
225
+ designMd: string;
226
+ assets?: RenderedPageResult["extraction"]["assets"];
227
+ }): BrandKitData {
228
+ const tokens = input.designTokens;
229
+ const semantic = tokens.semanticColors ?? {};
230
+ const colors = {
231
+ primary: semantic.primary ?? tokens.colors[0] ?? FALLBACK_COLORS.primary,
232
+ secondary:
233
+ semantic.secondary ?? tokens.colors[1] ?? FALLBACK_COLORS.secondary,
234
+ accent: semantic.accent ?? tokens.colors[0] ?? FALLBACK_COLORS.accent,
235
+ background:
236
+ semantic.background ?? tokens.colors[2] ?? FALLBACK_COLORS.background,
237
+ surface: semantic.surface ?? tokens.colors[3] ?? FALLBACK_COLORS.surface,
238
+ text: semantic.text ?? tokens.colors[4] ?? FALLBACK_COLORS.text,
239
+ textMuted:
240
+ semantic.textMuted ?? tokens.colors[5] ?? FALLBACK_COLORS.textMuted,
241
+ };
242
+ const heading = tokens.components?.find(
243
+ (component) => component.role === "heading",
244
+ );
245
+ const body = tokens.components?.find(
246
+ (component) => component.role === "body",
247
+ );
248
+ const typography = tokens.typography;
249
+ const headingTypography = typography[0];
250
+ const bodyTypography = typography[1] ?? typography[0];
251
+ const source = input.finalUrl ?? input.url;
252
+
253
+ return {
254
+ colors,
255
+ typography: {
256
+ headingFont:
257
+ firstFontFamily(heading?.fontFamily ?? headingTypography?.family) ||
258
+ "system-ui",
259
+ bodyFont:
260
+ firstFontFamily(body?.fontFamily ?? bodyTypography?.family) ||
261
+ "system-ui",
262
+ headingWeight: heading?.fontWeight ?? headingTypography?.weight ?? "700",
263
+ bodyWeight: body?.fontWeight ?? bodyTypography?.weight ?? "400",
264
+ headingSizes: {
265
+ h1: componentFontSize(tokens, "heading", "h1") ?? "48px",
266
+ h2: componentFontSize(tokens, "heading", "h2") ?? "36px",
267
+ h3: componentFontSize(tokens, "heading", "h3") ?? "24px",
268
+ },
269
+ },
270
+ spacing: {
271
+ pagePadding: tokens.layout?.pagePadding ?? tokens.spacing[0] ?? "24px",
272
+ elementGap: tokens.layout?.sectionGap ?? tokens.spacing[1] ?? "16px",
273
+ },
274
+ borders: {
275
+ radius: tokens.radii[0] ?? "8px",
276
+ accentWidth: "0px",
277
+ },
278
+ logos: (input.assets ?? [])
279
+ .filter((asset) => asset.role === "logo")
280
+ .slice(0, 12)
281
+ .map((asset, index) => ({
282
+ url: asset.url,
283
+ name: `Logo ${index + 1}`,
284
+ variant: "auto" as const,
285
+ })),
286
+ tokens: tokensToBrandKitTokens(tokens, source),
287
+ notes: [
288
+ `Source: ${source}`,
289
+ input.title ? `Page title: ${input.title}` : "",
290
+ "The values above were inferred from the live computed browser cascade.",
291
+ input.designMd,
292
+ ]
293
+ .filter(Boolean)
294
+ .join("\n\n"),
295
+ };
296
+ }
297
+
298
+ export function buildDesignMarkdown(input: {
299
+ url: string;
300
+ finalUrl: string;
301
+ title: string;
302
+ rendered: boolean;
303
+ method: RenderedPageResult["method"];
304
+ confidence: number;
305
+ designTokens: WebsiteDesignTokens;
306
+ warnings: string[];
307
+ }): string {
308
+ const tokens = input.designTokens;
309
+ const semantic = tokens.semanticColors ?? {};
310
+ const lines = [
311
+ `# ${safeHeading(input.title || new URL(input.finalUrl).hostname)} design system`,
312
+ "",
313
+ `Source: ${input.finalUrl}`,
314
+ `Extraction: ${input.rendered ? "real browser computed styles" : "SSRF-safe static HTML fallback"} (${input.method}; confidence ${Math.round(input.confidence * 100)}%)`,
315
+ "",
316
+ "## Colors",
317
+ ...roleLines(semantic),
318
+ ...(tokens.colors.length > 0
319
+ ? ["", `Observed palette: ${tokens.colors.slice(0, 16).join(", ")}`]
320
+ : ["", "No visible color samples were available."]),
321
+ "",
322
+ "## Typography",
323
+ ...tokens.typography
324
+ .slice(0, 12)
325
+ .map(
326
+ (style) =>
327
+ `- ${style.family}; ${style.size}; weight ${style.weight}; line-height ${style.lineHeight}; tracking ${style.letterSpacing}`,
328
+ ),
329
+ ...(tokens.typography.length === 0
330
+ ? ["- No visible typography samples were available."]
331
+ : []),
332
+ "",
333
+ "## Spacing, shape, and depth",
334
+ `- Spacing: ${tokens.spacing.slice(0, 20).join(", ") || "not observed"}`,
335
+ `- Radii: ${tokens.radii.slice(0, 12).join(", ") || "not observed"}`,
336
+ `- Shadows: ${(tokens.shadows ?? []).slice(0, 8).join("; ") || "not observed"}`,
337
+ `- Background treatments: ${(tokens.backgrounds ?? []).slice(0, 8).join("; ") || "not observed"}`,
338
+ ...(tokens.layout
339
+ ? [
340
+ `- Layout: content width ${tokens.layout.contentWidth ?? "fluid"}; page padding ${tokens.layout.pagePadding ?? "not observed"}; section gap ${tokens.layout.sectionGap ?? "not observed"}`,
341
+ ]
342
+ : []),
343
+ "",
344
+ "## Component language",
345
+ ...(tokens.components ?? [])
346
+ .slice(0, 12)
347
+ .map((component) =>
348
+ [
349
+ `- ${component.role}:`,
350
+ component.fontFamily,
351
+ component.fontSize ? `size ${component.fontSize}` : undefined,
352
+ component.fontWeight ? `weight ${component.fontWeight}` : undefined,
353
+ component.color ? `color ${component.color}` : undefined,
354
+ component.backgroundColor
355
+ ? `background ${component.backgroundColor}`
356
+ : undefined,
357
+ component.backgroundImage
358
+ ? `background image ${component.backgroundImage}`
359
+ : undefined,
360
+ component.borderRadius
361
+ ? `radius ${component.borderRadius}`
362
+ : undefined,
363
+ component.boxShadow ? `shadow ${component.boxShadow}` : undefined,
364
+ component.padding ? `padding ${component.padding}` : undefined,
365
+ component.textTransform
366
+ ? `case ${component.textTransform}`
367
+ : undefined,
368
+ ]
369
+ .filter(Boolean)
370
+ .join("; "),
371
+ ),
372
+ ...((tokens.components ?? []).length === 0
373
+ ? ["- No representative visible component styles were available."]
374
+ : []),
375
+ "",
376
+ "## CSS variables",
377
+ ...Object.entries(tokens.cssVariables)
378
+ .slice(0, 64)
379
+ .map(([name, value]) => `- ${name}: ${value}`),
380
+ ...(Object.keys(tokens.cssVariables).length === 0
381
+ ? ["- No root CSS custom properties were available."]
382
+ : []),
383
+ ...(input.warnings.length > 0
384
+ ? [
385
+ "",
386
+ "## Extraction notes",
387
+ ...input.warnings.map((warning) => `- ${warning}`),
388
+ ]
389
+ : []),
390
+ ];
391
+ return lines.join("\n").slice(0, MAX_DESIGN_MD_CHARS);
392
+ }
393
+
394
+ function failedExtraction(
395
+ url: string,
396
+ error: unknown,
397
+ ): RenderedDesignExtraction {
398
+ const message = error instanceof Error ? error.message : String(error);
399
+ return {
400
+ status: "failed",
401
+ url,
402
+ rendered: false,
403
+ warnings: [],
404
+ diagnostics: [],
405
+ error: message,
406
+ };
407
+ }
408
+
409
+ function roleLines(
410
+ colors: NonNullable<WebsiteDesignTokens["semanticColors"]>,
411
+ ): string[] {
412
+ const roles = [
413
+ "primary",
414
+ "secondary",
415
+ "accent",
416
+ "background",
417
+ "surface",
418
+ "text",
419
+ "textMuted",
420
+ ] as const;
421
+ return roles.map((role) => `- ${role}: ${colors[role] ?? "not observed"}`);
422
+ }
423
+
424
+ function firstFontFamily(value: string | undefined): string {
425
+ return (
426
+ value
427
+ ?.split(",")[0]
428
+ ?.trim()
429
+ .replace(/^['"]|['"]$/g, "") ?? ""
430
+ );
431
+ }
432
+
433
+ function uniqueStrings(values: string[]): string[] {
434
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
435
+ }
436
+
437
+ function typographyPolicy(
438
+ heading: NonNullable<WebsiteDesignTokens["components"]>[number] | undefined,
439
+ body: NonNullable<WebsiteDesignTokens["components"]>[number] | undefined,
440
+ ): string | undefined {
441
+ if (!heading && !body) return undefined;
442
+ return [
443
+ heading
444
+ ? `Headings use ${firstFontFamily(heading.fontFamily)} at ${heading.fontWeight ?? "default"} weight.`
445
+ : "",
446
+ body
447
+ ? `Body copy uses ${firstFontFamily(body.fontFamily)} at ${body.fontWeight ?? "default"} weight.`
448
+ : "",
449
+ ]
450
+ .filter(Boolean)
451
+ .join(" ");
452
+ }
453
+
454
+ function componentFontSize(
455
+ tokens: WebsiteDesignTokens,
456
+ role: "heading",
457
+ level: "h1" | "h2" | "h3",
458
+ ): string | undefined {
459
+ const heading = tokens.components?.find(
460
+ (component) => component.role === role,
461
+ );
462
+ if (!heading?.fontSize) return undefined;
463
+ if (level === "h1") return heading.fontSize;
464
+ const numeric = Number.parseFloat(heading.fontSize);
465
+ if (!Number.isFinite(numeric)) return heading.fontSize;
466
+ return `${Math.max(level === "h2" ? 24 : 18, Math.round(numeric * (level === "h2" ? 0.75 : 0.58)))}px`;
467
+ }
468
+
469
+ function tokensToBrandKitTokens(
470
+ tokens: WebsiteDesignTokens,
471
+ source: string,
472
+ ): BrandKitToken[] {
473
+ const entries = Object.entries(tokens.cssVariables).slice(0, MAX_TOKENS);
474
+ const cssTokens = entries.map(([cssVar, value]) => ({
475
+ name: friendlyTokenName(cssVar),
476
+ cssVar,
477
+ value,
478
+ type: classifyBrandKitToken(cssVar, value),
479
+ source,
480
+ }));
481
+ const observedColors = tokens.colors.slice(0, 32).map((value, index) => ({
482
+ name: `Observed Color ${index + 1}`,
483
+ cssVar: `--observed-color-${index + 1}`,
484
+ value,
485
+ type: "color" as const satisfies BrandKitTokenType,
486
+ source,
487
+ }));
488
+ return [...cssTokens, ...observedColors].slice(0, MAX_TOKENS);
489
+ }
490
+
491
+ function safeHeading(value: string): string {
492
+ return (
493
+ value
494
+ .replace(/[\r\n#]/g, " ")
495
+ .trim()
496
+ .slice(0, 160) || "Website"
497
+ );
498
+ }
@@ -0,0 +1,94 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const isBlockedExtensionUrlWithDns = vi.hoisted(() => vi.fn(async () => false));
4
+
5
+ vi.mock("@agent-native/core/extensions/url-safety", () => ({
6
+ isBlockedExtensionUrlWithDns,
7
+ ssrfSafeFetch: vi.fn(),
8
+ }));
9
+
10
+ const { renderWithPlaywright } = await import("./rendered-page.js");
11
+
12
+ describe("renderWithPlaywright lifecycle", () => {
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ it("closes isolated contexts and reports bounded stabilization failures", async () => {
18
+ vi.useFakeTimers();
19
+ const events: string[] = [];
20
+ let evaluateCalls = 0;
21
+ const page = {
22
+ async route() {},
23
+ async goto() {},
24
+ async waitForLoadState(state: string) {
25
+ throw new Error(`${state} timed out`);
26
+ },
27
+ async title() {
28
+ return "Example";
29
+ },
30
+ url() {
31
+ return "https://example.com/";
32
+ },
33
+ locator() {
34
+ return { innerText: async () => "Example content" };
35
+ },
36
+ async setViewportSize() {},
37
+ async screenshot() {
38
+ return new Uint8Array([1]);
39
+ },
40
+ async evaluate() {
41
+ evaluateCalls += 1;
42
+ if (evaluateCalls === 1) return new Promise(() => {});
43
+ if (evaluateCalls === 2) return undefined;
44
+ throw new Error("computed styles unavailable");
45
+ },
46
+ };
47
+ const context = {
48
+ pages: () => [],
49
+ newPage: async () => page,
50
+ async close() {
51
+ events.push("context.close");
52
+ },
53
+ };
54
+ const browser = {
55
+ contexts: () => [],
56
+ async newContext() {
57
+ events.push("context.new");
58
+ return context;
59
+ },
60
+ async close() {
61
+ events.push("browser.close");
62
+ },
63
+ };
64
+ const renderPromise = renderWithPlaywright(
65
+ {
66
+ chromium: {
67
+ async launch() {
68
+ return browser;
69
+ },
70
+ async connectOverCDP() {
71
+ return browser;
72
+ },
73
+ },
74
+ } as never,
75
+ { url: "https://example.com/", timeoutMs: 1_000 },
76
+ [],
77
+ "local-playwright",
78
+ );
79
+
80
+ await vi.advanceTimersByTimeAsync(1_200);
81
+ const result = await renderPromise;
82
+
83
+ expect(events).toEqual(["context.new", "context.close", "browser.close"]);
84
+ expect(result.warnings).toEqual(
85
+ expect.arrayContaining([
86
+ "Browser load stabilization unavailable: load timed out",
87
+ "Browser network-idle stabilization unavailable: networkidle timed out",
88
+ "Browser font readiness unavailable: font readiness timed out after 1000ms",
89
+ "Browser style extraction unavailable: computed styles unavailable",
90
+ ]),
91
+ );
92
+ expect(result.diagnostics).toEqual(expect.arrayContaining(result.warnings));
93
+ });
94
+ });