rivet-design 0.9.2 → 0.9.4

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 (157) hide show
  1. package/dist/mcp/agent-variants/SessionStore.d.ts +78 -2
  2. package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
  3. package/dist/mcp/agent-variants/SessionStore.js +464 -62
  4. package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
  5. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +331 -9
  6. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
  7. package/dist/mcp/agent-variants/WorktreeOrchestrator.js +1985 -61
  8. package/dist/mcp/agent-variants/WorktreeOrchestrator.js.map +1 -1
  9. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts +65 -0
  10. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts.map +1 -0
  11. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js +162 -0
  12. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js.map +1 -0
  13. package/dist/mcp/agent-variants/contracts.d.ts +2508 -10
  14. package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
  15. package/dist/mcp/agent-variants/contracts.js +295 -5
  16. package/dist/mcp/agent-variants/contracts.js.map +1 -1
  17. package/dist/mcp/agent-variants/createProjectArtifacts.d.ts +78 -0
  18. package/dist/mcp/agent-variants/createProjectArtifacts.d.ts.map +1 -0
  19. package/dist/mcp/agent-variants/createProjectArtifacts.js +123 -0
  20. package/dist/mcp/agent-variants/createProjectArtifacts.js.map +1 -0
  21. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts +241 -0
  22. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -0
  23. package/dist/mcp/agent-variants/createZeroToOneTool.js +213 -0
  24. package/dist/mcp/agent-variants/createZeroToOneTool.js.map +1 -0
  25. package/dist/mcp/agent-variants/designContextStore.d.ts +160 -0
  26. package/dist/mcp/agent-variants/designContextStore.d.ts.map +1 -0
  27. package/dist/mcp/agent-variants/designContextStore.js +295 -0
  28. package/dist/mcp/agent-variants/designContextStore.js.map +1 -0
  29. package/dist/mcp/agent-variants/elementRefToTarget.d.ts +21 -0
  30. package/dist/mcp/agent-variants/elementRefToTarget.d.ts.map +1 -0
  31. package/dist/mcp/agent-variants/elementRefToTarget.js +47 -0
  32. package/dist/mcp/agent-variants/elementRefToTarget.js.map +1 -0
  33. package/dist/mcp/agent-variants/errors.d.ts +1 -1
  34. package/dist/mcp/agent-variants/errors.d.ts.map +1 -1
  35. package/dist/mcp/agent-variants/errors.js +7 -0
  36. package/dist/mcp/agent-variants/errors.js.map +1 -1
  37. package/dist/mcp/agent-variants/index.d.ts +4 -2
  38. package/dist/mcp/agent-variants/index.d.ts.map +1 -1
  39. package/dist/mcp/agent-variants/index.js +7 -1
  40. package/dist/mcp/agent-variants/index.js.map +1 -1
  41. package/dist/mcp/agent-variants/inspirationDesignContext.d.ts +440 -0
  42. package/dist/mcp/agent-variants/inspirationDesignContext.d.ts.map +1 -0
  43. package/dist/mcp/agent-variants/inspirationDesignContext.js +2467 -0
  44. package/dist/mcp/agent-variants/inspirationDesignContext.js.map +1 -0
  45. package/dist/mcp/agent-variants/pendingChangesAdapter.d.ts.map +1 -1
  46. package/dist/mcp/agent-variants/pendingChangesAdapter.js +21 -7
  47. package/dist/mcp/agent-variants/pendingChangesAdapter.js.map +1 -1
  48. package/dist/mcp/agent-variants/previewQa.d.ts +61 -0
  49. package/dist/mcp/agent-variants/previewQa.d.ts.map +1 -0
  50. package/dist/mcp/agent-variants/previewQa.js +374 -0
  51. package/dist/mcp/agent-variants/previewQa.js.map +1 -0
  52. package/dist/mcp/agent-variants/sourceContext.d.ts +8 -0
  53. package/dist/mcp/agent-variants/sourceContext.d.ts.map +1 -0
  54. package/dist/mcp/agent-variants/sourceContext.js +183 -0
  55. package/dist/mcp/agent-variants/sourceContext.js.map +1 -0
  56. package/dist/mcp/agent-variants/tools.d.ts +36 -0
  57. package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
  58. package/dist/mcp/agent-variants/tools.js +451 -19
  59. package/dist/mcp/agent-variants/tools.js.map +1 -1
  60. package/dist/mcp/changeBatchClassification.d.ts +30 -0
  61. package/dist/mcp/changeBatchClassification.d.ts.map +1 -0
  62. package/dist/mcp/changeBatchClassification.js +65 -0
  63. package/dist/mcp/changeBatchClassification.js.map +1 -0
  64. package/dist/mcp/server.d.ts.map +1 -1
  65. package/dist/mcp/server.js +258 -41
  66. package/dist/mcp/server.js.map +1 -1
  67. package/dist/prompts/agentModPrompts.js +4 -4
  68. package/dist/prompts/agentModPrompts.js.map +1 -1
  69. package/dist/proxy-middleware/proxy-config.d.ts.map +1 -1
  70. package/dist/proxy-middleware/proxy-config.js +1 -15
  71. package/dist/proxy-middleware/proxy-config.js.map +1 -1
  72. package/dist/routes/agentVariants.d.ts +3 -1
  73. package/dist/routes/agentVariants.d.ts.map +1 -1
  74. package/dist/routes/agentVariants.js +138 -13
  75. package/dist/routes/agentVariants.js.map +1 -1
  76. package/dist/routes/mcp.d.ts +7 -1
  77. package/dist/routes/mcp.d.ts.map +1 -1
  78. package/dist/routes/mcp.js +139 -16
  79. package/dist/routes/mcp.js.map +1 -1
  80. package/dist/server.d.ts.map +1 -1
  81. package/dist/server.js +23 -5
  82. package/dist/server.js.map +1 -1
  83. package/dist/services/ProjectDetectionService.d.ts.map +1 -1
  84. package/dist/services/ProjectDetectionService.js +9 -0
  85. package/dist/services/ProjectDetectionService.js.map +1 -1
  86. package/dist/services/SessionBridgeService.d.ts +22 -0
  87. package/dist/services/SessionBridgeService.d.ts.map +1 -1
  88. package/dist/services/SessionBridgeService.js +61 -0
  89. package/dist/services/SessionBridgeService.js.map +1 -1
  90. package/dist/services/TelemetryService.d.ts +121 -0
  91. package/dist/services/TelemetryService.d.ts.map +1 -1
  92. package/dist/services/TelemetryService.js +155 -0
  93. package/dist/services/TelemetryService.js.map +1 -1
  94. package/dist/services/WorktreeManager.d.ts +116 -6
  95. package/dist/services/WorktreeManager.d.ts.map +1 -1
  96. package/dist/services/WorktreeManager.js +394 -19
  97. package/dist/services/WorktreeManager.js.map +1 -1
  98. package/dist/services/agent/AgentModService.js +6 -6
  99. package/dist/services/agent/AgentModService.js.map +1 -1
  100. package/dist/services/templates/designCatalog.d.ts +27 -0
  101. package/dist/services/templates/designCatalog.d.ts.map +1 -0
  102. package/dist/services/templates/designCatalog.js +141 -0
  103. package/dist/services/templates/designCatalog.js.map +1 -0
  104. package/dist/services/templates/designmd/airbnb.md +545 -0
  105. package/dist/services/templates/designmd/airtable.md +554 -0
  106. package/dist/services/templates/designmd/apple.md +562 -0
  107. package/dist/services/templates/designmd/binance.md +634 -0
  108. package/dist/services/templates/designmd/bmw-m.md +503 -0
  109. package/dist/services/templates/designmd/bmw.md +544 -0
  110. package/dist/services/templates/designmd/bugatti.md +454 -0
  111. package/dist/services/templates/designmd/cal.md +542 -0
  112. package/dist/services/templates/designmd/claude.md +589 -0
  113. package/dist/services/templates/designmd/clay.md +541 -0
  114. package/dist/services/templates/designmd/cohere.md +451 -0
  115. package/dist/services/templates/designmd/cursor.md +537 -0
  116. package/dist/services/templates/designmd/expo.md +526 -0
  117. package/dist/services/templates/designmd/figma.md +578 -0
  118. package/dist/services/templates/designmd/framer.md +544 -0
  119. package/dist/services/templates/designmd/hp.md +670 -0
  120. package/dist/services/templates/designmd/linear.app.md +548 -0
  121. package/dist/services/templates/designmd/mintlify.md +852 -0
  122. package/dist/services/templates/designmd/miro.md +825 -0
  123. package/dist/services/templates/designmd/notion.md +821 -0
  124. package/dist/services/templates/designmd/raycast.md +669 -0
  125. package/dist/services/templates/designmd/resend.md +585 -0
  126. package/dist/services/templates/designmd/sentry.md +262 -0
  127. package/dist/services/templates/designmd/shopify.md +350 -0
  128. package/dist/services/templates/designmd/spotify.md +246 -0
  129. package/dist/services/templates/designmd/stripe.md +322 -0
  130. package/dist/services/templates/designmd/supabase.md +255 -0
  131. package/dist/services/templates/designmd/superhuman.md +252 -0
  132. package/dist/services/templates/designmd/uber.md +295 -0
  133. package/dist/services/templates/designmd/vercel.md +310 -0
  134. package/dist/services/templates/viteReactTs.d.ts +48 -0
  135. package/dist/services/templates/viteReactTs.d.ts.map +1 -0
  136. package/dist/services/templates/viteReactTs.js +274 -0
  137. package/dist/services/templates/viteReactTs.js.map +1 -0
  138. package/dist/types/change-request-types.d.ts +29 -3
  139. package/dist/types/change-request-types.d.ts.map +1 -1
  140. package/dist/utils/skills/claude-skill.d.ts +2 -2
  141. package/dist/utils/skills/claude-skill.d.ts.map +1 -1
  142. package/dist/utils/skills/claude-skill.js +19 -98
  143. package/dist/utils/skills/claude-skill.js.map +1 -1
  144. package/dist/utils/skills/cursor-rules.d.ts +2 -2
  145. package/dist/utils/skills/cursor-rules.d.ts.map +1 -1
  146. package/dist/utils/skills/cursor-rules.js +15 -80
  147. package/dist/utils/skills/cursor-rules.js.map +1 -1
  148. package/dist/utils/skills/shared-variants-protocol.d.ts +23 -0
  149. package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -0
  150. package/dist/utils/skills/shared-variants-protocol.js +130 -0
  151. package/dist/utils/skills/shared-variants-protocol.js.map +1 -0
  152. package/package.json +6 -6
  153. package/src/ui/dist/assets/main-CpX7fB64.js +382 -0
  154. package/src/ui/dist/assets/main-Qqe2_oMT.css +1 -0
  155. package/src/ui/dist/index.html +2 -2
  156. package/src/ui/dist/assets/main-AsPCtLsx.js +0 -382
  157. package/src/ui/dist/assets/main-BzmseUDd.css +0 -1
@@ -0,0 +1,2467 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.standaloneDebugExtract = exports.extractPromptUrls = exports.extractInspirationDesignContext = exports.buildInspirationDesignContextMarkdown = exports.extractDesignSignals = exports.fetchLinkedStylesheets = exports.defaultLinkedCssFetcher = exports.discoverLinkedStylesheetUrls = exports.defaultInspirationFetcher = exports.classifyInspirationLinks = exports.inferLinkIntent = exports.isResolvedHostProtected = exports.isLocalLiteralHostname = exports.validateInspirationUrl = void 0;
7
+ const dns_1 = __importDefault(require("dns"));
8
+ const logger_1 = require("../../utils/logger");
9
+ const sourceContext_1 = require("./sourceContext");
10
+ Object.defineProperty(exports, "extractPromptUrls", { enumerable: true, get: function () { return sourceContext_1.extractPromptUrls; } });
11
+ const designContextStore_1 = require("./designContextStore");
12
+ const log = (0, logger_1.createLogger)('InspirationDesignContext');
13
+ /**
14
+ * Constants. Kept above the function bodies so any tuning is in one place.
15
+ * Tight defaults so the 0 -> 1 flow does not stall on large or hostile pages.
16
+ */
17
+ const FETCH_TIMEOUT_MS = 10_000;
18
+ const MAX_BYTES = 1_500_000;
19
+ // Note: Node's `fetch` follows up to undici's default redirect cap when
20
+ // `redirect: 'follow'` is set; we additionally rely on FETCH_TIMEOUT_MS +
21
+ // MAX_BYTES to bound pathological chains rather than enforcing a custom
22
+ // cap here. If this moves to a lower-level HTTP client, reintroduce an
23
+ // explicit redirect limit.
24
+ const MAX_INSPIRATION_LINKS = 6;
25
+ const MAX_PALETTE_COLORS = 12;
26
+ const MAX_TYPOGRAPHY_FAMILIES = 8;
27
+ const MAX_LIST_ITEMS_PER_SECTION = 12;
28
+ const MAX_DESIGN_CONTEXT_LABEL_LENGTH = 60;
29
+ const DESIGN_CONTEXT_LABEL_DEFAULT = 'Extracted from inspiration link';
30
+ /**
31
+ * Caps for the new token-evidence fields. Bounded so a pathological
32
+ * stylesheet cannot blow up the persisted signal object or downstream
33
+ * markdown output.
34
+ */
35
+ const MAX_SPACING_VALUES = 16;
36
+ const MAX_ROUNDED_VALUES = 8;
37
+ const MAX_SHADOWS = 8;
38
+ const MAX_TRANSITIONS = 8;
39
+ const MAX_BREAKPOINTS = 6;
40
+ const MAX_COMPONENT_DETAILS = 8;
41
+ const MAX_GOOGLE_FONT_FAMILIES = 12;
42
+ const MAX_FONT_FACE_EVIDENCE = 8;
43
+ /**
44
+ * Bounded discovery + fetch limits for linked stylesheets. Reuses the
45
+ * same SSRF / hostname checks as the HTML fetcher but applies tighter
46
+ * per-fetch byte and time budgets so a single inspiration link cannot
47
+ * stall the flow on heavy CSS bundles.
48
+ */
49
+ const MAX_LINKED_STYLESHEETS = 4;
50
+ const LINKED_CSS_FETCH_TIMEOUT_MS = 6_000;
51
+ const LINKED_CSS_MAX_BYTES_PER_FILE = 500_000;
52
+ const LINKED_CSS_MAX_TOTAL_BYTES = 1_500_000;
53
+ /**
54
+ * Phrases in user prompts that indicate a URL is being shared as
55
+ * source/content reference rather than visual inspiration. When at least one
56
+ * of these patterns appears near a URL the link is classified as `reference`
57
+ * and excluded from inspiration extraction.
58
+ */
59
+ const REFERENCE_INTENT_PATTERNS = [
60
+ /\bsource(?:s|\smaterial)?\b/i,
61
+ /\bcontent\s+reference\b/i,
62
+ /\bcontent\s+from\b/i,
63
+ /\bcopy\s+(?:the|from)\b/i,
64
+ /\bdocs?\b/i,
65
+ /\barticle\b/i,
66
+ /\bblog\s+post\b/i,
67
+ /\bdataset\b/i,
68
+ /\breference\s+only\b/i,
69
+ ];
70
+ /**
71
+ * Phrases that indicate users want multiple URLs treated as alternatives,
72
+ * competitors, or contrasting references. When matched, multi-link extraction
73
+ * defaults to separate-per-slot design context instead of one merged set.
74
+ */
75
+ const SEPARATE_INTENT_PATTERNS = [
76
+ /\balternatives?\b/i,
77
+ /\boptions?\b/i,
78
+ /\bcompetitors?\b/i,
79
+ /\bcontrast(?:ing)?\b/i,
80
+ /\bcompare\b/i,
81
+ /\bvs\.?\b/i,
82
+ ];
83
+ const PRIVATE_IPV4_PATTERNS = [
84
+ /^10\./,
85
+ /^127\./,
86
+ /^192\.168\./,
87
+ /^169\.254\./,
88
+ /^172\.(1[6-9]|2\d|3[0-1])\./,
89
+ /^0\./,
90
+ // RFC6598 shared address space (CGNAT): 100.64.0.0/10 -> 100.64..100.127.
91
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
92
+ ];
93
+ // IPv6 private/reserved ranges.
94
+ // ::1 loopback
95
+ // fe80::/10 link-local (matches `fe80:` onwards)
96
+ // fc00::/7 unique-local (ULA) — first byte is `fc` OR `fd`, so the
97
+ // second hex pair after the prefix can be any value;
98
+ // previously `^fc00:` missed e.g. `fc12::1`.
99
+ // IPv4-mapped IPv6 (`::ffff:x.x.x.x`) is handled separately via
100
+ // `isIpv4MappedPrivateIpv6` below so the embedded v4 portion gets the
101
+ // full v4 private-range check.
102
+ const PRIVATE_IPV6_PATTERNS = [
103
+ /^::1$/i,
104
+ /^fe80:/i,
105
+ /^f[cd][0-9a-f]{2}:/i,
106
+ ];
107
+ /**
108
+ * Detect an IPv4-mapped IPv6 address (`::ffff:x.x.x.x`) whose embedded
109
+ * IPv4 portion falls in the v4 private/reserved ranges. Returns false
110
+ * for non-mapped addresses so the caller can fall back to the regular
111
+ * v6 patterns. Without this, a dual-stack DNS lookup that returns
112
+ * `::ffff:127.0.0.1` or `::ffff:169.254.169.254` would slip past the v6
113
+ * regex set and reach the underlying private host.
114
+ */
115
+ const isIpv4MappedPrivateIpv6 = (address) => {
116
+ const match = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
117
+ if (!match)
118
+ return false;
119
+ const ipv4 = match[1];
120
+ return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ipv4));
121
+ };
122
+ const LOCAL_HOSTNAME_PATTERNS = [
123
+ /^localhost$/i,
124
+ /\.localhost$/i,
125
+ /^localhost\./i,
126
+ /\.local$/i,
127
+ /\.internal$/i,
128
+ /\.lan$/i,
129
+ /\.intranet$/i,
130
+ ];
131
+ const CSS_NAMED_COLORS = new Set([
132
+ 'black',
133
+ 'white',
134
+ 'red',
135
+ 'green',
136
+ 'blue',
137
+ 'yellow',
138
+ 'orange',
139
+ 'purple',
140
+ 'pink',
141
+ 'brown',
142
+ 'gray',
143
+ 'grey',
144
+ 'cyan',
145
+ 'magenta',
146
+ 'lime',
147
+ 'navy',
148
+ 'teal',
149
+ 'silver',
150
+ 'gold',
151
+ 'beige',
152
+ 'maroon',
153
+ 'olive',
154
+ 'coral',
155
+ 'indigo',
156
+ 'violet',
157
+ 'turquoise',
158
+ ]);
159
+ const COMPONENT_HINTS = [
160
+ { keyword: /<button\b|class=("|')[^"']*\bbtn\b/i, label: 'buttons' },
161
+ { keyword: /<form\b|<input\b|<textarea\b|<select\b/i, label: 'forms' },
162
+ { keyword: /<nav\b|class=("|')[^"']*\bnav\b/i, label: 'navigation' },
163
+ { keyword: /<header\b/i, label: 'header / top bar' },
164
+ { keyword: /<footer\b/i, label: 'footer' },
165
+ { keyword: /class=("|')[^"']*\bhero\b/i, label: 'hero section' },
166
+ { keyword: /class=("|')[^"']*\bcard\b/i, label: 'cards' },
167
+ { keyword: /class=("|')[^"']*\b(badge|chip)\b/i, label: 'badges / chips' },
168
+ { keyword: /class=("|')[^"']*\bpricing\b/i, label: 'pricing layout' },
169
+ { keyword: /class=("|')[^"']*\bcta\b/i, label: 'call-to-action blocks' },
170
+ { keyword: /class=("|')[^"']*\btestimonial\b/i, label: 'testimonials' },
171
+ { keyword: /class=("|')[^"']*\b(modal|dialog)\b/i, label: 'modals / dialogs' },
172
+ { keyword: /class=("|')[^"']*\b(grid|columns)\b/i, label: 'grid / columnar layout' },
173
+ { keyword: /class=("|')[^"']*\bavatar\b/i, label: 'avatars' },
174
+ ];
175
+ const MOTION_HINTS = [
176
+ { keyword: /\btransition\s*:/i, label: 'CSS transitions' },
177
+ { keyword: /\banimation\s*:/i, label: 'CSS keyframe animation' },
178
+ { keyword: /@keyframes\b/i, label: 'custom keyframes' },
179
+ { keyword: /\bdata-aos\b/i, label: 'scroll-driven AOS animations' },
180
+ { keyword: /\bmotion\.|framer-motion/i, label: 'motion library usage' },
181
+ ];
182
+ const ICONOGRAPHY_HINTS = [
183
+ { keyword: /\b(?:phosphor|lucide|feather|heroicons|font-awesome|material-icons)\b/i, label: 'icon library references' },
184
+ { keyword: /<svg\b/i, label: 'inline SVG iconography' },
185
+ ];
186
+ const trimWhitespace = (value) => value.replace(/\s+/g, ' ').trim();
187
+ const dedupe = (values) => {
188
+ const seen = new Set();
189
+ const out = [];
190
+ for (const value of values) {
191
+ const cleaned = trimWhitespace(value);
192
+ if (!cleaned)
193
+ continue;
194
+ const key = cleaned.toLowerCase();
195
+ if (seen.has(key))
196
+ continue;
197
+ seen.add(key);
198
+ out.push(cleaned);
199
+ }
200
+ return out;
201
+ };
202
+ /**
203
+ * Synchronous URL validator: enforces HTTP/HTTPS scheme and rejects literal
204
+ * hostnames that match local/loopback/link-local patterns. DNS-resolved
205
+ * checks live in `defaultInspirationFetcher` so the simple checks here can
206
+ * run without IO during input validation.
207
+ */
208
+ const validateInspirationUrl = (rawUrl) => {
209
+ const trimmed = rawUrl.trim();
210
+ if (!trimmed) {
211
+ return { ok: false, reason: 'URL is empty.' };
212
+ }
213
+ let parsed;
214
+ try {
215
+ parsed = new URL(trimmed);
216
+ }
217
+ catch {
218
+ return { ok: false, reason: `URL is not valid: ${trimmed}` };
219
+ }
220
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
221
+ return {
222
+ ok: false,
223
+ reason: `Only HTTP and HTTPS inspiration links are supported (got '${parsed.protocol.replace(/:$/, '')}').`,
224
+ };
225
+ }
226
+ const hostname = parsed.hostname.toLowerCase();
227
+ if (!hostname) {
228
+ return { ok: false, reason: 'URL is missing a hostname.' };
229
+ }
230
+ if ((0, exports.isLocalLiteralHostname)(hostname)) {
231
+ return {
232
+ ok: false,
233
+ reason: `URL hostname '${hostname}' resolves to a local or private-network target.`,
234
+ };
235
+ }
236
+ parsed.hash = '';
237
+ return { ok: true, url: parsed, normalized: parsed.toString() };
238
+ };
239
+ exports.validateInspirationUrl = validateInspirationUrl;
240
+ /**
241
+ * Returns true for hostnames that are unambiguously local or private without
242
+ * any DNS lookup. IPv4 / IPv6 literals get the private-range check;
243
+ * hostname strings get the local-domain pattern check.
244
+ */
245
+ const isLocalLiteralHostname = (hostname) => {
246
+ const trimmed = hostname.replace(/^\[|\]$/g, '');
247
+ if (PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(trimmed))) {
248
+ return true;
249
+ }
250
+ if (PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(trimmed))) {
251
+ return true;
252
+ }
253
+ if (isIpv4MappedPrivateIpv6(trimmed)) {
254
+ return true;
255
+ }
256
+ if (LOCAL_HOSTNAME_PATTERNS.some((pattern) => pattern.test(trimmed))) {
257
+ return true;
258
+ }
259
+ return false;
260
+ };
261
+ exports.isLocalLiteralHostname = isLocalLiteralHostname;
262
+ /**
263
+ * Resolve hostname and check the resolved address against private/loopback/
264
+ * link-local ranges. Used inside `defaultInspirationFetcher` before issuing
265
+ * a fetch so users cannot point inspiration links at private network hosts
266
+ * via DNS rebinding-style indirection.
267
+ *
268
+ * @effect Performs a DNS lookup via dns.promises.lookup
269
+ */
270
+ const isResolvedHostProtected = async (hostname) => {
271
+ if ((0, exports.isLocalLiteralHostname)(hostname))
272
+ return true;
273
+ try {
274
+ const lookup = await dns_1.default.promises.lookup(hostname, { all: true });
275
+ return lookup.some(({ address }) => {
276
+ if (address.includes(':')) {
277
+ if (PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(address))) {
278
+ return true;
279
+ }
280
+ return isIpv4MappedPrivateIpv6(address);
281
+ }
282
+ return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(address));
283
+ });
284
+ }
285
+ catch {
286
+ // Fail closed: a DNS resolution failure here must not allow the
287
+ // subsequent `fetch` to proceed unchecked, since a transient DNS error
288
+ // followed by a successful resolution to a private IP would bypass the
289
+ // SSRF guard. Treat lookup failures as protected.
290
+ return true;
291
+ }
292
+ };
293
+ exports.isResolvedHostProtected = isResolvedHostProtected;
294
+ const isReferenceIntent = (windowText) => REFERENCE_INTENT_PATTERNS.some((pattern) => pattern.test(windowText));
295
+ const isSeparateIntent = (text) => SEPARATE_INTENT_PATTERNS.some((pattern) => pattern.test(text));
296
+ const normalizeRoleMatchUrl = (rawUrl) => {
297
+ const trimmed = rawUrl.trim();
298
+ if (!trimmed)
299
+ return null;
300
+ try {
301
+ const parsed = new URL(trimmed);
302
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
303
+ return null;
304
+ }
305
+ parsed.hash = '';
306
+ return parsed.toString();
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ };
312
+ const findRoleForUrl = (url, sourceContext) => {
313
+ const normalizedCandidate = normalizeRoleMatchUrl(url);
314
+ if (!normalizedCandidate)
315
+ return undefined;
316
+ const roles = sourceContext?.artifact?.sourceRoles ?? [];
317
+ for (const entry of roles) {
318
+ if (normalizeRoleMatchUrl(entry.url) === normalizedCandidate) {
319
+ return entry.role;
320
+ }
321
+ }
322
+ return undefined;
323
+ };
324
+ const findArtifactLabelForUrl = (url, sourceContext) => {
325
+ for (const artifact of sourceContext?.sourceArtifacts ?? []) {
326
+ if (artifact.kind === 'urlText' && artifact.content.includes(url)) {
327
+ return artifact.label;
328
+ }
329
+ }
330
+ return undefined;
331
+ };
332
+ const windowAroundUrl = (text, url, span = 80) => {
333
+ const index = text.indexOf(url);
334
+ if (index < 0)
335
+ return '';
336
+ const start = Math.max(0, index - span);
337
+ const end = Math.min(text.length, index + url.length + span);
338
+ return text.slice(start, end);
339
+ };
340
+ /**
341
+ * Decide whether a URL discovered in the user message is design inspiration,
342
+ * source/content reference, or unrelated context. Combines local heuristics
343
+ * on the prompt with explicit role hints from `sourceContext.artifact`.
344
+ *
345
+ * `primary` role -> `reference` (treated as content source).
346
+ * `inspiration` role -> `inspiration`.
347
+ * `competitor` role -> `inspiration` (will merge as separate per-slot).
348
+ * Otherwise prompt phrasing wins; default is `inspiration` because the user
349
+ * provided the URL in a creation flow.
350
+ */
351
+ const inferLinkIntent = (input) => {
352
+ const role = findRoleForUrl(input.url, input.sourceContext);
353
+ if (role === 'primary')
354
+ return 'reference';
355
+ if (role === 'inspiration' || role === 'competitor')
356
+ return 'inspiration';
357
+ const promptWindow = windowAroundUrl(input.prompt, input.url);
358
+ if (promptWindow && isReferenceIntent(promptWindow)) {
359
+ return 'reference';
360
+ }
361
+ return 'inspiration';
362
+ };
363
+ exports.inferLinkIntent = inferLinkIntent;
364
+ /**
365
+ * Classify a deduped URL list against the prompt + sourceContext. Picks an
366
+ * overall mergeStrategy: when the user's message frames the links as
367
+ * alternatives/competitors/options, return `'separate'`; otherwise `'merge'`.
368
+ * Explicit `competitor` roles also force `'separate'`.
369
+ */
370
+ const classifyInspirationLinks = (input) => {
371
+ const inspiration = [];
372
+ const reference = [];
373
+ let competitorOrAlternativeHint = false;
374
+ for (const url of input.urls.slice(0, MAX_INSPIRATION_LINKS)) {
375
+ const intent = (0, exports.inferLinkIntent)({
376
+ url,
377
+ prompt: input.prompt,
378
+ sourceContext: input.sourceContext,
379
+ });
380
+ const label = findArtifactLabelForUrl(url, input.sourceContext);
381
+ const entry = { url, intent, ...(label ? { label } : {}) };
382
+ if (intent === 'inspiration') {
383
+ inspiration.push(entry);
384
+ const role = findRoleForUrl(url, input.sourceContext);
385
+ if (role === 'competitor')
386
+ competitorOrAlternativeHint = true;
387
+ }
388
+ else if (intent === 'reference') {
389
+ reference.push(entry);
390
+ }
391
+ }
392
+ const promptHintsSeparate = isSeparateIntent(input.prompt);
393
+ const mergeStrategy = competitorOrAlternativeHint || promptHintsSeparate ? 'separate' : 'merge';
394
+ return { inspiration, reference, mergeStrategy };
395
+ };
396
+ exports.classifyInspirationLinks = classifyInspirationLinks;
397
+ /**
398
+ * Default fetcher used by `extractInspirationDesignContext`. Performs a
399
+ * pre-fetch DNS check, applies a timeout, caps response size, validates the
400
+ * response content-type, and refuses non-HTML payloads. Treats remote page
401
+ * content as untrusted data — does not execute, evaluate, or inline its
402
+ * text as agent instructions.
403
+ *
404
+ * @effect Issues an outbound HTTPS/HTTP fetch and a DNS lookup. Aborts on
405
+ * timeout, redirect limit, or oversize payloads.
406
+ */
407
+ const defaultInspirationFetcher = async (rawUrl) => {
408
+ const validation = (0, exports.validateInspirationUrl)(rawUrl);
409
+ if (!validation.ok) {
410
+ return { ok: false, url: rawUrl, reason: validation.reason };
411
+ }
412
+ const url = validation.url;
413
+ const protectedTarget = await (0, exports.isResolvedHostProtected)(url.hostname);
414
+ if (protectedTarget) {
415
+ return {
416
+ ok: false,
417
+ url: validation.normalized,
418
+ reason: `Hostname '${url.hostname}' resolves to a protected target.`,
419
+ };
420
+ }
421
+ const controller = new AbortController();
422
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
423
+ try {
424
+ const response = await fetch(validation.normalized, {
425
+ redirect: 'follow',
426
+ signal: controller.signal,
427
+ headers: {
428
+ accept: 'text/html,application/xhtml+xml',
429
+ 'user-agent': 'rivet-inspiration-extractor/1.0 (+https://tryrivet.design)',
430
+ },
431
+ });
432
+ if (!response.ok) {
433
+ return {
434
+ ok: false,
435
+ url: validation.normalized,
436
+ reason: `Request failed with status ${response.status}.`,
437
+ };
438
+ }
439
+ const finalUrlRaw = response.url || validation.normalized;
440
+ let finalUrlNormalized = finalUrlRaw;
441
+ try {
442
+ const finalParsed = new URL(finalUrlRaw);
443
+ if (await (0, exports.isResolvedHostProtected)(finalParsed.hostname)) {
444
+ return {
445
+ ok: false,
446
+ url: validation.normalized,
447
+ reason: `Final hostname '${finalParsed.hostname}' resolves to a protected target after redirects.`,
448
+ };
449
+ }
450
+ finalUrlNormalized = finalParsed.toString();
451
+ }
452
+ catch {
453
+ // best-effort; fall back to the raw final URL string
454
+ }
455
+ const contentType = response.headers.get('content-type') ?? '';
456
+ if (!/text\/html|application\/xhtml\+xml/i.test(contentType)) {
457
+ return {
458
+ ok: false,
459
+ url: validation.normalized,
460
+ reason: `Unsupported content type '${contentType || 'unknown'}'.`,
461
+ };
462
+ }
463
+ const reader = response.body?.getReader();
464
+ if (!reader) {
465
+ const text = await response.text();
466
+ if (text.length > MAX_BYTES) {
467
+ return {
468
+ ok: false,
469
+ url: validation.normalized,
470
+ reason: 'Response exceeds size limit.',
471
+ };
472
+ }
473
+ return {
474
+ ok: true,
475
+ url: validation.normalized,
476
+ finalUrl: finalUrlNormalized,
477
+ html: text,
478
+ contentType,
479
+ byteLength: text.length,
480
+ };
481
+ }
482
+ const chunks = [];
483
+ let total = 0;
484
+ while (true) {
485
+ const { done, value } = await reader.read();
486
+ if (done)
487
+ break;
488
+ if (!value)
489
+ continue;
490
+ total += value.byteLength;
491
+ if (total > MAX_BYTES) {
492
+ try {
493
+ await reader.cancel();
494
+ }
495
+ catch {
496
+ // best effort
497
+ }
498
+ return {
499
+ ok: false,
500
+ url: validation.normalized,
501
+ reason: 'Response exceeds size limit.',
502
+ };
503
+ }
504
+ chunks.push(value);
505
+ }
506
+ const html = new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
507
+ return {
508
+ ok: true,
509
+ url: validation.normalized,
510
+ finalUrl: finalUrlNormalized,
511
+ html,
512
+ contentType,
513
+ byteLength: total,
514
+ };
515
+ }
516
+ catch (error) {
517
+ const message = error instanceof Error ? error.message : 'fetch failed';
518
+ return { ok: false, url: validation.normalized, reason: message };
519
+ }
520
+ finally {
521
+ clearTimeout(timeout);
522
+ }
523
+ };
524
+ exports.defaultInspirationFetcher = defaultInspirationFetcher;
525
+ const decodeHtmlEntities = (value) => value
526
+ .replace(/&amp;/g, '&')
527
+ .replace(/&lt;/g, '<')
528
+ .replace(/&gt;/g, '>')
529
+ .replace(/&quot;/g, '"')
530
+ .replace(/&#39;/g, "'");
531
+ /**
532
+ * Discover absolute stylesheet URLs from `<link rel="stylesheet">` tags.
533
+ * Resolves relative `href` values against `baseUrl`, deduplicates, and
534
+ * returns at most `MAX_LINKED_STYLESHEETS` entries so the caller's fetch
535
+ * budget stays bounded. Only HTTP/HTTPS targets are returned — `data:`,
536
+ * `blob:`, and other schemes are dropped before any safety checks run.
537
+ */
538
+ const discoverLinkedStylesheetUrls = (html, baseUrl) => {
539
+ const collected = new Set();
540
+ const tagRegex = /<link\b[^>]*>/gi;
541
+ let tag;
542
+ while ((tag = tagRegex.exec(html)) !== null) {
543
+ const fragment = tag[0];
544
+ if (!/\brel\s*=\s*["'][^"']*\bstylesheet\b/i.test(fragment))
545
+ continue;
546
+ const hrefMatch = fragment.match(/\bhref\s*=\s*["']([^"']+)["']/i);
547
+ if (!hrefMatch)
548
+ continue;
549
+ const rawHref = decodeHtmlEntities(hrefMatch[1]).trim();
550
+ if (!rawHref)
551
+ continue;
552
+ try {
553
+ const resolved = new URL(rawHref, baseUrl);
554
+ if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') {
555
+ continue;
556
+ }
557
+ resolved.hash = '';
558
+ collected.add(resolved.toString());
559
+ }
560
+ catch {
561
+ // ignore malformed hrefs; skipping is the safest outcome
562
+ }
563
+ if (collected.size >= MAX_LINKED_STYLESHEETS)
564
+ break;
565
+ }
566
+ return Array.from(collected).slice(0, MAX_LINKED_STYLESHEETS);
567
+ };
568
+ exports.discoverLinkedStylesheetUrls = discoverLinkedStylesheetUrls;
569
+ /**
570
+ * Default linked-stylesheet fetcher. Mirrors `defaultInspirationFetcher`'s
571
+ * pre-fetch DNS check, redirect-aware re-check, timeout, and byte cap, but
572
+ * accepts `text/css` (or `text/plain`) content and uses tighter per-file
573
+ * limits. Returned `css` is treated as untrusted data — callers must not
574
+ * evaluate it or inline it as agent instructions.
575
+ *
576
+ * @effect Issues an outbound HTTPS/HTTP fetch and a DNS lookup. Aborts on
577
+ * timeout or oversize payloads.
578
+ */
579
+ const defaultLinkedCssFetcher = async (rawUrl) => {
580
+ const validation = (0, exports.validateInspirationUrl)(rawUrl);
581
+ if (!validation.ok) {
582
+ return { ok: false, url: rawUrl, reason: validation.reason };
583
+ }
584
+ const url = validation.url;
585
+ const protectedTarget = await (0, exports.isResolvedHostProtected)(url.hostname);
586
+ if (protectedTarget) {
587
+ return {
588
+ ok: false,
589
+ url: validation.normalized,
590
+ reason: `Hostname '${url.hostname}' resolves to a protected target.`,
591
+ };
592
+ }
593
+ const controller = new AbortController();
594
+ const timeout = setTimeout(() => controller.abort(), LINKED_CSS_FETCH_TIMEOUT_MS);
595
+ try {
596
+ const response = await fetch(validation.normalized, {
597
+ redirect: 'follow',
598
+ signal: controller.signal,
599
+ headers: {
600
+ accept: 'text/css,*/*;q=0.1',
601
+ 'user-agent': 'rivet-inspiration-extractor/1.0 (+https://tryrivet.design)',
602
+ },
603
+ });
604
+ if (!response.ok) {
605
+ return {
606
+ ok: false,
607
+ url: validation.normalized,
608
+ reason: `CSS request failed with status ${response.status}.`,
609
+ };
610
+ }
611
+ const finalUrlRaw = response.url || validation.normalized;
612
+ let finalUrlNormalized = finalUrlRaw;
613
+ try {
614
+ const finalParsed = new URL(finalUrlRaw);
615
+ if (await (0, exports.isResolvedHostProtected)(finalParsed.hostname)) {
616
+ return {
617
+ ok: false,
618
+ url: validation.normalized,
619
+ reason: `Final CSS hostname '${finalParsed.hostname}' resolves to a protected target after redirects.`,
620
+ };
621
+ }
622
+ finalUrlNormalized = finalParsed.toString();
623
+ }
624
+ catch {
625
+ // best-effort; fall back to the raw final URL string
626
+ }
627
+ const contentType = response.headers.get('content-type') ?? '';
628
+ if (!/text\/css|text\/plain/i.test(contentType)) {
629
+ return {
630
+ ok: false,
631
+ url: validation.normalized,
632
+ reason: `Unsupported CSS content type '${contentType || 'unknown'}'.`,
633
+ };
634
+ }
635
+ const reader = response.body?.getReader();
636
+ if (!reader) {
637
+ const text = await response.text();
638
+ if (text.length > LINKED_CSS_MAX_BYTES_PER_FILE) {
639
+ return {
640
+ ok: false,
641
+ url: validation.normalized,
642
+ reason: 'CSS response exceeds size limit.',
643
+ };
644
+ }
645
+ return {
646
+ ok: true,
647
+ url: validation.normalized,
648
+ finalUrl: finalUrlNormalized,
649
+ css: text,
650
+ byteLength: text.length,
651
+ };
652
+ }
653
+ const chunks = [];
654
+ let total = 0;
655
+ while (true) {
656
+ const { done, value } = await reader.read();
657
+ if (done)
658
+ break;
659
+ if (!value)
660
+ continue;
661
+ total += value.byteLength;
662
+ if (total > LINKED_CSS_MAX_BYTES_PER_FILE) {
663
+ try {
664
+ await reader.cancel();
665
+ }
666
+ catch {
667
+ // best effort
668
+ }
669
+ return {
670
+ ok: false,
671
+ url: validation.normalized,
672
+ reason: 'CSS response exceeds size limit.',
673
+ };
674
+ }
675
+ chunks.push(value);
676
+ }
677
+ const css = new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
678
+ return {
679
+ ok: true,
680
+ url: validation.normalized,
681
+ finalUrl: finalUrlNormalized,
682
+ css,
683
+ byteLength: total,
684
+ };
685
+ }
686
+ catch (error) {
687
+ const message = error instanceof Error ? error.message : 'fetch failed';
688
+ return { ok: false, url: validation.normalized, reason: message };
689
+ }
690
+ finally {
691
+ clearTimeout(timeout);
692
+ }
693
+ };
694
+ exports.defaultLinkedCssFetcher = defaultLinkedCssFetcher;
695
+ /**
696
+ * Fetch up to `MAX_LINKED_STYLESHEETS` linked stylesheets discovered in
697
+ * the fetched HTML, honoring a cumulative byte budget. Returns the
698
+ * concatenated CSS text and a count of how many sheets contributed.
699
+ * The returned CSS is intentionally ephemeral — callers MUST NOT persist
700
+ * or forward it to telemetry; only derived signal extractions may be
701
+ * stored.
702
+ */
703
+ const fetchLinkedStylesheets = async (html, baseUrl, fetcher) => {
704
+ const urls = (0, exports.discoverLinkedStylesheetUrls)(html, baseUrl);
705
+ if (urls.length === 0) {
706
+ return {
707
+ css: '',
708
+ fetchedCount: 0,
709
+ attemptedCount: 0,
710
+ byteTotal: 0,
711
+ skippedReasons: [],
712
+ };
713
+ }
714
+ const parts = [];
715
+ const skippedReasons = [];
716
+ let byteTotal = 0;
717
+ let fetchedCount = 0;
718
+ let attemptedCount = 0;
719
+ for (const url of urls) {
720
+ attemptedCount += 1;
721
+ if (byteTotal >= LINKED_CSS_MAX_TOTAL_BYTES) {
722
+ skippedReasons.push('total_budget_exceeded');
723
+ break;
724
+ }
725
+ const result = await fetcher(url);
726
+ if (!result.ok) {
727
+ skippedReasons.push(result.reason);
728
+ continue;
729
+ }
730
+ const remaining = LINKED_CSS_MAX_TOTAL_BYTES - byteTotal;
731
+ const css = result.css.length > remaining ? result.css.slice(0, remaining) : result.css;
732
+ parts.push(css);
733
+ byteTotal += css.length;
734
+ fetchedCount += 1;
735
+ }
736
+ return {
737
+ css: parts.join('\n'),
738
+ fetchedCount,
739
+ attemptedCount,
740
+ byteTotal,
741
+ skippedReasons,
742
+ };
743
+ };
744
+ exports.fetchLinkedStylesheets = fetchLinkedStylesheets;
745
+ const stripTags = (html) => decodeHtmlEntities(html.replace(/<[^>]*>/g, ' '));
746
+ const extractMetaContent = (html, name) => {
747
+ const namePattern = new RegExp(`<meta\\b[^>]*\\b(?:name|property)\\s*=\\s*["']${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*\\bcontent\\s*=\\s*["']([^"']+)["']`, 'i');
748
+ const reversedPattern = new RegExp(`<meta\\b[^>]*\\bcontent\\s*=\\s*["']([^"']+)["'][^>]*\\b(?:name|property)\\s*=\\s*["']${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i');
749
+ const match = html.match(namePattern) ?? html.match(reversedPattern);
750
+ return match ? decodeHtmlEntities(match[1]) : undefined;
751
+ };
752
+ const extractTitle = (html) => {
753
+ const match = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
754
+ if (!match)
755
+ return undefined;
756
+ const cleaned = trimWhitespace(decodeHtmlEntities(match[1]));
757
+ return cleaned || undefined;
758
+ };
759
+ const findFontFamilies = (block) => {
760
+ const families = [];
761
+ // Capture the entire value up to the next `;`, `}`, or newline so quoted
762
+ // family names (`'Inter'`, `"Space Grotesk"`) are kept intact. The
763
+ // per-token split below strips surrounding quotes.
764
+ const regex = /font-family\s*:\s*([^;}\n]+)/gi;
765
+ let match;
766
+ while ((match = regex.exec(block)) !== null) {
767
+ const stack = match[1]
768
+ .split(',')
769
+ .map((item) => trimWhitespace(item.replace(/^["']|["']$/g, '')))
770
+ .filter(Boolean);
771
+ families.push(...stack);
772
+ }
773
+ return families;
774
+ };
775
+ const GOOGLE_FONTS_HOST_PATTERN = /^fonts\.googleapis\.com$/i;
776
+ const normalizeFontFamilyToken = (value) => trimWhitespace(value.replace(/^["']|["']$/g, ''));
777
+ /**
778
+ * Parse Google Fonts families from `<link rel="stylesheet" href="...">`
779
+ * entries in the fetched HTML. Captures only family names; query details
780
+ * and raw URL payloads are intentionally discarded.
781
+ */
782
+ const extractGoogleFontFamilies = (html) => {
783
+ const families = [];
784
+ const tagRegex = /<link\b[^>]*>/gi;
785
+ let tagMatch;
786
+ while ((tagMatch = tagRegex.exec(html)) !== null) {
787
+ const tag = tagMatch[0];
788
+ if (!/\brel\s*=\s*["'][^"']*\bstylesheet\b/i.test(tag))
789
+ continue;
790
+ const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i);
791
+ if (!hrefMatch)
792
+ continue;
793
+ const href = decodeHtmlEntities(hrefMatch[1]).trim();
794
+ if (!href)
795
+ continue;
796
+ let parsed;
797
+ try {
798
+ parsed = new URL(href);
799
+ }
800
+ catch {
801
+ continue;
802
+ }
803
+ if (!GOOGLE_FONTS_HOST_PATTERN.test(parsed.hostname))
804
+ continue;
805
+ for (const familyParam of parsed.searchParams.getAll('family')) {
806
+ const normalized = trimWhitespace(decodeURIComponent(familyParam).split(':')[0].replace(/\+/g, ' '));
807
+ if (normalized)
808
+ families.push(normalized);
809
+ }
810
+ }
811
+ return dedupe(families).slice(0, MAX_GOOGLE_FONT_FAMILIES);
812
+ };
813
+ const parseSrcHosts = (srcValue) => {
814
+ if (!srcValue)
815
+ return [];
816
+ const hosts = [];
817
+ const urlRegex = /url\(([^)]+)\)/gi;
818
+ let match;
819
+ while ((match = urlRegex.exec(srcValue)) !== null) {
820
+ const raw = trimWhitespace(match[1].replace(/^["']|["']$/g, ''));
821
+ if (!raw)
822
+ continue;
823
+ try {
824
+ const parsed = new URL(raw);
825
+ hosts.push(parsed.hostname.toLowerCase());
826
+ }
827
+ catch {
828
+ if (/^\/|^\.\.?\//.test(raw))
829
+ hosts.push('relative');
830
+ }
831
+ }
832
+ return dedupe(hosts);
833
+ };
834
+ /**
835
+ * Extract structured `@font-face` evidence without persisting raw CSS text.
836
+ */
837
+ const extractFontFaceEvidence = (css) => {
838
+ const evidence = [];
839
+ const regex = /@font-face\s*\{([\s\S]*?)\}/gi;
840
+ let match;
841
+ while ((match = regex.exec(css)) !== null) {
842
+ const body = match[1];
843
+ const rawFamily = getCssDeclaration(body, 'font-family');
844
+ if (!rawFamily)
845
+ continue;
846
+ const fontFamily = normalizeFontFamilyToken(rawFamily);
847
+ if (!fontFamily)
848
+ continue;
849
+ evidence.push({
850
+ fontFamily,
851
+ ...(getCssDeclaration(body, 'font-weight')
852
+ ? { fontWeight: getCssDeclaration(body, 'font-weight') }
853
+ : {}),
854
+ ...(getCssDeclaration(body, 'font-style')
855
+ ? { fontStyle: getCssDeclaration(body, 'font-style') }
856
+ : {}),
857
+ ...(getCssDeclaration(body, 'font-display')
858
+ ? { fontDisplay: getCssDeclaration(body, 'font-display') }
859
+ : {}),
860
+ srcHosts: parseSrcHosts(getCssDeclaration(body, 'src')),
861
+ });
862
+ if (evidence.length >= MAX_FONT_FACE_EVIDENCE)
863
+ break;
864
+ }
865
+ return evidence;
866
+ };
867
+ const extractStyleBlocks = (html) => {
868
+ const matches = html.match(/<style\b[^>]*>([\s\S]*?)<\/style>/gi) ?? [];
869
+ return matches.map((block) => block.replace(/<\/?style[^>]*>/gi, '')).join('\n');
870
+ };
871
+ const extractInlineStyles = (html) => {
872
+ const matches = html.match(/style\s*=\s*["']([^"']+)["']/gi) ?? [];
873
+ return matches.join('\n');
874
+ };
875
+ const HEX_COLOR_PATTERN = /#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/gi;
876
+ const RGB_COLOR_PATTERN = /\brgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*[0-9.]+)?\s*\)/gi;
877
+ const collectColors = (block) => {
878
+ const colors = [];
879
+ for (const match of block.match(HEX_COLOR_PATTERN) ?? []) {
880
+ colors.push(match.toLowerCase());
881
+ }
882
+ for (const match of block.match(RGB_COLOR_PATTERN) ?? []) {
883
+ colors.push(match.toLowerCase());
884
+ }
885
+ const namedRegex = /\b(?:color|background(?:-color)?|border(?:-color)?)\s*:\s*([a-z]+)/gi;
886
+ let namedMatch;
887
+ while ((namedMatch = namedRegex.exec(block)) !== null) {
888
+ const value = namedMatch[1].toLowerCase();
889
+ if (CSS_NAMED_COLORS.has(value))
890
+ colors.push(value);
891
+ }
892
+ return colors;
893
+ };
894
+ const collectWeights = (block) => {
895
+ const weights = [];
896
+ const regex = /font-weight\s*:\s*([^;}\n"']+)/gi;
897
+ let match;
898
+ while ((match = regex.exec(block)) !== null) {
899
+ weights.push(trimWhitespace(match[1]));
900
+ }
901
+ return weights;
902
+ };
903
+ const collectScaleSamples = (block) => {
904
+ const samples = [];
905
+ const regex = /font-size\s*:\s*([^;}\n"']+)/gi;
906
+ let match;
907
+ while ((match = regex.exec(block)) !== null) {
908
+ samples.push(trimWhitespace(match[1]));
909
+ }
910
+ return samples;
911
+ };
912
+ const detectSections = (html) => {
913
+ const sections = [];
914
+ if (/<header\b/i.test(html))
915
+ sections.push('Header / top bar');
916
+ if (/<nav\b/i.test(html))
917
+ sections.push('Primary navigation');
918
+ if (/class=("|')[^"']*\bhero\b/i.test(html))
919
+ sections.push('Hero section');
920
+ if (/class=("|')[^"']*\bfeatures?\b/i.test(html))
921
+ sections.push('Feature blocks');
922
+ if (/class=("|')[^"']*\bpricing\b/i.test(html))
923
+ sections.push('Pricing layout');
924
+ if (/class=("|')[^"']*\btestimonial\b/i.test(html))
925
+ sections.push('Social proof / testimonials');
926
+ if (/class=("|')[^"']*\bcta\b/i.test(html))
927
+ sections.push('Call-to-action band');
928
+ if (/<footer\b/i.test(html))
929
+ sections.push('Footer');
930
+ return dedupe(sections);
931
+ };
932
+ const detectStructureNotes = (html) => {
933
+ const notes = [];
934
+ if (/class=("|')[^"']*\b(?:container|max-w-)/i.test(html)) {
935
+ notes.push('Centered max-width container pattern');
936
+ }
937
+ if (/class=("|')[^"']*\b(?:grid|columns)\b/i.test(html)) {
938
+ notes.push('Multi-column grid sections');
939
+ }
940
+ if (/class=("|')[^"']*\b(?:flex|row|col)\b/i.test(html)) {
941
+ notes.push('Flexbox-driven horizontal sections');
942
+ }
943
+ if (/class=("|')[^"']*\b(?:sticky|fixed)\b/i.test(html)) {
944
+ notes.push('Sticky or fixed elements (likely top nav)');
945
+ }
946
+ if (/class=("|')[^"']*\b(?:dark|bg-black|bg-gray-9)\b/i.test(html)) {
947
+ notes.push('Dark surface usage in at least one section');
948
+ }
949
+ return dedupe(notes);
950
+ };
951
+ const detectComponents = (html) => dedupe(COMPONENT_HINTS.filter(({ keyword }) => keyword.test(html)).map(({ label }) => label));
952
+ const detectMotionCues = (html) => dedupe(MOTION_HINTS.filter(({ keyword }) => keyword.test(html)).map(({ label }) => label));
953
+ const detectIconography = (html) => dedupe(ICONOGRAPHY_HINTS.filter(({ keyword }) => keyword.test(html)).map(({ label }) => label));
954
+ const HEX_COLOR_TEST = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
955
+ const RGB_COLOR_TEST = /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*[0-9.]+)?\s*\)$/i;
956
+ /**
957
+ * Strip CSS variables, `!important`, comments, fallback chains, and quotes
958
+ * from a single property value so we can keep just the primary token.
959
+ */
960
+ const cleanCssValue = (raw) => trimWhitespace(raw
961
+ .replace(/\/\*[\s\S]*?\*\//g, '')
962
+ .replace(/!important/gi, '')
963
+ .replace(/^["']|["']$/g, ''));
964
+ /**
965
+ * Normalize a CSS color expression to a comparable lowercase form.
966
+ * Returns `undefined` for values we don't recognize as a single color
967
+ * literal (e.g., `var(--x)`, `linear-gradient(...)`).
968
+ */
969
+ const normalizeCssColor = (raw) => {
970
+ const cleaned = cleanCssValue(raw).split(/\s+/)[0]?.replace(/[,;]$/, '');
971
+ if (!cleaned)
972
+ return undefined;
973
+ const lower = cleaned.toLowerCase();
974
+ if (HEX_COLOR_TEST.test(lower))
975
+ return lower;
976
+ if (RGB_COLOR_TEST.test(lower))
977
+ return lower;
978
+ if (CSS_NAMED_COLORS.has(lower))
979
+ return lower;
980
+ return undefined;
981
+ };
982
+ /**
983
+ * Light-weight CSS rule splitter. Returns top-level `selector { body }`
984
+ * pairs. Skips at-rule wrappers (`@media`, `@supports`, etc.) by walking
985
+ * the brace stream and only emitting rules whose selector does not start
986
+ * with `@`. Nested-inside-at-rule rules are emitted with the at-rule
987
+ * stripped — good enough for static token extraction without pulling in a
988
+ * full CSS parser.
989
+ */
990
+ const extractRuleBlocks = (css) => {
991
+ const blocks = [];
992
+ const buffer = css.replace(/\/\*[\s\S]*?\*\//g, '');
993
+ let depth = 0;
994
+ let cursor = 0;
995
+ let selectorStart = 0;
996
+ for (let i = 0; i < buffer.length; i += 1) {
997
+ const ch = buffer[i];
998
+ if (ch === '{') {
999
+ if (depth === 0) {
1000
+ const selector = trimWhitespace(buffer.slice(selectorStart, i));
1001
+ cursor = i + 1;
1002
+ if (selector.startsWith('@')) {
1003
+ // Skip at-rule header; iterate into its body so nested rules
1004
+ // still surface (treat the at-rule like a transparent wrapper).
1005
+ depth = 1;
1006
+ selectorStart = cursor;
1007
+ continue;
1008
+ }
1009
+ depth = 1;
1010
+ // store selector + body once we hit the matching closing brace
1011
+ const startBody = cursor;
1012
+ let nested = 0;
1013
+ let j = i + 1;
1014
+ for (; j < buffer.length; j += 1) {
1015
+ const cj = buffer[j];
1016
+ if (cj === '{')
1017
+ nested += 1;
1018
+ else if (cj === '}') {
1019
+ if (nested === 0)
1020
+ break;
1021
+ nested -= 1;
1022
+ }
1023
+ }
1024
+ const body = buffer.slice(startBody, j);
1025
+ if (selector)
1026
+ blocks.push({ selector, body });
1027
+ i = j;
1028
+ depth = 0;
1029
+ selectorStart = j + 1;
1030
+ continue;
1031
+ }
1032
+ depth += 1;
1033
+ }
1034
+ else if (ch === '}') {
1035
+ depth = Math.max(0, depth - 1);
1036
+ if (depth === 0)
1037
+ selectorStart = i + 1;
1038
+ }
1039
+ }
1040
+ return blocks;
1041
+ };
1042
+ const getCssDeclaration = (body, property) => {
1043
+ const escaped = property.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
1044
+ const regex = new RegExp(`(?:^|;)\\s*${escaped}\\s*:\\s*([^;]+?)(?:;|$)`, 'i');
1045
+ const match = body.match(regex);
1046
+ if (!match)
1047
+ return undefined;
1048
+ return cleanCssValue(match[1]);
1049
+ };
1050
+ const collectDeclarations = (css, property) => {
1051
+ const escaped = property.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
1052
+ const regex = new RegExp(`${escaped}\\s*:\\s*([^;}\\n]+)`, 'gi');
1053
+ const values = [];
1054
+ let match;
1055
+ while ((match = regex.exec(css)) !== null) {
1056
+ const value = cleanCssValue(match[1]);
1057
+ if (value)
1058
+ values.push(value);
1059
+ }
1060
+ return values;
1061
+ };
1062
+ const PIXEL_PARSE_REGEX = /^(-?\d*\.?\d+)(px|rem|em)?$/i;
1063
+ const parseDimensionPixels = (value) => {
1064
+ const trimmed = value.trim().split(/\s+/)[0]?.toLowerCase();
1065
+ if (!trimmed)
1066
+ return undefined;
1067
+ const match = trimmed.match(PIXEL_PARSE_REGEX);
1068
+ if (!match)
1069
+ return undefined;
1070
+ const num = parseFloat(match[1]);
1071
+ if (Number.isNaN(num))
1072
+ return undefined;
1073
+ const unit = match[2] ?? '';
1074
+ if (unit === 'rem' || unit === 'em')
1075
+ return num * 16;
1076
+ return num;
1077
+ };
1078
+ /**
1079
+ * Bucket a list of dimension strings into sm/md/lg by ascending pixel
1080
+ * value. Preserves the original raw strings (so `0.5rem` stays as
1081
+ * `0.5rem` instead of becoming `8px`). Returns `observed` containing the
1082
+ * deduped raw values sorted by computed pixel size so callers can present
1083
+ * a clean ladder without re-sorting.
1084
+ */
1085
+ const bucketDimensions = (rawValues) => {
1086
+ const parsed = rawValues
1087
+ .map((raw) => ({ raw, px: parseDimensionPixels(raw) }))
1088
+ .filter((entry) => entry.px !== undefined)
1089
+ .filter((entry) => entry.px >= 0)
1090
+ .sort((a, b) => a.px - b.px);
1091
+ const deduped = [];
1092
+ const seen = new Set();
1093
+ for (const entry of parsed) {
1094
+ if (seen.has(entry.px))
1095
+ continue;
1096
+ seen.add(entry.px);
1097
+ deduped.push(entry);
1098
+ }
1099
+ const observed = deduped.map((entry) => entry.raw).slice(0, MAX_SPACING_VALUES);
1100
+ if (deduped.length === 0)
1101
+ return { observed: [] };
1102
+ const sm = deduped[0].raw;
1103
+ const lg = deduped[deduped.length - 1].raw;
1104
+ const md = deduped[Math.floor(deduped.length / 2)].raw;
1105
+ return {
1106
+ sm,
1107
+ md: md !== sm && md !== lg ? md : undefined,
1108
+ lg: lg !== sm ? lg : undefined,
1109
+ observed,
1110
+ };
1111
+ };
1112
+ const SPACING_PROPERTIES = [
1113
+ 'padding',
1114
+ 'padding-top',
1115
+ 'padding-right',
1116
+ 'padding-bottom',
1117
+ 'padding-left',
1118
+ 'padding-inline',
1119
+ 'padding-block',
1120
+ 'margin',
1121
+ 'margin-top',
1122
+ 'margin-right',
1123
+ 'margin-bottom',
1124
+ 'margin-left',
1125
+ 'margin-inline',
1126
+ 'margin-block',
1127
+ 'gap',
1128
+ 'row-gap',
1129
+ 'column-gap',
1130
+ ];
1131
+ const extractSpacingTokens = (css) => {
1132
+ const values = [];
1133
+ for (const property of SPACING_PROPERTIES) {
1134
+ for (const raw of collectDeclarations(css, property)) {
1135
+ for (const piece of raw.split(/\s+/)) {
1136
+ const cleaned = piece.replace(/[,;]/g, '').trim();
1137
+ if (cleaned)
1138
+ values.push(cleaned);
1139
+ }
1140
+ }
1141
+ }
1142
+ return bucketDimensions(values);
1143
+ };
1144
+ const extractRoundedTokens = (css) => {
1145
+ const values = [];
1146
+ for (const raw of collectDeclarations(css, 'border-radius')) {
1147
+ for (const piece of raw.split(/\s+/)) {
1148
+ const cleaned = piece.replace(/[,;]/g, '').trim();
1149
+ if (cleaned)
1150
+ values.push(cleaned);
1151
+ }
1152
+ }
1153
+ const bucketed = bucketDimensions(values);
1154
+ return {
1155
+ ...bucketed,
1156
+ observed: bucketed.observed.slice(0, MAX_ROUNDED_VALUES),
1157
+ };
1158
+ };
1159
+ const extractShadowDeclarations = (css) => {
1160
+ const values = collectDeclarations(css, 'box-shadow').filter((value) => value.toLowerCase() !== 'none');
1161
+ return dedupe(values).slice(0, MAX_SHADOWS);
1162
+ };
1163
+ const extractTransitionDeclarations = (css) => {
1164
+ const values = [
1165
+ ...collectDeclarations(css, 'transition'),
1166
+ ...collectDeclarations(css, 'transition-property'),
1167
+ ...collectDeclarations(css, 'transition-duration'),
1168
+ ...collectDeclarations(css, 'transition-timing-function'),
1169
+ ...collectDeclarations(css, 'transition-delay'),
1170
+ ].filter((value) => value.toLowerCase() !== 'none');
1171
+ return dedupe(values).slice(0, MAX_TRANSITIONS);
1172
+ };
1173
+ const extractBreakpointConditions = (css) => {
1174
+ const out = [];
1175
+ const regex = /@media\b([^{]+)\{/gi;
1176
+ let match;
1177
+ while ((match = regex.exec(css)) !== null) {
1178
+ const condition = trimWhitespace(match[1]);
1179
+ if (condition)
1180
+ out.push(condition);
1181
+ }
1182
+ return dedupe(out).slice(0, MAX_BREAKPOINTS);
1183
+ };
1184
+ const TYPOGRAPHY_DISPLAY_SELECTOR_HINTS = [
1185
+ /(?:^|[,\s])h1\b/i,
1186
+ /(?:^|[,\s])h2\b/i,
1187
+ /\bdisplay\b/i,
1188
+ /\bhero(?:[-_].*)?\b/i,
1189
+ /\bheadline\b/i,
1190
+ /\btitle(?:[-_].*)?\b/i,
1191
+ ];
1192
+ const TYPOGRAPHY_BODY_SELECTOR_HINTS = [
1193
+ /(?:^|[,\s])body\b/i,
1194
+ /(?:^|[,\s])p\b/i,
1195
+ /\btext-body\b/i,
1196
+ /\bparagraph\b/i,
1197
+ ];
1198
+ const extractTypographyTokenFromBody = (body) => {
1199
+ const families = findFontFamilies(body);
1200
+ const fontFamily = families.length > 0 ? families[0] : undefined;
1201
+ return {
1202
+ fontFamily,
1203
+ fontSize: getCssDeclaration(body, 'font-size'),
1204
+ fontWeight: getCssDeclaration(body, 'font-weight'),
1205
+ lineHeight: getCssDeclaration(body, 'line-height'),
1206
+ letterSpacing: getCssDeclaration(body, 'letter-spacing'),
1207
+ fontVariationSettings: getCssDeclaration(body, 'font-variation-settings'),
1208
+ };
1209
+ };
1210
+ const hasAnyTypographyMetric = (token) => Boolean(token.fontFamily ||
1211
+ token.fontSize ||
1212
+ token.fontWeight ||
1213
+ token.lineHeight ||
1214
+ token.letterSpacing ||
1215
+ token.fontVariationSettings);
1216
+ const extractTypographyTokens = (css) => {
1217
+ const blocks = extractRuleBlocks(css);
1218
+ const tokens = {};
1219
+ for (const { selector, body } of blocks) {
1220
+ const normalized = selector.toLowerCase();
1221
+ if (!tokens.display &&
1222
+ TYPOGRAPHY_DISPLAY_SELECTOR_HINTS.some((re) => re.test(normalized))) {
1223
+ const token = extractTypographyTokenFromBody(body);
1224
+ if (hasAnyTypographyMetric(token))
1225
+ tokens.display = token;
1226
+ }
1227
+ if (!tokens.body &&
1228
+ TYPOGRAPHY_BODY_SELECTOR_HINTS.some((re) => re.test(normalized))) {
1229
+ const token = extractTypographyTokenFromBody(body);
1230
+ if (hasAnyTypographyMetric(token))
1231
+ tokens.body = token;
1232
+ }
1233
+ if (tokens.display && tokens.body)
1234
+ break;
1235
+ }
1236
+ return tokens;
1237
+ };
1238
+ const COMPONENT_SELECTOR_HINTS = [
1239
+ { pattern: /\b(?:btn-primary|button-primary|primary-button)\b/i, name: 'button-primary' },
1240
+ {
1241
+ pattern: /\b(?:btn-secondary|button-secondary|secondary-button)\b/i,
1242
+ name: 'button-secondary',
1243
+ },
1244
+ { pattern: /\bcard\b/i, name: 'card' },
1245
+ { pattern: /(?:^|[,\s])input\b/i, name: 'input' },
1246
+ { pattern: /\b(?:badge|chip)\b/i, name: 'badge' },
1247
+ { pattern: /\b(?:nav-primary|navbar|topnav)\b/i, name: 'navigation' },
1248
+ ];
1249
+ const extractComponentTokenDetails = (css) => {
1250
+ const blocks = extractRuleBlocks(css);
1251
+ const accum = new Map();
1252
+ for (const { selector, body } of blocks) {
1253
+ for (const { pattern, name } of COMPONENT_SELECTOR_HINTS) {
1254
+ if (!pattern.test(selector))
1255
+ continue;
1256
+ const existing = accum.get(name) ?? { name };
1257
+ if (!existing.backgroundColor) {
1258
+ const value = getCssDeclaration(body, 'background-color') ??
1259
+ getCssDeclaration(body, 'background');
1260
+ if (value)
1261
+ existing.backgroundColor = value;
1262
+ }
1263
+ if (!existing.textColor) {
1264
+ const value = getCssDeclaration(body, 'color');
1265
+ if (value)
1266
+ existing.textColor = value;
1267
+ }
1268
+ if (!existing.borderColor) {
1269
+ const value = getCssDeclaration(body, 'border-color') ??
1270
+ getCssDeclaration(body, 'border');
1271
+ if (value)
1272
+ existing.borderColor = value;
1273
+ }
1274
+ if (!existing.borderRadius) {
1275
+ const value = getCssDeclaration(body, 'border-radius');
1276
+ if (value)
1277
+ existing.borderRadius = value;
1278
+ }
1279
+ if (!existing.padding) {
1280
+ const value = getCssDeclaration(body, 'padding');
1281
+ if (value)
1282
+ existing.padding = value;
1283
+ }
1284
+ if (!existing.boxShadow) {
1285
+ const value = getCssDeclaration(body, 'box-shadow');
1286
+ if (value && value.toLowerCase() !== 'none')
1287
+ existing.boxShadow = value;
1288
+ }
1289
+ accum.set(name, existing);
1290
+ }
1291
+ }
1292
+ return Array.from(accum.values()).slice(0, MAX_COMPONENT_DETAILS);
1293
+ };
1294
+ // D8: Role-aware palette extraction.
1295
+ // Class-keyed hints (existing) anchor `primary` to explicit primary-CTA
1296
+ // classes. Element/attribute hints (added) anchor it to the visible CSS role
1297
+ // of CTA elements even when no `.primary`/`.btn-primary` class is in play —
1298
+ // many real pages style a bare `button` or `a[class*=cta]` directly and would
1299
+ // otherwise fall through to the frequency-rank fallback (now removed) and
1300
+ // silently promote a neutral background like `#f7f7f7` to `colors.primary`.
1301
+ const PRIMARY_SELECTOR_HINTS = [
1302
+ /\b(?:btn-primary|button-primary|primary-button|primary-cta)\b/i,
1303
+ /\b(?:cta-primary|action-primary)\b/i,
1304
+ /(?:^|[\s,>+~])\.primary\b/i,
1305
+ // Element-anchored hints. Match selectors whose final compound is a
1306
+ // `button` element, a `[role="button"]` ARIA-button anchor, a generic
1307
+ // `.btn` class, or an anchor carrying a `cta`/`primary` substring in any
1308
+ // class attribute. These cover the realistic patterns where the brand
1309
+ // color lives on a bare element rule rather than a semantic class name.
1310
+ /(?:^|[\s,>+~])button(?:[.:#[\s,]|$)/i,
1311
+ /(?:^|[\s,>+~])\.btn(?:[.:#[\s,]|$)/i,
1312
+ /\[role\s*=\s*("|')button\1\]/i,
1313
+ /(?:^|[\s,>+~])a\[class\*?=("|')[^"']*(?:cta|primary)/i,
1314
+ ];
1315
+ const SURFACE_SELECTOR_HINTS = [
1316
+ /\bcard\b/i,
1317
+ /\bpanel\b/i,
1318
+ /\bsurface\b/i,
1319
+ /\bsheet\b/i,
1320
+ ];
1321
+ const MUTED_SELECTOR_HINTS = [
1322
+ /\bmuted\b/i,
1323
+ /\bsubtle\b/i,
1324
+ /\bsecondary-text\b/i,
1325
+ /\btext-secondary\b/i,
1326
+ /\btext-muted\b/i,
1327
+ ];
1328
+ const NEUTRAL_HEX_TEST = /^#(?:0{3,8}|f{3,8}|([0-9a-f])\1{2,7})$/i;
1329
+ const isLikelyNeutralColor = (value) => {
1330
+ if (NEUTRAL_HEX_TEST.test(value))
1331
+ return true;
1332
+ const named = value.toLowerCase();
1333
+ return named === 'black' || named === 'white' || named === 'gray' || named === 'grey';
1334
+ };
1335
+ const inferColorRoles = (css, _palette) => {
1336
+ const blocks = extractRuleBlocks(css);
1337
+ const roles = {};
1338
+ for (const { selector, body } of blocks) {
1339
+ const normalized = selector.toLowerCase();
1340
+ const isRootScope = /(?:^|[,\s])(?:body|html|:root)\b/.test(normalized);
1341
+ if (isRootScope) {
1342
+ if (!roles.background) {
1343
+ const value = getCssDeclaration(body, 'background-color') ??
1344
+ getCssDeclaration(body, 'background');
1345
+ if (value) {
1346
+ const color = normalizeCssColor(value);
1347
+ if (color)
1348
+ roles.background = color;
1349
+ }
1350
+ }
1351
+ if (!roles.text) {
1352
+ const value = getCssDeclaration(body, 'color');
1353
+ if (value) {
1354
+ const color = normalizeCssColor(value);
1355
+ if (color)
1356
+ roles.text = color;
1357
+ }
1358
+ }
1359
+ }
1360
+ if (PRIMARY_SELECTOR_HINTS.some((re) => re.test(normalized))) {
1361
+ if (!roles.primary) {
1362
+ const value = getCssDeclaration(body, 'background-color') ??
1363
+ getCssDeclaration(body, 'background');
1364
+ if (value) {
1365
+ const color = normalizeCssColor(value);
1366
+ if (color)
1367
+ roles.primary = color;
1368
+ }
1369
+ }
1370
+ if (!roles.onPrimary) {
1371
+ const value = getCssDeclaration(body, 'color');
1372
+ if (value) {
1373
+ const color = normalizeCssColor(value);
1374
+ if (color)
1375
+ roles.onPrimary = color;
1376
+ }
1377
+ }
1378
+ }
1379
+ if (!roles.surface &&
1380
+ SURFACE_SELECTOR_HINTS.some((re) => re.test(normalized))) {
1381
+ const value = getCssDeclaration(body, 'background-color') ??
1382
+ getCssDeclaration(body, 'background');
1383
+ if (value) {
1384
+ const color = normalizeCssColor(value);
1385
+ if (color)
1386
+ roles.surface = color;
1387
+ }
1388
+ }
1389
+ if (!roles.muted &&
1390
+ MUTED_SELECTOR_HINTS.some((re) => re.test(normalized))) {
1391
+ const value = getCssDeclaration(body, 'color');
1392
+ if (value) {
1393
+ const color = normalizeCssColor(value);
1394
+ if (color)
1395
+ roles.muted = color;
1396
+ }
1397
+ }
1398
+ if (!roles.border) {
1399
+ const value = getCssDeclaration(body, 'border-color') ??
1400
+ getCssDeclaration(body, 'border');
1401
+ if (value) {
1402
+ const color = normalizeCssColor(value);
1403
+ if (color)
1404
+ roles.border = color;
1405
+ }
1406
+ }
1407
+ }
1408
+ // D8: No frequency-rank fallback. When no role-anchored color exists,
1409
+ // `roles.primary` stays undefined and the synthesized DESIGN.md surfaces
1410
+ // the gap via the "colors.primary was not observed directly" caveat. This
1411
+ // is intentional: a neutral promoted by frequency (e.g. `#f7f7f7` from
1412
+ // card backgrounds) was the exact failure mode that motivated this
1413
+ // change. Surfacing the gap is more useful than guessing.
1414
+ return roles;
1415
+ };
1416
+ const clampPercentage = (value) => Math.max(0, Math.min(100, Math.round(value)));
1417
+ const hasTypographyEvidence = (tokens, headingFamilies, bodyFamilies, scaleSamples) => Boolean(tokens.display ||
1418
+ tokens.body ||
1419
+ headingFamilies.length > 0 ||
1420
+ bodyFamilies.length > 0 ||
1421
+ scaleSamples.length > 0);
1422
+ const hasComponentTokenReferenceEvidence = (input) => {
1423
+ const hasColorToken = Boolean(input.colorRoles.primary ||
1424
+ input.colorRoles.onPrimary ||
1425
+ input.colorRoles.background ||
1426
+ input.colorRoles.surface ||
1427
+ input.colorRoles.text ||
1428
+ input.colorRoles.muted ||
1429
+ input.colorRoles.border);
1430
+ const hasTypographyToken = Boolean(input.typographyTokens.body || input.typographyTokens.display);
1431
+ const hasRoundedToken = Boolean(input.rounded.sm || input.rounded.md || input.rounded.lg);
1432
+ return input.componentDetails.some((detail) => Boolean((detail.backgroundColor && hasColorToken) ||
1433
+ (detail.textColor && hasColorToken) ||
1434
+ (detail.borderColor && hasColorToken) ||
1435
+ (detail.borderRadius && hasRoundedToken) ||
1436
+ (detail.padding && hasTypographyToken)));
1437
+ };
1438
+ /**
1439
+ * Fidelity-weighted coverage score for DESIGN.md readiness.
1440
+ * Major token gaps (colors.primary, typography tokens, component token refs)
1441
+ * incur heavy penalties. Sparse canonical-section evidence incurs additional
1442
+ * penalties so "lots of random CSS" does not look high-confidence.
1443
+ */
1444
+ const computeDesignFidelityScore = (input) => {
1445
+ let score = 100;
1446
+ if (!input.colorRoles.primary)
1447
+ score -= 30;
1448
+ const typographyTokenCount = (input.typographyTokens.display ? 1 : 0) +
1449
+ (input.typographyTokens.body ? 1 : 0);
1450
+ if (typographyTokenCount === 0)
1451
+ score -= 30;
1452
+ else if (typographyTokenCount === 1)
1453
+ score -= 15;
1454
+ if (!hasComponentTokenReferenceEvidence({
1455
+ colorRoles: input.colorRoles,
1456
+ typographyTokens: input.typographyTokens,
1457
+ rounded: input.rounded,
1458
+ componentDetails: input.componentDetails,
1459
+ })) {
1460
+ score -= 25;
1461
+ }
1462
+ const colorsSectionCovered = input.colors.length > 0 ||
1463
+ Object.values(input.colorRoles).filter(Boolean).length > 0;
1464
+ const typographySectionCovered = hasTypographyEvidence(input.typographyTokens, input.headingFamilies, input.bodyFamilies, input.scaleSamples);
1465
+ const layoutSectionCovered = input.sections.length > 0 || input.structureNotes.length > 0;
1466
+ const elevationSectionCovered = input.shadows.length > 0 || input.transitions.length > 0;
1467
+ const shapesSectionCovered = input.rounded.observed.length > 0;
1468
+ const componentsSectionCovered = input.components.length > 0 || input.componentDetails.length > 0;
1469
+ const motionCovered = input.motionCues.length > 0 || input.transitions.length > 0;
1470
+ const responsiveCovered = input.breakpoints.length > 0;
1471
+ if (!typographySectionCovered)
1472
+ score -= 10;
1473
+ if (!componentsSectionCovered)
1474
+ score -= 10;
1475
+ if (!motionCovered)
1476
+ score -= 8;
1477
+ if (!responsiveCovered)
1478
+ score -= 8;
1479
+ if (!elevationSectionCovered)
1480
+ score -= 8;
1481
+ const canonicalCoverageCount = [
1482
+ colorsSectionCovered,
1483
+ typographySectionCovered,
1484
+ layoutSectionCovered,
1485
+ elevationSectionCovered,
1486
+ shapesSectionCovered,
1487
+ componentsSectionCovered,
1488
+ ].filter(Boolean).length;
1489
+ if (canonicalCoverageCount < 5) {
1490
+ score -= (5 - canonicalCoverageCount) * 6;
1491
+ }
1492
+ return clampPercentage(score);
1493
+ };
1494
+ const TONE_HINTS = [
1495
+ { keyword: /\bcraft|handmade|artisan/i, label: 'crafted / artisanal' },
1496
+ { keyword: /\benterprise|secure|trusted/i, label: 'enterprise-grade / trustworthy' },
1497
+ { keyword: /\bplayful|fun|delight/i, label: 'playful / delight-forward' },
1498
+ { keyword: /\bminimal|simple|clean/i, label: 'minimal / restrained' },
1499
+ { keyword: /\bfast|speed|performant/i, label: 'speed-led' },
1500
+ { keyword: /\bbold|powerful|next-gen/i, label: 'bold / declarative' },
1501
+ ];
1502
+ const detectTone = (html) => {
1503
+ const text = stripTags(html).toLowerCase();
1504
+ return dedupe(TONE_HINTS.filter(({ keyword }) => keyword.test(text)).map(({ label }) => label));
1505
+ };
1506
+ /**
1507
+ * Pure HTML signal extractor. Operates on raw fetched HTML and pulls
1508
+ * typography, color, layout, component, motion, brand, and tone signals
1509
+ * via regex without instantiating a DOM. Returns deterministic ordered
1510
+ * arrays so callers and tests can compare snapshots stably.
1511
+ *
1512
+ * `signalDensity` is a 0-100 fidelity-weighted score indicating how well
1513
+ * the extracted evidence can populate canonical DESIGN.md token groups and
1514
+ * sections. Callers can treat low scores as low-signal pages and fall back
1515
+ * to prompt direction.
1516
+ */
1517
+ const extractDesignSignals = (html, options) => {
1518
+ const inlineStyles = `${extractStyleBlocks(html)}\n${extractInlineStyles(html)}`;
1519
+ const styles = options?.extraCss
1520
+ ? `${inlineStyles}\n${options.extraCss}`
1521
+ : inlineStyles;
1522
+ const googleFontFamilies = extractGoogleFontFamilies(html);
1523
+ const fontFaces = extractFontFaceEvidence(styles);
1524
+ const families = dedupe([
1525
+ ...findFontFamilies(styles),
1526
+ ...googleFontFamilies,
1527
+ ...fontFaces.map((face) => face.fontFamily),
1528
+ ]);
1529
+ const weights = dedupe(collectWeights(styles)).slice(0, MAX_TYPOGRAPHY_FAMILIES);
1530
+ const scaleSamples = dedupe(collectScaleSamples(styles)).slice(0, MAX_TYPOGRAPHY_FAMILIES);
1531
+ const colors = dedupe(collectColors(styles)).slice(0, MAX_PALETTE_COLORS);
1532
+ const themeColor = extractMetaContent(html, 'theme-color');
1533
+ const accents = colors.slice(0, Math.min(4, colors.length));
1534
+ // Word-boundary anchors keep `sans-serif` out of headings (only true
1535
+ // display-y names like `Space Grotesk` or `Display` should land here).
1536
+ const headingFamilyHint = families
1537
+ .filter((family) => /\b(?:display|grotesk|black|heavy)\b/i.test(family))
1538
+ .slice(0, MAX_TYPOGRAPHY_FAMILIES);
1539
+ const bodyFamilyHint = families
1540
+ .filter((family) => !/\b(?:display|grotesk|black|heavy)\b/i.test(family))
1541
+ .slice(0, MAX_TYPOGRAPHY_FAMILIES);
1542
+ const description = extractMetaContent(html, 'description') ??
1543
+ extractMetaContent(html, 'og:description');
1544
+ const siteName = extractMetaContent(html, 'og:site_name');
1545
+ const typographyTokens = extractTypographyTokens(styles);
1546
+ const colorRoles = inferColorRoles(styles, colors);
1547
+ const spacing = extractSpacingTokens(styles);
1548
+ const rounded = extractRoundedTokens(styles);
1549
+ const shadows = extractShadowDeclarations(styles);
1550
+ const transitions = extractTransitionDeclarations(styles);
1551
+ const breakpoints = extractBreakpointConditions(styles);
1552
+ const componentDetails = extractComponentTokenDetails(styles);
1553
+ const sectionSignals = detectSections(html).slice(0, MAX_LIST_ITEMS_PER_SECTION);
1554
+ const componentSignals = detectComponents(html).slice(0, MAX_LIST_ITEMS_PER_SECTION);
1555
+ const motionSignals = detectMotionCues(html).slice(0, MAX_LIST_ITEMS_PER_SECTION);
1556
+ const structureSignals = detectStructureNotes(html).slice(0, MAX_LIST_ITEMS_PER_SECTION);
1557
+ const signalDensity = computeDesignFidelityScore({
1558
+ colors,
1559
+ colorRoles,
1560
+ typographyTokens,
1561
+ headingFamilies: families.filter((family) => /\b(?:display|grotesk|black|heavy)\b/i.test(family)),
1562
+ bodyFamilies: families.filter((family) => !/\b(?:display|grotesk|black|heavy)\b/i.test(family)),
1563
+ scaleSamples,
1564
+ sections: sectionSignals,
1565
+ structureNotes: structureSignals,
1566
+ rounded,
1567
+ shadows,
1568
+ transitions,
1569
+ breakpoints,
1570
+ components: componentSignals,
1571
+ componentDetails,
1572
+ motionCues: motionSignals,
1573
+ });
1574
+ return {
1575
+ brand: {
1576
+ title: extractTitle(html),
1577
+ description,
1578
+ siteName,
1579
+ tone: detectTone(html),
1580
+ },
1581
+ typography: {
1582
+ headingFamilies: dedupe(headingFamilyHint),
1583
+ bodyFamilies: dedupe(bodyFamilyHint),
1584
+ weights,
1585
+ scaleSamples,
1586
+ ...(typographyTokens.display || typographyTokens.body
1587
+ ? { tokens: typographyTokens }
1588
+ : {}),
1589
+ ...(googleFontFamilies.length > 0 ? { googleFontFamilies } : {}),
1590
+ ...(fontFaces.length > 0 ? { fontFaces } : {}),
1591
+ },
1592
+ colors: {
1593
+ palette: colors,
1594
+ themeColor,
1595
+ accents,
1596
+ ...(Object.values(colorRoles).filter(Boolean).length > 0
1597
+ ? { roles: colorRoles }
1598
+ : {}),
1599
+ },
1600
+ layout: {
1601
+ sections: sectionSignals,
1602
+ structureNotes: structureSignals,
1603
+ },
1604
+ components: componentSignals,
1605
+ motionCues: motionSignals,
1606
+ iconography: detectIconography(html).slice(0, MAX_LIST_ITEMS_PER_SECTION),
1607
+ ...(spacing.observed.length > 0 ? { spacing } : {}),
1608
+ ...(rounded.observed.length > 0 ? { rounded } : {}),
1609
+ ...(shadows.length > 0 ? { shadows } : {}),
1610
+ ...(transitions.length > 0 ? { transitions } : {}),
1611
+ ...(breakpoints.length > 0 ? { breakpoints } : {}),
1612
+ ...(componentDetails.length > 0 ? { componentDetails } : {}),
1613
+ signalDensity,
1614
+ };
1615
+ };
1616
+ exports.extractDesignSignals = extractDesignSignals;
1617
+ const NOT_OBSERVED = 'Not observed';
1618
+ const formatList = (items, fallback) => items.length === 0 ? [`- ${fallback}`] : items.map((item) => `- ${item}`);
1619
+ const quoteYaml = (value) => JSON.stringify(value);
1620
+ const asTokenString = (value) => value ?? NOT_OBSERVED;
1621
+ const normalizeTokenValue = (value) => {
1622
+ if (!value)
1623
+ return undefined;
1624
+ return trimWhitespace(value).toLowerCase();
1625
+ };
1626
+ const resolveColorTokenReference = (value, colors) => {
1627
+ const normalized = normalizeTokenValue(value);
1628
+ if (!normalized)
1629
+ return undefined;
1630
+ const entries = [
1631
+ ['primary', colors.primary],
1632
+ ['on-primary', colors.onPrimary],
1633
+ ['background', colors.background],
1634
+ ['surface', colors.surface],
1635
+ ['text', colors.text],
1636
+ ['muted', colors.muted],
1637
+ ['border', colors.border],
1638
+ ];
1639
+ for (const [name, tokenValue] of entries) {
1640
+ if (normalizeTokenValue(tokenValue) === normalized)
1641
+ return `{colors.${name}}`;
1642
+ }
1643
+ return undefined;
1644
+ };
1645
+ const resolveRoundedTokenReference = (value, rounded) => {
1646
+ const normalized = normalizeTokenValue(value);
1647
+ if (!normalized)
1648
+ return undefined;
1649
+ const entries = [
1650
+ ['sm', rounded.sm],
1651
+ ['md', rounded.md],
1652
+ ['lg', rounded.lg],
1653
+ ];
1654
+ for (const [name, tokenValue] of entries) {
1655
+ if (normalizeTokenValue(tokenValue) === normalized)
1656
+ return `{rounded.${name}}`;
1657
+ }
1658
+ return undefined;
1659
+ };
1660
+ const hasTypographyToken = (token) => Boolean(token.fontFamily ||
1661
+ token.fontSize ||
1662
+ token.fontWeight ||
1663
+ token.lineHeight ||
1664
+ token.letterSpacing ||
1665
+ token.fontVariationSettings);
1666
+ const hasComponentTokenReference = (components) => components.some((component) => Object.values(component).some((value) => /\{(?:colors|typography|rounded)\.[^}]+\}/.test(value)));
1667
+ const formatHostForLabel = (url) => {
1668
+ try {
1669
+ return new URL(url).hostname;
1670
+ }
1671
+ catch {
1672
+ return url;
1673
+ }
1674
+ };
1675
+ /**
1676
+ * Produce reusable DESIGN.md markdown with token frontmatter plus canonical
1677
+ * prose sections (`Overview` through `Do's and Don'ts`). The markdown avoids
1678
+ * embedding raw fetched HTML, source code, DOM dumps, screenshots, or
1679
+ * proprietary copied text.
1680
+ */
1681
+ const buildInspirationDesignContextMarkdown = (input) => {
1682
+ if (input.signals.length === 0)
1683
+ return '';
1684
+ const mergedSignals = mergeSignals(input.signals);
1685
+ const sourceHosts = input.sources.map(({ url }) => formatHostForLabel(url));
1686
+ const colors = {
1687
+ // D8: do NOT fall back to the most prominent accent. If no role-anchored
1688
+ // primary was found, leave the slot undefined so the Caveat line below
1689
+ // surfaces the gap. Promoting a frequency-ranked accent here was the
1690
+ // SellRaze failure mode this change addresses.
1691
+ primary: mergedSignals.colors.roles?.primary,
1692
+ onPrimary: mergedSignals.colors.roles?.onPrimary,
1693
+ background: mergedSignals.colors.roles?.background ??
1694
+ mergedSignals.colors.palette.find((color) => isLikelyNeutralColor(color)),
1695
+ surface: mergedSignals.colors.roles?.surface,
1696
+ text: mergedSignals.colors.roles?.text,
1697
+ muted: mergedSignals.colors.roles?.muted,
1698
+ border: mergedSignals.colors.roles?.border,
1699
+ };
1700
+ const displayToken = {
1701
+ ...(mergedSignals.typography.tokens?.display ?? {}),
1702
+ ...(!mergedSignals.typography.tokens?.display?.fontFamily &&
1703
+ mergedSignals.typography.headingFamilies[0]
1704
+ ? { fontFamily: mergedSignals.typography.headingFamilies[0] }
1705
+ : {}),
1706
+ ...(!mergedSignals.typography.tokens?.display?.fontSize &&
1707
+ mergedSignals.typography.scaleSamples[mergedSignals.typography.scaleSamples.length - 1]
1708
+ ? {
1709
+ fontSize: mergedSignals.typography.scaleSamples[mergedSignals.typography.scaleSamples.length - 1],
1710
+ }
1711
+ : {}),
1712
+ };
1713
+ const bodyToken = {
1714
+ ...(mergedSignals.typography.tokens?.body ?? {}),
1715
+ ...(!mergedSignals.typography.tokens?.body?.fontFamily &&
1716
+ mergedSignals.typography.bodyFamilies[0]
1717
+ ? { fontFamily: mergedSignals.typography.bodyFamilies[0] }
1718
+ : {}),
1719
+ ...(!mergedSignals.typography.tokens?.body?.fontSize &&
1720
+ mergedSignals.typography.scaleSamples[0]
1721
+ ? { fontSize: mergedSignals.typography.scaleSamples[0] }
1722
+ : {}),
1723
+ };
1724
+ const rounded = {
1725
+ sm: mergedSignals.rounded?.sm,
1726
+ md: mergedSignals.rounded?.md,
1727
+ lg: mergedSignals.rounded?.lg,
1728
+ };
1729
+ const spacing = {
1730
+ sm: mergedSignals.spacing?.sm,
1731
+ md: mergedSignals.spacing?.md,
1732
+ lg: mergedSignals.spacing?.lg,
1733
+ };
1734
+ const componentFrontmatterRows = [];
1735
+ for (const detail of mergedSignals.componentDetails ?? []) {
1736
+ const fields = {};
1737
+ const backgroundRef = resolveColorTokenReference(detail.backgroundColor, colors);
1738
+ if (backgroundRef)
1739
+ fields.backgroundColor = backgroundRef;
1740
+ else if (detail.backgroundColor)
1741
+ fields.backgroundColor = detail.backgroundColor;
1742
+ const textRef = resolveColorTokenReference(detail.textColor, colors);
1743
+ if (textRef)
1744
+ fields.textColor = textRef;
1745
+ else if (detail.textColor)
1746
+ fields.textColor = detail.textColor;
1747
+ const borderRef = resolveColorTokenReference(detail.borderColor, colors);
1748
+ if (borderRef)
1749
+ fields.borderColor = borderRef;
1750
+ else if (detail.borderColor)
1751
+ fields.borderColor = detail.borderColor;
1752
+ if (hasTypographyToken(bodyToken)) {
1753
+ fields.typography = '{typography.body}';
1754
+ }
1755
+ const roundedRef = resolveRoundedTokenReference(detail.borderRadius, rounded);
1756
+ if (roundedRef)
1757
+ fields.rounded = roundedRef;
1758
+ else if (detail.borderRadius)
1759
+ fields.rounded = detail.borderRadius;
1760
+ if (detail.padding)
1761
+ fields.padding = detail.padding;
1762
+ if (detail.boxShadow)
1763
+ fields.boxShadow = detail.boxShadow;
1764
+ if (Object.keys(fields).length > 0) {
1765
+ componentFrontmatterRows.push({ name: detail.name, fields });
1766
+ }
1767
+ }
1768
+ const hasComponentRefs = hasComponentTokenReference(componentFrontmatterRows.map((entry) => entry.fields));
1769
+ const docName = mergedSignals.brand.siteName ??
1770
+ mergedSignals.brand.title ??
1771
+ (sourceHosts.length > 0 ? sourceHosts[0] : 'Inspiration');
1772
+ const description = mergedSignals.brand.description ??
1773
+ (mergedSignals.brand.tone.length > 0
1774
+ ? `Visual direction leans ${mergedSignals.brand.tone.join(', ')}.`
1775
+ : 'Static inspiration extraction with limited observable evidence.');
1776
+ const lines = [];
1777
+ lines.push('---');
1778
+ lines.push(`version: ${quoteYaml('alpha')}`);
1779
+ lines.push(`name: ${quoteYaml(docName)}`);
1780
+ lines.push(`description: ${quoteYaml(description)}`);
1781
+ lines.push('colors:');
1782
+ lines.push(` primary: ${quoteYaml(asTokenString(colors.primary))}`);
1783
+ lines.push(` on-primary: ${quoteYaml(asTokenString(colors.onPrimary))}`);
1784
+ lines.push(` background: ${quoteYaml(asTokenString(colors.background))}`);
1785
+ lines.push(` surface: ${quoteYaml(asTokenString(colors.surface))}`);
1786
+ lines.push(` text: ${quoteYaml(asTokenString(colors.text))}`);
1787
+ lines.push(` muted: ${quoteYaml(asTokenString(colors.muted))}`);
1788
+ lines.push(` border: ${quoteYaml(asTokenString(colors.border))}`);
1789
+ lines.push('typography:');
1790
+ lines.push(' display:');
1791
+ lines.push(` fontFamily: ${quoteYaml(asTokenString(displayToken.fontFamily))}`);
1792
+ lines.push(` fontSize: ${quoteYaml(asTokenString(displayToken.fontSize))}`);
1793
+ lines.push(` fontWeight: ${quoteYaml(asTokenString(displayToken.fontWeight))}`);
1794
+ lines.push(` lineHeight: ${quoteYaml(asTokenString(displayToken.lineHeight))}`);
1795
+ lines.push(` letterSpacing: ${quoteYaml(asTokenString(displayToken.letterSpacing))}`);
1796
+ lines.push(' body:');
1797
+ lines.push(` fontFamily: ${quoteYaml(asTokenString(bodyToken.fontFamily))}`);
1798
+ lines.push(` fontSize: ${quoteYaml(asTokenString(bodyToken.fontSize))}`);
1799
+ lines.push(` fontWeight: ${quoteYaml(asTokenString(bodyToken.fontWeight))}`);
1800
+ lines.push(` lineHeight: ${quoteYaml(asTokenString(bodyToken.lineHeight))}`);
1801
+ lines.push(` letterSpacing: ${quoteYaml(asTokenString(bodyToken.letterSpacing))}`);
1802
+ lines.push('rounded:');
1803
+ lines.push(` sm: ${quoteYaml(asTokenString(rounded.sm))}`);
1804
+ lines.push(` md: ${quoteYaml(asTokenString(rounded.md))}`);
1805
+ lines.push(` lg: ${quoteYaml(asTokenString(rounded.lg))}`);
1806
+ lines.push('spacing:');
1807
+ lines.push(` sm: ${quoteYaml(asTokenString(spacing.sm))}`);
1808
+ lines.push(` md: ${quoteYaml(asTokenString(spacing.md))}`);
1809
+ lines.push(` lg: ${quoteYaml(asTokenString(spacing.lg))}`);
1810
+ lines.push('components:');
1811
+ if (componentFrontmatterRows.length === 0) {
1812
+ lines.push(' not-observed:');
1813
+ lines.push(` note: ${quoteYaml(NOT_OBSERVED)}`);
1814
+ }
1815
+ else {
1816
+ for (const component of componentFrontmatterRows) {
1817
+ lines.push(` ${component.name}:`);
1818
+ for (const [field, value] of Object.entries(component.fields)) {
1819
+ lines.push(` ${field}: ${quoteYaml(value)}`);
1820
+ }
1821
+ }
1822
+ }
1823
+ lines.push('---');
1824
+ lines.push('');
1825
+ lines.push('## Overview');
1826
+ if (sourceHosts.length > 0) {
1827
+ lines.push(`- Inspiration source${sourceHosts.length > 1 ? 's' : ''}: ${sourceHosts.join(', ')}.`);
1828
+ }
1829
+ else {
1830
+ lines.push(`- Inspiration source: ${NOT_OBSERVED}.`);
1831
+ }
1832
+ lines.push(`- Visual identity summary: ${description}`);
1833
+ if (input.merged && sourceHosts.length > 1) {
1834
+ lines.push('- Multiple inspirations were merged; treat conflicting cues as hypotheses to validate.');
1835
+ }
1836
+ lines.push('');
1837
+ lines.push('## Colors');
1838
+ lines.push(`- Primary token: ${asTokenString(colors.primary)}`);
1839
+ lines.push(`- Background token: ${asTokenString(colors.background)}`);
1840
+ lines.push(`- Text token: ${asTokenString(colors.text)}`);
1841
+ lines.push(...formatList(mergedSignals.colors.palette.slice(0, 8).map((color) => `Observed palette: ${color}`), `Observed palette: ${NOT_OBSERVED}`));
1842
+ if (!colors.primary) {
1843
+ lines.push('- Caveat: `colors.primary` was not observed directly; validate accent intent manually.');
1844
+ }
1845
+ lines.push('');
1846
+ lines.push('## Typography');
1847
+ lines.push(`- Display font family: ${asTokenString(displayToken.fontFamily)}`);
1848
+ lines.push(`- Body font family: ${asTokenString(bodyToken.fontFamily)}`);
1849
+ lines.push(...formatList(mergedSignals.typography.scaleSamples.slice(0, 6).map((sample) => `Observed scale: ${sample}`), `Observed scale: ${NOT_OBSERVED}`));
1850
+ lines.push(...formatList((mergedSignals.typography.googleFontFamilies ?? []).map((family) => `Google Fonts evidence: ${family}`), `Google Fonts evidence: ${NOT_OBSERVED}`));
1851
+ lines.push(...formatList((mergedSignals.typography.fontFaces ?? []).map((face) => `@font-face evidence: ${face.fontFamily}${face.fontWeight ? ` (${face.fontWeight})` : ''}`), `@font-face evidence: ${NOT_OBSERVED}`));
1852
+ lines.push('');
1853
+ lines.push('## Layout');
1854
+ lines.push(...formatList(mergedSignals.layout.sections, NOT_OBSERVED));
1855
+ lines.push(...formatList(mergedSignals.layout.structureNotes.map((note) => `Structure note: ${note}`), `Structure note: ${NOT_OBSERVED}`));
1856
+ lines.push(`- Spacing scale (sm/md/lg): ${asTokenString(spacing.sm)} / ${asTokenString(spacing.md)} / ${asTokenString(spacing.lg)}`);
1857
+ lines.push(...formatList((mergedSignals.breakpoints ?? []).map((breakpoint) => `Responsive breakpoint: ${breakpoint}`), `Responsive breakpoint: ${NOT_OBSERVED}`));
1858
+ lines.push('');
1859
+ lines.push('## Elevation & Depth');
1860
+ lines.push(...formatList((mergedSignals.shadows ?? []).map((shadow) => `Shadow token evidence: ${shadow}`), `Shadow token evidence: ${NOT_OBSERVED}`));
1861
+ lines.push(...formatList((mergedSignals.transitions ?? []).map((transition) => `Transition token evidence: ${transition}`), `Transition token evidence: ${NOT_OBSERVED}`));
1862
+ lines.push('');
1863
+ lines.push('## Shapes');
1864
+ lines.push(`- Rounded scale (sm/md/lg): ${asTokenString(rounded.sm)} / ${asTokenString(rounded.md)} / ${asTokenString(rounded.lg)}`);
1865
+ if (!rounded.sm && !rounded.md && !rounded.lg) {
1866
+ lines.push('- Caveat: Rounded token evidence is not observed; shape direction is uncertain.');
1867
+ }
1868
+ lines.push('');
1869
+ lines.push('## Components');
1870
+ if (componentFrontmatterRows.length === 0) {
1871
+ lines.push(`- ${NOT_OBSERVED}`);
1872
+ }
1873
+ else {
1874
+ for (const component of componentFrontmatterRows) {
1875
+ const summary = Object.entries(component.fields)
1876
+ .map(([field, value]) => `${field}=${value}`)
1877
+ .join(', ');
1878
+ lines.push(`- ${component.name}: ${summary}`);
1879
+ }
1880
+ }
1881
+ if (!hasComponentRefs) {
1882
+ lines.push('- Caveat: Component token references are thin; map component styles back to token groups before implementation.');
1883
+ }
1884
+ lines.push('');
1885
+ lines.push("## Do's and Don'ts");
1886
+ lines.push('- Do keep implementations aligned with observed tokens and section evidence.');
1887
+ lines.push('- Do treat any `Not observed` field as an explicit validation gap before shipping.');
1888
+ lines.push("- Don't copy source copy, proprietary assets, or assume exact values where evidence is missing.");
1889
+ lines.push("- Don't invent responsive, motion, or elevation behavior when those sections are `Not observed`.");
1890
+ return `${lines.join('\n')}\n`;
1891
+ };
1892
+ exports.buildInspirationDesignContextMarkdown = buildInspirationDesignContextMarkdown;
1893
+ const mergeSignals = (signals) => {
1894
+ if (signals.length === 1)
1895
+ return signals[0];
1896
+ const merge = (selector) => dedupe(signals.flatMap(selector));
1897
+ const mergeTypographyToken = (selector) => {
1898
+ const mergedToken = {};
1899
+ for (const signal of signals) {
1900
+ const token = selector(signal);
1901
+ if (!token)
1902
+ continue;
1903
+ if (!mergedToken.fontFamily && token.fontFamily) {
1904
+ mergedToken.fontFamily = token.fontFamily;
1905
+ }
1906
+ if (!mergedToken.fontSize && token.fontSize) {
1907
+ mergedToken.fontSize = token.fontSize;
1908
+ }
1909
+ if (!mergedToken.fontWeight && token.fontWeight) {
1910
+ mergedToken.fontWeight = token.fontWeight;
1911
+ }
1912
+ if (!mergedToken.lineHeight && token.lineHeight) {
1913
+ mergedToken.lineHeight = token.lineHeight;
1914
+ }
1915
+ if (!mergedToken.letterSpacing && token.letterSpacing) {
1916
+ mergedToken.letterSpacing = token.letterSpacing;
1917
+ }
1918
+ if (!mergedToken.fontVariationSettings && token.fontVariationSettings) {
1919
+ mergedToken.fontVariationSettings = token.fontVariationSettings;
1920
+ }
1921
+ }
1922
+ return hasTypographyToken(mergedToken) ? mergedToken : undefined;
1923
+ };
1924
+ const mergeColorRoles = () => {
1925
+ const roles = {};
1926
+ for (const signal of signals) {
1927
+ const next = signal.colors.roles;
1928
+ if (!next)
1929
+ continue;
1930
+ if (!roles.primary && next.primary)
1931
+ roles.primary = next.primary;
1932
+ if (!roles.onPrimary && next.onPrimary)
1933
+ roles.onPrimary = next.onPrimary;
1934
+ if (!roles.background && next.background)
1935
+ roles.background = next.background;
1936
+ if (!roles.surface && next.surface)
1937
+ roles.surface = next.surface;
1938
+ if (!roles.text && next.text)
1939
+ roles.text = next.text;
1940
+ if (!roles.muted && next.muted)
1941
+ roles.muted = next.muted;
1942
+ if (!roles.border && next.border)
1943
+ roles.border = next.border;
1944
+ }
1945
+ return Object.values(roles).filter(Boolean).length > 0 ? roles : undefined;
1946
+ };
1947
+ const mergeComponentDetails = () => {
1948
+ const byName = new Map();
1949
+ for (const signal of signals) {
1950
+ for (const detail of signal.componentDetails ?? []) {
1951
+ const existing = byName.get(detail.name) ?? { name: detail.name };
1952
+ if (!existing.backgroundColor && detail.backgroundColor) {
1953
+ existing.backgroundColor = detail.backgroundColor;
1954
+ }
1955
+ if (!existing.textColor && detail.textColor) {
1956
+ existing.textColor = detail.textColor;
1957
+ }
1958
+ if (!existing.borderColor && detail.borderColor) {
1959
+ existing.borderColor = detail.borderColor;
1960
+ }
1961
+ if (!existing.borderRadius && detail.borderRadius) {
1962
+ existing.borderRadius = detail.borderRadius;
1963
+ }
1964
+ if (!existing.padding && detail.padding)
1965
+ existing.padding = detail.padding;
1966
+ if (!existing.boxShadow && detail.boxShadow) {
1967
+ existing.boxShadow = detail.boxShadow;
1968
+ }
1969
+ byName.set(detail.name, existing);
1970
+ }
1971
+ }
1972
+ return Array.from(byName.values()).slice(0, MAX_COMPONENT_DETAILS);
1973
+ };
1974
+ const mergeDimensionScale = (selector) => {
1975
+ const observed = dedupe(signals.flatMap((signal) => selector(signal)?.observed ?? []));
1976
+ if (observed.length === 0)
1977
+ return undefined;
1978
+ return bucketDimensions(observed);
1979
+ };
1980
+ const mergedTypographyDisplay = mergeTypographyToken((signal) => signal.typography.tokens?.display);
1981
+ const mergedTypographyBody = mergeTypographyToken((signal) => signal.typography.tokens?.body);
1982
+ const mergedColorRoles = mergeColorRoles();
1983
+ const mergedComponentDetails = mergeComponentDetails();
1984
+ const mergedSpacing = mergeDimensionScale((signal) => signal.spacing);
1985
+ const mergedRounded = mergeDimensionScale((signal) => signal.rounded);
1986
+ const mergedFontFaces = signals
1987
+ .flatMap((signal) => signal.typography.fontFaces ?? [])
1988
+ .slice(0, MAX_FONT_FACE_EVIDENCE);
1989
+ return {
1990
+ brand: {
1991
+ title: signals.find((s) => s.brand.title)?.brand.title,
1992
+ description: signals.find((s) => s.brand.description)?.brand.description,
1993
+ siteName: signals.find((s) => s.brand.siteName)?.brand.siteName,
1994
+ tone: merge((s) => s.brand.tone),
1995
+ },
1996
+ typography: {
1997
+ headingFamilies: merge((s) => s.typography.headingFamilies).slice(0, MAX_TYPOGRAPHY_FAMILIES),
1998
+ bodyFamilies: merge((s) => s.typography.bodyFamilies).slice(0, MAX_TYPOGRAPHY_FAMILIES),
1999
+ weights: merge((s) => s.typography.weights).slice(0, MAX_TYPOGRAPHY_FAMILIES),
2000
+ scaleSamples: merge((s) => s.typography.scaleSamples).slice(0, MAX_TYPOGRAPHY_FAMILIES),
2001
+ ...(merge((s) => s.typography.googleFontFamilies ?? []).length > 0
2002
+ ? {
2003
+ googleFontFamilies: merge((s) => s.typography.googleFontFamilies ?? []).slice(0, MAX_GOOGLE_FONT_FAMILIES),
2004
+ }
2005
+ : {}),
2006
+ ...(mergedFontFaces.length > 0
2007
+ ? {
2008
+ fontFaces: mergedFontFaces,
2009
+ }
2010
+ : {}),
2011
+ ...(mergedTypographyDisplay || mergedTypographyBody
2012
+ ? {
2013
+ tokens: {
2014
+ ...(mergedTypographyDisplay ? { display: mergedTypographyDisplay } : {}),
2015
+ ...(mergedTypographyBody ? { body: mergedTypographyBody } : {}),
2016
+ },
2017
+ }
2018
+ : {}),
2019
+ },
2020
+ colors: {
2021
+ palette: merge((s) => s.colors.palette).slice(0, MAX_PALETTE_COLORS),
2022
+ themeColor: signals.find((s) => s.colors.themeColor)?.colors.themeColor,
2023
+ accents: merge((s) => s.colors.accents).slice(0, 4),
2024
+ ...(mergedColorRoles ? { roles: mergedColorRoles } : {}),
2025
+ },
2026
+ layout: {
2027
+ sections: merge((s) => s.layout.sections).slice(0, MAX_LIST_ITEMS_PER_SECTION),
2028
+ structureNotes: merge((s) => s.layout.structureNotes).slice(0, MAX_LIST_ITEMS_PER_SECTION),
2029
+ },
2030
+ components: merge((s) => s.components).slice(0, MAX_LIST_ITEMS_PER_SECTION),
2031
+ motionCues: merge((s) => s.motionCues).slice(0, MAX_LIST_ITEMS_PER_SECTION),
2032
+ iconography: merge((s) => s.iconography).slice(0, MAX_LIST_ITEMS_PER_SECTION),
2033
+ ...(mergedSpacing ? { spacing: mergedSpacing } : {}),
2034
+ ...(mergedRounded ? { rounded: mergedRounded } : {}),
2035
+ ...(merge((s) => s.shadows ?? []).length > 0
2036
+ ? { shadows: merge((s) => s.shadows ?? []).slice(0, MAX_SHADOWS) }
2037
+ : {}),
2038
+ ...(merge((s) => s.transitions ?? []).length > 0
2039
+ ? { transitions: merge((s) => s.transitions ?? []).slice(0, MAX_TRANSITIONS) }
2040
+ : {}),
2041
+ ...(merge((s) => s.breakpoints ?? []).length > 0
2042
+ ? { breakpoints: merge((s) => s.breakpoints ?? []).slice(0, MAX_BREAKPOINTS) }
2043
+ : {}),
2044
+ ...(mergedComponentDetails.length > 0
2045
+ ? { componentDetails: mergedComponentDetails }
2046
+ : {}),
2047
+ signalDensity: Math.max(...signals.map((s) => s.signalDensity)),
2048
+ };
2049
+ };
2050
+ const LOW_SIGNAL_THRESHOLD = 18;
2051
+ const truncateLabel = (label) => {
2052
+ if (label.length <= MAX_DESIGN_CONTEXT_LABEL_LENGTH)
2053
+ return label;
2054
+ return label.slice(0, MAX_DESIGN_CONTEXT_LABEL_LENGTH);
2055
+ };
2056
+ const SOURCE_ROLE_LABELS = {
2057
+ primary: 'Primary',
2058
+ inspiration: 'Reference',
2059
+ competitor: 'Reference',
2060
+ };
2061
+ const formatSourceRoleLabel = (source) => {
2062
+ const sourceLabel = source.label ?? formatHostForLabel(source.url);
2063
+ if (!source.role)
2064
+ return sourceLabel;
2065
+ return `${SOURCE_ROLE_LABELS[source.role]}: ${sourceLabel}`;
2066
+ };
2067
+ const buildLabel = (sources, merged, index) => {
2068
+ const labels = sources.map(formatSourceRoleLabel);
2069
+ if (labels.length === 0)
2070
+ return DESIGN_CONTEXT_LABEL_DEFAULT;
2071
+ if (merged) {
2072
+ return truncateLabel(labels.join(' · '));
2073
+ }
2074
+ if (typeof index === 'number') {
2075
+ return truncateLabel(`Inspiration ${index + 1}: ${labels[0]}`);
2076
+ }
2077
+ return truncateLabel(`Inspiration: ${labels[0]}`);
2078
+ };
2079
+ const emptyCacheCounters = (forcedRefresh) => ({
2080
+ hitCount: 0,
2081
+ missCount: 0,
2082
+ staleCount: 0,
2083
+ versionMismatchCount: 0,
2084
+ lowConfidenceCount: 0,
2085
+ regenerationCount: 0,
2086
+ forcedRefresh,
2087
+ });
2088
+ /**
2089
+ * Build a single-source markdown for a link that came from the cache so the
2090
+ * separate-strategy path can reuse cached extractions without re-running
2091
+ * the regex pipeline. The cached entry already encodes the per-URL signal
2092
+ * extraction; this rebuilds the markdown with the slot's `Inspiration N:`
2093
+ * label using the cached signals.
2094
+ */
2095
+ const rebuildSingleSourceMarkdown = (signals, link) => (0, exports.buildInspirationDesignContextMarkdown)({
2096
+ signals: [signals],
2097
+ sources: [{ url: link.url, label: link.label }],
2098
+ merged: false,
2099
+ });
2100
+ const pushCacheReuseWarning = (warnings, link, ageMs, confidence) => {
2101
+ warnings.push(`Reusing cached design context for ${formatHostForLabel(link.url)} (age ${(0, designContextStore_1.ageBucketFor)(ageMs)}, confidence ${(0, designContextStore_1.confidenceBucketFor)(confidence)}). Ask for "fresh" or "latest" to regenerate.`);
2102
+ };
2103
+ const extractInspirationDesignContext = async (input) => {
2104
+ const forceRefresh = Boolean(input.forceRefresh);
2105
+ if (input.slotCount <= 0) {
2106
+ return {
2107
+ entries: [],
2108
+ warnings: [],
2109
+ telemetry: {
2110
+ inspirationLinkCount: 0,
2111
+ referenceLinkCount: 0,
2112
+ successfulExtractions: 0,
2113
+ lowSignalCount: 0,
2114
+ fetchFailureCount: 0,
2115
+ blockedTargetCount: 0,
2116
+ mergeStrategy: 'none',
2117
+ cache: emptyCacheCounters(forceRefresh),
2118
+ },
2119
+ };
2120
+ }
2121
+ const classification = (0, exports.classifyInspirationLinks)({
2122
+ urls: input.urls,
2123
+ prompt: input.prompt,
2124
+ sourceContext: input.sourceContext,
2125
+ });
2126
+ if (classification.inspiration.length === 0) {
2127
+ return {
2128
+ entries: new Array(input.slotCount).fill(undefined),
2129
+ warnings: [],
2130
+ telemetry: {
2131
+ inspirationLinkCount: 0,
2132
+ referenceLinkCount: classification.reference.length,
2133
+ successfulExtractions: 0,
2134
+ lowSignalCount: 0,
2135
+ fetchFailureCount: 0,
2136
+ blockedTargetCount: 0,
2137
+ mergeStrategy: 'none',
2138
+ cache: emptyCacheCounters(forceRefresh),
2139
+ },
2140
+ };
2141
+ }
2142
+ const fetcher = input.fetcher ?? exports.defaultInspirationFetcher;
2143
+ const cssFetcher = input.cssFetcher ?? exports.defaultLinkedCssFetcher;
2144
+ const extractor = input.signalExtractor ?? exports.extractDesignSignals;
2145
+ const store = input.store;
2146
+ const now = input.now ?? Date.now;
2147
+ const schemaVersion = input.schemaVersion ?? designContextStore_1.DESIGN_CONTEXT_SCHEMA_VERSION;
2148
+ const warnings = [];
2149
+ let fetchFailureCount = 0;
2150
+ let blockedTargetCount = 0;
2151
+ let lowSignalCount = 0;
2152
+ const cacheCounters = emptyCacheCounters(forceRefresh);
2153
+ // Track the most recent reuse age + confidence so callers can surface a
2154
+ // single coarse summary in user-facing output.
2155
+ let lastReuseMetadata;
2156
+ /**
2157
+ * Resolve a single inspiration link: prefer a compatible cache entry,
2158
+ * otherwise fetch + extract and write the cache. Returns `undefined`
2159
+ * signals when the source ultimately cannot produce usable design
2160
+ * context so the caller can drop it from successful slots.
2161
+ */
2162
+ const resolveLink = async (link) => {
2163
+ const cacheKey = (0, designContextStore_1.cacheKeyForUrl)(link.url);
2164
+ // D9: When the caller supplies a `sourceContext.artifact` it represents
2165
+ // explicit, intentional research that may contradict or augment a
2166
+ // cached extraction. Bypassing cache reads (writes still happen) lets
2167
+ // the override take precedence without erasing the cache for other
2168
+ // callers. The presence of `artifact` is sufficient signal — we don't
2169
+ // diff its contents against cached signals.
2170
+ const artifactOverride = Boolean(input.sourceContext?.artifact);
2171
+ if (store && cacheKey && !artifactOverride) {
2172
+ let cached;
2173
+ try {
2174
+ cached = await store.get(cacheKey);
2175
+ }
2176
+ catch (error) {
2177
+ log.debug(`Cache read failed for inspiration link: ${String(error)}`);
2178
+ cached = undefined;
2179
+ }
2180
+ if (cached) {
2181
+ const decision = (0, designContextStore_1.canReuseCacheEntry)(cached, {
2182
+ forceRefresh,
2183
+ schemaVersion,
2184
+ confidenceThreshold: LOW_SIGNAL_THRESHOLD,
2185
+ freshnessMs: input.freshnessMs,
2186
+ now: now(),
2187
+ });
2188
+ if (decision.reuse) {
2189
+ cacheCounters.hitCount += 1;
2190
+ pushCacheReuseWarning(warnings, link, decision.ageMs, cached.metadata.confidence);
2191
+ lastReuseMetadata = {
2192
+ ageBucket: (0, designContextStore_1.ageBucketFor)(decision.ageMs),
2193
+ confidenceBucket: (0, designContextStore_1.confidenceBucketFor)(cached.metadata.confidence),
2194
+ freshness: decision.freshness,
2195
+ };
2196
+ const refreshedEntry = {
2197
+ ...cached,
2198
+ metadata: {
2199
+ ...cached.metadata,
2200
+ lastUsedAtMs: now(),
2201
+ },
2202
+ };
2203
+ try {
2204
+ await store.put(refreshedEntry);
2205
+ }
2206
+ catch (error) {
2207
+ log.debug(`Cache lastUsedAt refresh failed (continuing): ${String(error)}`);
2208
+ }
2209
+ return {
2210
+ link,
2211
+ signals: cached.signals,
2212
+ source: 'cache',
2213
+ ageMs: decision.ageMs,
2214
+ confidence: cached.metadata.confidence,
2215
+ };
2216
+ }
2217
+ switch (decision.reason) {
2218
+ case 'stale':
2219
+ cacheCounters.staleCount += 1;
2220
+ cacheCounters.regenerationCount += 1;
2221
+ warnings.push(`Cached design context for ${formatHostForLabel(link.url)} is stale (age ${(0, designContextStore_1.ageBucketFor)(decision.ageMs)}, confidence ${(0, designContextStore_1.confidenceBucketFor)(cached.metadata.confidence)}); regenerating.`);
2222
+ break;
2223
+ case 'version_mismatch':
2224
+ cacheCounters.versionMismatchCount += 1;
2225
+ cacheCounters.regenerationCount += 1;
2226
+ break;
2227
+ case 'low_confidence':
2228
+ cacheCounters.lowConfidenceCount += 1;
2229
+ cacheCounters.regenerationCount += 1;
2230
+ break;
2231
+ case 'forced_refresh':
2232
+ cacheCounters.regenerationCount += 1;
2233
+ break;
2234
+ case 'corrupt':
2235
+ cacheCounters.regenerationCount += 1;
2236
+ break;
2237
+ }
2238
+ }
2239
+ else {
2240
+ cacheCounters.missCount += 1;
2241
+ }
2242
+ }
2243
+ const result = await fetcher(link.url);
2244
+ if (!result.ok) {
2245
+ // Match both DNS-resolved protected-target reasons and literal-hostname
2246
+ // blocks produced by `validateInspirationUrl` (e.g. localhost, .local,
2247
+ // private IP literals). Without the second alternative, literal-hostname
2248
+ // rejections were miscounted as generic fetch failures.
2249
+ if (/protected target|private-network target/i.test(result.reason)) {
2250
+ blockedTargetCount += 1;
2251
+ }
2252
+ else {
2253
+ fetchFailureCount += 1;
2254
+ }
2255
+ warnings.push(`Could not extract design context from ${formatHostForLabel(link.url)}: ${result.reason}`);
2256
+ return undefined;
2257
+ }
2258
+ // Bounded linked-stylesheet ingestion. The fetched CSS is ephemeral —
2259
+ // we pass it to the signal extractor and discard it. Only derived
2260
+ // signals (palette, role assignments, token scales, etc.) survive into
2261
+ // the cache or telemetry.
2262
+ const linkedCss = await (0, exports.fetchLinkedStylesheets)(result.html, result.finalUrl, cssFetcher);
2263
+ if (linkedCss.skippedReasons.includes('total_budget_exceeded')) {
2264
+ warnings.push(`Linked stylesheet ingestion for ${formatHostForLabel(link.url)} hit the total byte budget; some external stylesheets were skipped.`);
2265
+ }
2266
+ else if (linkedCss.attemptedCount > 0 &&
2267
+ linkedCss.fetchedCount === 0) {
2268
+ warnings.push(`Could not load any linked stylesheets for ${formatHostForLabel(link.url)}; using inline styles only.`);
2269
+ }
2270
+ const signals = extractor(result.html, {
2271
+ sourceUrl: result.finalUrl,
2272
+ extraCss: linkedCss.css ? linkedCss.css : undefined,
2273
+ });
2274
+ if (signals.signalDensity < LOW_SIGNAL_THRESHOLD) {
2275
+ lowSignalCount += 1;
2276
+ warnings.push(`${formatHostForLabel(link.url)} produced low design signal; consider providing more prompt direction.`);
2277
+ return undefined;
2278
+ }
2279
+ if (store && cacheKey) {
2280
+ const markdown = rebuildSingleSourceMarkdown(signals, link);
2281
+ const label = buildLabel([
2282
+ {
2283
+ url: link.url,
2284
+ label: link.label,
2285
+ role: findRoleForUrl(link.url, input.sourceContext),
2286
+ },
2287
+ ], false);
2288
+ const entry = {
2289
+ metadata: {
2290
+ cacheKey,
2291
+ schemaVersion,
2292
+ createdAtMs: now(),
2293
+ lastUsedAtMs: now(),
2294
+ confidence: signals.signalDensity,
2295
+ warningCodes: [],
2296
+ },
2297
+ markdown,
2298
+ label,
2299
+ signals,
2300
+ };
2301
+ try {
2302
+ await store.put(entry);
2303
+ }
2304
+ catch (error) {
2305
+ log.debug(`Cache write failed (continuing): ${String(error)}`);
2306
+ }
2307
+ }
2308
+ return { link, signals, source: 'fresh', confidence: signals.signalDensity };
2309
+ };
2310
+ const resolved = await Promise.all(classification.inspiration.map((link) => resolveLink(link)));
2311
+ const successful = resolved.filter((item) => item !== undefined);
2312
+ if (successful.length === 0) {
2313
+ const allFetchesFailed = classification.inspiration.length > 0 &&
2314
+ fetchFailureCount === classification.inspiration.length;
2315
+ return {
2316
+ entries: new Array(input.slotCount).fill(undefined),
2317
+ warnings,
2318
+ telemetry: {
2319
+ inspirationLinkCount: classification.inspiration.length,
2320
+ referenceLinkCount: classification.reference.length,
2321
+ successfulExtractions: 0,
2322
+ lowSignalCount,
2323
+ fetchFailureCount,
2324
+ blockedTargetCount,
2325
+ mergeStrategy: classification.mergeStrategy,
2326
+ cache: cacheCounters,
2327
+ },
2328
+ ...(lastReuseMetadata ? { cacheReuse: lastReuseMetadata } : {}),
2329
+ ...(allFetchesFailed
2330
+ ? {
2331
+ errorCode: 'INSPIRATION_FETCH_ALL_FAILED',
2332
+ errorMessage: 'All supplied inspiration URLs failed to fetch (DNS, timeout, or non-2xx). ' +
2333
+ 'Consider passing a local asset via `assetUrls` (e.g. a screenshot or sketch) ' +
2334
+ 'so generation can still ground against a visual reference.',
2335
+ }
2336
+ : {}),
2337
+ };
2338
+ }
2339
+ if (classification.mergeStrategy === 'merge') {
2340
+ const markdown = (0, exports.buildInspirationDesignContextMarkdown)({
2341
+ signals: successful.map((item) => item.signals),
2342
+ sources: successful.map((item) => ({
2343
+ url: item.link.url,
2344
+ label: item.link.label,
2345
+ })),
2346
+ merged: true,
2347
+ });
2348
+ // Dedupe by URL: a link can appear in both `classification.reference`
2349
+ // (explicitly tagged) and `successful` (extracted via inspiration
2350
+ // pipeline). Without dedupe the merged label would list the same URL
2351
+ // twice. Keep the first occurrence (reference) so the role label wins.
2352
+ const mergedSources = [];
2353
+ const seenUrls = new Set();
2354
+ for (const link of classification.reference) {
2355
+ if (seenUrls.has(link.url))
2356
+ continue;
2357
+ seenUrls.add(link.url);
2358
+ mergedSources.push({
2359
+ url: link.url,
2360
+ label: link.label,
2361
+ role: findRoleForUrl(link.url, input.sourceContext),
2362
+ });
2363
+ }
2364
+ for (const item of successful) {
2365
+ if (seenUrls.has(item.link.url))
2366
+ continue;
2367
+ seenUrls.add(item.link.url);
2368
+ mergedSources.push({
2369
+ url: item.link.url,
2370
+ label: item.link.label,
2371
+ role: findRoleForUrl(item.link.url, input.sourceContext),
2372
+ });
2373
+ }
2374
+ const label = buildLabel(mergedSources, true);
2375
+ // Use a factory so each slot gets its own object instance; sharing one
2376
+ // reference via `fill()` would let any downstream mutation leak across
2377
+ // every slot.
2378
+ const entries = Array.from({ length: input.slotCount }, () => ({
2379
+ kind: 'markdown',
2380
+ label,
2381
+ content: markdown,
2382
+ }));
2383
+ return {
2384
+ entries,
2385
+ warnings,
2386
+ telemetry: {
2387
+ inspirationLinkCount: classification.inspiration.length,
2388
+ referenceLinkCount: classification.reference.length,
2389
+ successfulExtractions: successful.length,
2390
+ lowSignalCount,
2391
+ fetchFailureCount,
2392
+ blockedTargetCount,
2393
+ mergeStrategy: 'merge',
2394
+ cache: cacheCounters,
2395
+ },
2396
+ ...(lastReuseMetadata ? { cacheReuse: lastReuseMetadata } : {}),
2397
+ };
2398
+ }
2399
+ const entries = new Array(input.slotCount).fill(undefined);
2400
+ for (let i = 0; i < input.slotCount; i++) {
2401
+ const item = successful[i % successful.length];
2402
+ const markdown = (0, exports.buildInspirationDesignContextMarkdown)({
2403
+ signals: [item.signals],
2404
+ sources: [{ url: item.link.url, label: item.link.label }],
2405
+ merged: false,
2406
+ });
2407
+ entries[i] = {
2408
+ kind: 'markdown',
2409
+ label: buildLabel([
2410
+ {
2411
+ url: item.link.url,
2412
+ label: item.link.label,
2413
+ role: findRoleForUrl(item.link.url, input.sourceContext),
2414
+ },
2415
+ ], false, i),
2416
+ content: markdown,
2417
+ };
2418
+ }
2419
+ return {
2420
+ entries,
2421
+ warnings,
2422
+ telemetry: {
2423
+ inspirationLinkCount: classification.inspiration.length,
2424
+ referenceLinkCount: classification.reference.length,
2425
+ successfulExtractions: successful.length,
2426
+ lowSignalCount,
2427
+ fetchFailureCount,
2428
+ blockedTargetCount,
2429
+ mergeStrategy: 'separate',
2430
+ cache: cacheCounters,
2431
+ },
2432
+ ...(lastReuseMetadata ? { cacheReuse: lastReuseMetadata } : {}),
2433
+ };
2434
+ };
2435
+ exports.extractInspirationDesignContext = extractInspirationDesignContext;
2436
+ /**
2437
+ * Internal debug helper for ad-hoc engineering verification. Not registered
2438
+ * as an MCP tool, not exported from `index.ts`, and not advertised as a
2439
+ * product surface. Routes through the same validation, fetch, and markdown
2440
+ * generation as the MCP pipeline so debug output cannot drift from product
2441
+ * output.
2442
+ */
2443
+ const standaloneDebugExtract = async (url, fetcher = exports.defaultInspirationFetcher) => {
2444
+ log.debug(`standaloneDebugExtract: ${url}`);
2445
+ const outcome = await (0, exports.extractInspirationDesignContext)({
2446
+ urls: [url],
2447
+ prompt: url,
2448
+ slotCount: 1,
2449
+ fetcher,
2450
+ });
2451
+ const entry = outcome.entries[0];
2452
+ if (!entry || entry.kind !== 'markdown') {
2453
+ return {
2454
+ url,
2455
+ ok: false,
2456
+ warnings: outcome.warnings,
2457
+ };
2458
+ }
2459
+ return {
2460
+ url,
2461
+ ok: true,
2462
+ warnings: outcome.warnings,
2463
+ markdown: entry.content,
2464
+ };
2465
+ };
2466
+ exports.standaloneDebugExtract = standaloneDebugExtract;
2467
+ //# sourceMappingURL=inspirationDesignContext.js.map