brandpull 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,382 @@
1
+ import { contrastYiq, hexify, isGrayish, isVibrant } from "./colors"
2
+ import { selectLogoWithConfidence } from "./logo"
3
+ import type { BrandingProfile, ButtonSnapshot, InputSnapshot, RawBrandingData, StyleSnapshot } from "./types"
4
+
5
+ function inferPalette(raw: RawBrandingData) {
6
+ const freq = new Map<string, number>()
7
+ const bump = (color: string | null, weight = 1) => {
8
+ if (!color) return
9
+ freq.set(color, (freq.get(color) ?? 0) + weight)
10
+ }
11
+
12
+ if (raw.pageBackground) bump(hexify(raw.pageBackground), 1000)
13
+ for (const snapshot of raw.snapshots) {
14
+ const area = Math.max(1, snapshot.rect.w * snapshot.rect.h)
15
+ bump(hexify(snapshot.colors.background, raw.pageBackground), 0.5 + Math.log10(area + 10))
16
+ bump(hexify(snapshot.colors.text, raw.pageBackground), 1)
17
+ bump(hexify(snapshot.colors.border, raw.pageBackground), 0.3)
18
+ }
19
+ for (const color of raw.cssData.colors) bump(hexify(color, raw.pageBackground), 0.5)
20
+
21
+ const ranked = Array.from(freq.entries())
22
+ .sort((a, b) => b[1] - a[1])
23
+ .map(([color]) => color)
24
+
25
+ let background = "#FFFFFF"
26
+ const pageBackground = raw.pageBackground ? hexify(raw.pageBackground) : null
27
+ if (pageBackground && isGrayish(pageBackground)) background = pageBackground
28
+
29
+ if (background === "#FFFFFF" || (!raw.pageBackground && ranked.length > 0)) {
30
+ if (raw.colorScheme === "dark") {
31
+ background =
32
+ ranked.find((h) => isGrayish(h) && contrastYiq(h) < 128 && contrastYiq(h) > 0) ||
33
+ ranked.find((h) => isGrayish(h) && contrastYiq(h) < 180) ||
34
+ "#1A1A1A"
35
+ } else {
36
+ background = ranked.find((h) => isGrayish(h) && contrastYiq(h) > 180) || "#FFFFFF"
37
+ }
38
+ }
39
+
40
+ const textPrimary =
41
+ raw.colorScheme === "dark"
42
+ ? ranked.find((h) => h !== background && contrastYiq(h) > 180) || "#FFFFFF"
43
+ : ranked.find((h) => h.toUpperCase() !== "#FFFFFF" && contrastYiq(h) < 160) || "#111111"
44
+ const chromatic = ranked.filter((h) => isVibrant(h) && h !== background && h !== textPrimary)
45
+ const primary =
46
+ chromatic[0] ||
47
+ ranked.find((h) => !isGrayish(h) && h !== textPrimary && h !== background) ||
48
+ (raw.colorScheme === "dark" ? "#FFFFFF" : "#000000")
49
+ const accent = chromatic.find((h) => h !== primary) || primary
50
+ const secondary = chromatic.find((h) => h !== primary && h !== accent)
51
+ const textSecondary =
52
+ raw.colorScheme === "dark"
53
+ ? ranked.find((h) => h !== textPrimary && contrastYiq(h) > 140 && isGrayish(h))
54
+ : ranked.find((h) => h !== textPrimary && contrastYiq(h) < 180 && isGrayish(h))
55
+
56
+ return {
57
+ primary,
58
+ secondary,
59
+ accent,
60
+ background,
61
+ textPrimary,
62
+ textSecondary,
63
+ link: accent,
64
+ }
65
+ }
66
+
67
+ function inferBaseUnit(values: number[]): number {
68
+ const normalized = values.filter((v) => Number.isFinite(v) && v > 0 && v <= 128).map((v) => Math.round(v))
69
+ if (!normalized.length) return 8
70
+ for (const candidate of [4, 6, 8, 10, 12]) {
71
+ const share =
72
+ normalized.filter((v) => v % candidate === 0 || Math.abs((v % candidate) - candidate) <= 1 || v % candidate <= 1)
73
+ .length / normalized.length
74
+ if (share >= 0.6) return candidate
75
+ }
76
+ normalized.sort((a, b) => a - b)
77
+ const median = normalized[Math.floor(normalized.length / 2)] ?? 8
78
+ return Math.max(2, Math.min(12, Math.round(median / 2) * 2))
79
+ }
80
+
81
+ function pickBorderRadius(values: Array<number | null>): string {
82
+ const radii = values.filter((v): v is number => Number.isFinite(v))
83
+ if (!radii.length) return "8px"
84
+ radii.sort((a, b) => a - b)
85
+ return `${Math.round(radii[Math.floor(radii.length / 2)] ?? 8)}px`
86
+ }
87
+
88
+ function fontsFromStacks(stacks: string[][]) {
89
+ const ignored =
90
+ /^(system-ui|sans-serif|serif|monospace|ui-sans-serif|ui-serif|arial|helvetica|times new roman|inherit|initial|unset)$/i
91
+ const freq = new Map<string, number>()
92
+ for (const stack of stacks) {
93
+ for (const font of stack) {
94
+ const cleaned = cleanFontName(font)
95
+ if (!cleaned || ignored.test(cleaned) || /^var\(/i.test(cleaned)) continue
96
+ freq.set(cleaned, (freq.get(cleaned) ?? 0) + 1)
97
+ }
98
+ }
99
+ return Array.from(freq.entries())
100
+ .sort((a, b) => b[1] - a[1])
101
+ .slice(0, 10)
102
+ .map(([family, count]) => ({ family, count }))
103
+ }
104
+
105
+ export function cleanFontName(font: string): string {
106
+ const noQuotes = font.replace(/["']/g, "").trim()
107
+ const next = noQuotes.match(/^__(.+?)(?:_Fallback)?_[a-f0-9]+$/i)
108
+ if (next?.[1]) return next[1].replace(/_/g, " ").trim()
109
+ return noQuotes
110
+ .replace(/_Fallback_[a-f0-9]+$/i, "")
111
+ .replace(/_/g, " ")
112
+ .trim()
113
+ }
114
+
115
+ function representativeBorderRadius(radius?: StyleSnapshot["borderRadius"]): string {
116
+ const max = Math.max(radius?.topLeft || 0, radius?.topRight || 0, radius?.bottomRight || 0, radius?.bottomLeft || 0)
117
+ return max > 0 ? `${max}px` : "0px"
118
+ }
119
+
120
+ function corners(radius?: StyleSnapshot["borderRadius"]) {
121
+ return {
122
+ topLeft: `${radius?.topLeft ?? 0}px`,
123
+ topRight: `${radius?.topRight ?? 0}px`,
124
+ bottomRight: `${radius?.bottomRight ?? 0}px`,
125
+ bottomLeft: `${radius?.bottomLeft ?? 0}px`,
126
+ }
127
+ }
128
+
129
+ function snapshotToButton(snapshot: StyleSnapshot, index: number, raw: RawBrandingData, score: number): ButtonSnapshot {
130
+ const borderHex =
131
+ snapshot.colors.borderWidth && snapshot.colors.borderWidth > 0
132
+ ? hexify(snapshot.colors.border, raw.pageBackground)
133
+ : null
134
+ return {
135
+ index,
136
+ text: snapshot.text || "",
137
+ classes: snapshot.classes || "",
138
+ background: hexify(snapshot.colors.background, raw.pageBackground) || "transparent",
139
+ textColor: hexify(snapshot.colors.text, raw.pageBackground) || "#000000",
140
+ borderColor: borderHex,
141
+ borderRadius: representativeBorderRadius(snapshot.borderRadius),
142
+ borderRadiusCorners: corners(snapshot.borderRadius),
143
+ shadow: snapshot.shadow,
144
+ score,
145
+ originalBackgroundColor: snapshot.colors.background || undefined,
146
+ originalTextColor: snapshot.colors.text || undefined,
147
+ originalBorderColor: snapshot.colors.border || undefined,
148
+ }
149
+ }
150
+
151
+ function extractButtons(raw: RawBrandingData): ButtonSnapshot[] {
152
+ const candidates = raw.snapshots
153
+ .filter((snapshot) => {
154
+ if (!snapshot.isButton) return false
155
+ if (snapshot.rect.w < 30 || snapshot.rect.h < 25) return false
156
+ if (!snapshot.text?.trim()) return false
157
+ const bg = hexify(snapshot.colors.background, raw.pageBackground)
158
+ const hasBorder = !!snapshot.colors.borderWidth && snapshot.colors.borderWidth > 0
159
+ return !!bg || hasBorder
160
+ })
161
+ .map((snapshot) => {
162
+ let score = 0
163
+ if (snapshot.hasCTAIndicator) score += 1000
164
+ const text = snapshot.text.toLowerCase()
165
+ if (
166
+ [
167
+ "sign up",
168
+ "get started",
169
+ "start",
170
+ "try",
171
+ "demo",
172
+ "contact",
173
+ "buy",
174
+ "subscribe",
175
+ "join",
176
+ "register",
177
+ "download",
178
+ "book",
179
+ ].some((kw) => text.includes(kw))
180
+ ) {
181
+ score += 500
182
+ }
183
+ const bg = hexify(snapshot.colors.background, raw.pageBackground)
184
+ const border =
185
+ snapshot.colors.borderWidth && snapshot.colors.borderWidth > 0
186
+ ? hexify(snapshot.colors.border, raw.pageBackground)
187
+ : null
188
+ if (bg && bg !== "#FFFFFF" && bg !== "#FAFAFA" && bg !== "#F5F5F5") score += 300
189
+ if (border && !bg) score += 200
190
+ if (text.length > 0 && text.length < 50) score += 100
191
+ score += Math.log10(snapshot.rect.w * snapshot.rect.h + 1) * 10
192
+ return { snapshot, score }
193
+ })
194
+ .sort((a, b) => b.score - a.score)
195
+
196
+ const seen = new Set<string>()
197
+ const unique: ButtonSnapshot[] = []
198
+ for (const item of candidates) {
199
+ const bg = hexify(item.snapshot.colors.background, raw.pageBackground) || "transparent"
200
+ const border =
201
+ item.snapshot.colors.borderWidth && item.snapshot.colors.borderWidth > 0
202
+ ? hexify(item.snapshot.colors.border, raw.pageBackground) || "transparent-border"
203
+ : "no-border"
204
+ const key = `${item.snapshot.text.trim().toLowerCase().slice(0, 50)}|${bg}|${border}|${item.snapshot.classes.split(/\s+/).slice(0, 5).join(" ")}`
205
+ if (seen.has(key)) continue
206
+ seen.add(key)
207
+ unique.push(snapshotToButton(item.snapshot, unique.length, raw, item.score))
208
+ }
209
+
210
+ return unique.slice(0, 80)
211
+ }
212
+
213
+ function classifyButtons(buttons: ButtonSnapshot[]) {
214
+ if (!buttons.length) return { primary: undefined, secondary: undefined, confidence: 0 }
215
+ const scored = buttons
216
+ .map((button) => {
217
+ let score = button.score ?? 0
218
+ if (isVibrant(button.background)) score += 400
219
+ if (/primary|brand|cta|accent|bg-brand|bg-primary/i.test(button.classes)) score += 250
220
+ if (/get started|sign up|start|try|buy|book|demo|download|join/i.test(button.text)) score += 200
221
+ if (button.background === "transparent" || button.background === "#FFFFFF") score -= 80
222
+ return { button, score }
223
+ })
224
+ .sort((a, b) => b.score - a.score)
225
+ const primary = scored[0]?.button
226
+ const secondary = scored.find(
227
+ (item) => item.button.index !== primary?.index && item.button.background !== primary?.background,
228
+ )?.button
229
+ return { primary, secondary, confidence: primary ? 0.65 : 0 }
230
+ }
231
+
232
+ function extractInputs(raw: RawBrandingData): InputSnapshot[] {
233
+ const candidates = raw.snapshots
234
+ .filter((snapshot) => snapshot.isInput && snapshot.inputMetadata && snapshot.rect.w >= 50 && snapshot.rect.h >= 20)
235
+ .map((snapshot) => {
236
+ const meta = snapshot.inputMetadata!
237
+ let score = 0
238
+ if (meta.type === "email") score += 100
239
+ else if (meta.type === "text") score += 80
240
+ else if (meta.type === "password") score += 70
241
+ else if (meta.type === "search") score += 60
242
+ if (meta.required) score += 50
243
+ if (meta.placeholder) score += 30
244
+ if (meta.label) score += 40
245
+ const words = `${meta.placeholder} ${meta.label} ${meta.name}`.toLowerCase()
246
+ if (words.includes("email")) score += 80
247
+ if (words.includes("search")) score += 60
248
+ return { snapshot, score }
249
+ })
250
+ .sort((a, b) => b.score - a.score)
251
+
252
+ return candidates.slice(0, 20).map(({ snapshot }) => {
253
+ const meta = snapshot.inputMetadata!
254
+ const border =
255
+ snapshot.colors.borderWidth && snapshot.colors.borderWidth > 0
256
+ ? hexify(snapshot.colors.border, raw.pageBackground)
257
+ : null
258
+ return {
259
+ type: meta.type,
260
+ placeholder: meta.placeholder,
261
+ label: meta.label,
262
+ name: meta.name,
263
+ required: meta.required,
264
+ classes: snapshot.classes,
265
+ background: hexify(snapshot.colors.background, raw.pageBackground) || "transparent",
266
+ textColor: hexify(snapshot.colors.text, raw.pageBackground),
267
+ borderColor: border,
268
+ borderRadius: representativeBorderRadius(snapshot.borderRadius),
269
+ borderRadiusCorners: corners(snapshot.borderRadius),
270
+ shadow: snapshot.shadow,
271
+ }
272
+ })
273
+ }
274
+
275
+ function pickLogo(raw: RawBrandingData, profile: BrandingProfile) {
276
+ const heuristic = selectLogoWithConfidence(raw.logoCandidates, raw.brandName)
277
+ if (heuristic.selectedIndex >= 0) {
278
+ const selected = raw.logoCandidates[heuristic.selectedIndex]
279
+ if (selected) {
280
+ profile.logo = selected.src
281
+ profile.images = {
282
+ ...profile.images,
283
+ logo: selected.src,
284
+ logoHref: selected.href ?? null,
285
+ logoAlt: selected.alt || null,
286
+ }
287
+ }
288
+ }
289
+ profile.diagnostics = {
290
+ ...profile.diagnostics,
291
+ logo: {
292
+ source: heuristic.source,
293
+ selectedIndex: heuristic.selectedIndex,
294
+ reasoning: heuristic.reasoning,
295
+ confidence: heuristic.confidence,
296
+ },
297
+ }
298
+ profile.confidence = { ...profile.confidence, logo: heuristic.confidence }
299
+ }
300
+
301
+ export function processRawBranding(
302
+ raw: RawBrandingData,
303
+ options: { debug?: boolean; url?: string } = {},
304
+ ): BrandingProfile {
305
+ const palette = inferPalette(raw)
306
+ const allFontStacks = [
307
+ raw.typography.stacks.body,
308
+ raw.typography.stacks.heading,
309
+ raw.typography.stacks.paragraph,
310
+ ...raw.snapshots.map((snapshot) => snapshot.typography.fontStack),
311
+ ]
312
+ const fonts = fontsFromStacks(allFontStacks)
313
+ const buttons = extractButtons(raw)
314
+ const inputs = extractInputs(raw)
315
+ const classified = classifyButtons(buttons)
316
+ const profile: BrandingProfile = {
317
+ url: options.url,
318
+ finalUrl: raw.pageUrl,
319
+ brandName: raw.brandName,
320
+ pageTitle: raw.pageTitle,
321
+ colorScheme: raw.colorScheme,
322
+ fonts,
323
+ colors: palette,
324
+ typography: {
325
+ fontFamilies: {
326
+ primary: cleanFontName(raw.typography.stacks.body[0] || fonts[0]?.family || "system-ui"),
327
+ heading: cleanFontName(
328
+ raw.typography.stacks.heading[0] || raw.typography.stacks.body[0] || fonts[0]?.family || "system-ui",
329
+ ),
330
+ },
331
+ fontStacks: raw.typography.stacks,
332
+ fontSizes: raw.typography.sizes,
333
+ },
334
+ spacing: {
335
+ baseUnit: inferBaseUnit(raw.cssData.spacings),
336
+ borderRadius: pickBorderRadius([...raw.snapshots.map((snapshot) => snapshot.radius), ...raw.cssData.radii]),
337
+ },
338
+ components: {},
339
+ images: {
340
+ logo:
341
+ raw.images.find((image) => image.type === "logo")?.src ||
342
+ raw.images.find((image) => image.type === "logo-svg")?.src ||
343
+ null,
344
+ favicon: raw.images.find((image) => image.type === "favicon")?.src || null,
345
+ ogImage:
346
+ raw.images.find((image) => image.type === "og")?.src ||
347
+ raw.images.find((image) => image.type === "twitter")?.src ||
348
+ null,
349
+ },
350
+ confidence: {
351
+ colors: 0.65,
352
+ buttons: classified.confidence,
353
+ },
354
+ diagnostics: {
355
+ errors: raw.errors,
356
+ },
357
+ }
358
+
359
+ if (inputs[0]) profile.components!.input = inputs[0]
360
+ if (classified.primary) profile.components!.buttonPrimary = classified.primary
361
+ if (classified.secondary) profile.components!.buttonSecondary = classified.secondary
362
+
363
+ pickLogo(raw, profile)
364
+
365
+ profile.confidence = {
366
+ ...profile.confidence,
367
+ overall:
368
+ ((profile.confidence?.logo ?? 0) + (profile.confidence?.colors ?? 0) + (profile.confidence?.buttons ?? 0)) / 3,
369
+ }
370
+
371
+ if (options.debug) {
372
+ profile.debug = {
373
+ buttons,
374
+ inputs,
375
+ logoCandidates: raw.logoCandidates,
376
+ frameworkHints: raw.frameworkHints,
377
+ backgroundCandidates: raw.backgroundCandidates,
378
+ }
379
+ }
380
+
381
+ return profile
382
+ }
@@ -0,0 +1,279 @@
1
+ export type ColorScheme = "light" | "dark"
2
+
3
+ export interface CSSData {
4
+ colors: string[]
5
+ spacings: number[]
6
+ radii: number[]
7
+ }
8
+
9
+ export interface StyleSnapshot {
10
+ tag: string
11
+ classes: string
12
+ text: string
13
+ rect: { w: number; h: number }
14
+ colors: {
15
+ text: string
16
+ background: string
17
+ border: string
18
+ borderWidth: number | null
19
+ borderTop?: string
20
+ borderTopWidth?: number | null
21
+ borderRight?: string
22
+ borderRightWidth?: number | null
23
+ borderBottom?: string
24
+ borderBottomWidth?: number | null
25
+ borderLeft?: string
26
+ borderLeftWidth?: number | null
27
+ }
28
+ typography: {
29
+ fontStack: string[]
30
+ size: string | null
31
+ weight: number | null
32
+ }
33
+ radius: number | null
34
+ borderRadius: {
35
+ topLeft: number | null
36
+ topRight: number | null
37
+ bottomRight: number | null
38
+ bottomLeft: number | null
39
+ }
40
+ shadow: string | null
41
+ isButton: boolean
42
+ isNavigation: boolean
43
+ hasCTAIndicator: boolean
44
+ isInput: boolean
45
+ inputMetadata: {
46
+ type: string
47
+ placeholder: string
48
+ value: string
49
+ required: boolean
50
+ disabled: boolean
51
+ name: string
52
+ id: string
53
+ label: string
54
+ } | null
55
+ isLink: boolean
56
+ }
57
+
58
+ export interface LogoCandidate {
59
+ src: string
60
+ alt: string
61
+ ariaLabel?: string
62
+ title?: string
63
+ isSvg: boolean
64
+ isVisible: boolean
65
+ location: "header" | "body" | "footer"
66
+ position: { top: number; left: number; width: number; height: number }
67
+ indicators: {
68
+ inHeader: boolean
69
+ altMatch: boolean
70
+ srcMatch: boolean
71
+ classMatch: boolean
72
+ hrefMatch: boolean
73
+ }
74
+ href?: string
75
+ source: string
76
+ logoSvgScore?: number
77
+ }
78
+
79
+ export interface RawImage {
80
+ type: "favicon" | "og" | "twitter" | "logo" | "logo-svg" | string
81
+ src: string
82
+ }
83
+
84
+ export interface BackgroundCandidate {
85
+ color: string
86
+ source: string
87
+ priority: number
88
+ area?: number
89
+ }
90
+
91
+ export interface TypographyData {
92
+ stacks: {
93
+ body: string[]
94
+ heading: string[]
95
+ paragraph: string[]
96
+ }
97
+ sizes: {
98
+ h1: string
99
+ h2: string
100
+ body: string
101
+ }
102
+ }
103
+
104
+ export interface ExtractionDiagnostic {
105
+ context: string
106
+ message: string
107
+ timestamp: number
108
+ }
109
+
110
+ export interface RawBrandingData {
111
+ cssData: CSSData
112
+ snapshots: StyleSnapshot[]
113
+ images: RawImage[]
114
+ logoCandidates: LogoCandidate[]
115
+ brandName: string
116
+ pageTitle: string
117
+ pageUrl: string
118
+ typography: TypographyData
119
+ frameworkHints: string[]
120
+ colorScheme: ColorScheme
121
+ pageBackground: string | null
122
+ backgroundCandidates: BackgroundCandidate[]
123
+ errors?: ExtractionDiagnostic[]
124
+ }
125
+
126
+ export interface ButtonSnapshot {
127
+ index: number
128
+ text: string
129
+ classes: string
130
+ background: string
131
+ textColor: string
132
+ borderColor?: string | null
133
+ borderRadius?: string
134
+ borderRadiusCorners?: {
135
+ topLeft?: string
136
+ topRight?: string
137
+ bottomRight?: string
138
+ bottomLeft?: string
139
+ }
140
+ shadow?: string | null
141
+ score?: number
142
+ originalBackgroundColor?: string
143
+ originalTextColor?: string
144
+ originalBorderColor?: string
145
+ }
146
+
147
+ export interface InputSnapshot {
148
+ type: string
149
+ placeholder: string
150
+ label: string
151
+ name: string
152
+ required: boolean
153
+ classes: string
154
+ background: string
155
+ textColor: string | null
156
+ borderColor?: string | null
157
+ borderRadius?: string
158
+ borderRadiusCorners?: {
159
+ topLeft?: string
160
+ topRight?: string
161
+ bottomRight?: string
162
+ bottomLeft?: string
163
+ }
164
+ shadow?: string | null
165
+ }
166
+
167
+ export interface BrandingProfile {
168
+ url?: string
169
+ finalUrl?: string
170
+ brandName?: string
171
+ pageTitle?: string
172
+ colorScheme?: ColorScheme
173
+ logo?: string | null
174
+ fonts?: Array<{ family: string; count?: number; role?: string }>
175
+ colors?: {
176
+ primary?: string
177
+ secondary?: string
178
+ accent?: string
179
+ background?: string
180
+ textPrimary?: string
181
+ textSecondary?: string
182
+ link?: string
183
+ [key: string]: string | undefined
184
+ }
185
+ typography?: {
186
+ fontFamilies?: {
187
+ primary?: string
188
+ heading?: string
189
+ code?: string
190
+ }
191
+ fontStacks?: {
192
+ body?: string[]
193
+ heading?: string[]
194
+ paragraph?: string[]
195
+ primary?: string[]
196
+ }
197
+ fontSizes?: {
198
+ h1?: string
199
+ h2?: string
200
+ body?: string
201
+ }
202
+ }
203
+ spacing?: {
204
+ baseUnit?: number
205
+ borderRadius?: string
206
+ }
207
+ components?: {
208
+ buttonPrimary?: ComponentStyle
209
+ buttonSecondary?: ComponentStyle
210
+ input?: ComponentStyle
211
+ }
212
+ images?: {
213
+ logo?: string | null
214
+ logoHref?: string | null
215
+ logoAlt?: string | null
216
+ favicon?: string | null
217
+ ogImage?: string | null
218
+ }
219
+ personality?: {
220
+ tone: "professional" | "playful" | "modern" | "traditional" | "minimalist" | "bold"
221
+ energy: "low" | "medium" | "high"
222
+ targetAudience: string
223
+ }
224
+ designSystem?: {
225
+ framework: "tailwind" | "bootstrap" | "material" | "chakra" | "custom" | "unknown"
226
+ componentLibrary: string
227
+ }
228
+ confidence?: {
229
+ logo?: number
230
+ colors?: number
231
+ buttons?: number
232
+ overall?: number
233
+ }
234
+ diagnostics?: {
235
+ llm?: {
236
+ enabled: boolean
237
+ used: boolean
238
+ error?: string
239
+ model?: string
240
+ }
241
+ logo?: {
242
+ source: "heuristic" | "llm" | "fallback" | "none"
243
+ selectedIndex: number
244
+ reasoning: string
245
+ confidence: number
246
+ }
247
+ errors?: ExtractionDiagnostic[]
248
+ }
249
+ debug?: {
250
+ buttons?: ButtonSnapshot[]
251
+ inputs?: InputSnapshot[]
252
+ logoCandidates?: LogoCandidate[]
253
+ frameworkHints?: string[]
254
+ backgroundCandidates?: BackgroundCandidate[]
255
+ }
256
+ }
257
+
258
+ export interface ComponentStyle {
259
+ background?: string
260
+ textColor?: string | null
261
+ borderColor?: string | null
262
+ borderRadius?: string
263
+ borderRadiusCorners?: {
264
+ topLeft?: string
265
+ topRight?: string
266
+ bottomRight?: string
267
+ bottomLeft?: string
268
+ }
269
+ shadow?: string | null
270
+ }
271
+
272
+ export interface BrandingExtractionOptions {
273
+ debug?: boolean
274
+ includeRaw?: boolean
275
+ llm?: boolean
276
+ waitMs?: number
277
+ timeoutMs?: number
278
+ model?: string
279
+ }