From 326cccd8391250b11e892f6380f604a1aa856e7d Mon Sep 17 00:00:00 2001 From: Abimael Martell Date: Mon, 17 Nov 2025 13:52:46 -0800 Subject: [PATCH] Improve Logo and Color Detection on Branding Extract (#2362) * Improve Logo Detection on Branding Extract * llm logos!!! * optimize! * fix color detection * remove stuff * cleanup stuff * cr comments * fix alphas * add color unit tests * add test --- .../lib/branding/processor-color.test.ts | 223 +++++ apps/api/src/lib/branding/merge.ts | 50 + apps/api/src/lib/branding/processor.ts | 264 +++++- apps/api/src/lib/branding/prompt.ts | 100 +- apps/api/src/lib/branding/schema.ts | 22 + apps/api/src/lib/branding/transformer.ts | 38 +- apps/api/src/lib/branding/types.ts | 54 +- .../engines/fire-engine/brandingScript.ts | 867 +++++++++++++++--- 8 files changed, 1411 insertions(+), 207 deletions(-) create mode 100644 apps/api/src/__tests__/lib/branding/processor-color.test.ts diff --git a/apps/api/src/__tests__/lib/branding/processor-color.test.ts b/apps/api/src/__tests__/lib/branding/processor-color.test.ts new file mode 100644 index 000000000..1bea801ff --- /dev/null +++ b/apps/api/src/__tests__/lib/branding/processor-color.test.ts @@ -0,0 +1,223 @@ +import { hexify } from "../../../lib/branding/processor"; + +describe("hexify color conversion", () => { + describe("basic color formats", () => { + it("converts rgb to hex", () => { + expect(hexify("rgb(255, 0, 0)")).toBe("#FF0000"); + expect(hexify("rgb(0, 255, 0)")).toBe("#00FF00"); + expect(hexify("rgb(0, 0, 255)")).toBe("#0000FF"); + expect(hexify("rgb(128, 128, 128)")).toBe("#808080"); + }); + + it("converts rgba to hex (opaque)", () => { + expect(hexify("rgba(255, 0, 0, 1)")).toBe("#FF0000"); + expect(hexify("rgba(0, 255, 0, 1.0)")).toBe("#00FF00"); + expect(hexify("rgba(0, 0, 255, 1)")).toBe("#0000FF"); + }); + + it("converts hex to hex (normalized)", () => { + expect(hexify("#ff0000")).toBe("#FF0000"); + expect(hexify("#00ff00")).toBe("#00FF00"); + expect(hexify("#0000ff")).toBe("#0000FF"); + }); + + it("handles black colors correctly", () => { + expect(hexify("rgb(0, 0, 0)")).toBe("#000000"); + expect(hexify("rgba(0, 0, 0, 1)")).toBe("#000000"); + expect(hexify("#000000")).toBe("#000000"); + }); + + it("handles white colors correctly", () => { + expect(hexify("rgb(255, 255, 255)")).toBe("#FFFFFF"); + expect(hexify("rgba(255, 255, 255, 1)")).toBe("#FFFFFF"); + expect(hexify("#ffffff")).toBe("#FFFFFF"); + }); + }); + + describe("transparent colors", () => { + it("returns null for fully transparent colors", () => { + expect(hexify("rgba(0, 0, 0, 0)")).toBeNull(); + expect(hexify("rgba(255, 255, 255, 0)")).toBeNull(); + expect(hexify("rgba(100, 200, 50, 0)")).toBeNull(); + }); + + it("returns null for very low alpha values", () => { + expect(hexify("rgba(0, 0, 0, 0.001)")).toBeNull(); + expect(hexify("rgba(255, 255, 255, 0.005)")).toBeNull(); + }); + + it("handles transparent keyword", () => { + expect(hexify("transparent")).toBeNull(); + }); + }); + + describe("semi-transparent colors without background", () => { + it("blends semi-transparent colors with default white background", () => { + // rgba(255, 255, 255, 0.5) on white = white + const result = hexify("rgba(255, 255, 255, 0.5)"); + expect(result).toBe("#FFFFFF"); + + // rgba(0, 0, 0, 0.5) on white = gray + const black50 = hexify("rgba(0, 0, 0, 0.5)"); + expect(black50).toBe("#808080"); + + // rgba(255, 0, 0, 0.5) on white = light red + const red50 = hexify("rgba(255, 0, 0, 0.5)"); + // Expected: 0.5 * 255 + 0.5 * 255 = 255 for red, 0.5 * 0 + 0.5 * 255 = 127.5 ≈ 128 for others + expect(red50).toBe("#FF8080"); + }); + + it("blends semi-transparent colors correctly with different alpha values", () => { + // rgba(0, 0, 0, 0.2) on white = very light gray + const black20 = hexify("rgba(0, 0, 0, 0.2)"); + // Expected: 0.2 * 0 + 0.8 * 255 = 204 + expect(black20).toBe("#CCCCCC"); + + // rgba(0, 0, 0, 0.8) on white = dark gray + const black80 = hexify("rgba(0, 0, 0, 0.8)"); + // Expected: 0.8 * 0 + 0.2 * 255 = 51 + expect(black80).toBe("#333333"); + }); + }); + + describe("semi-transparent colors with opaque background", () => { + it("blends semi-transparent colors with provided background", () => { + // rgba(255, 255, 255, 0.5) on black = gray + const whiteOnBlack = hexify("rgba(255, 255, 255, 0.5)", "rgb(0, 0, 0)"); + // Expected: 0.5 * 255 + 0.5 * 0 = 127.5 ≈ 128 + expect(whiteOnBlack).toBe("#808080"); + + // rgba(0, 0, 0, 0.5) on black = black + const blackOnBlack = hexify("rgba(0, 0, 0, 0.5)", "rgb(0, 0, 0)"); + expect(blackOnBlack).toBe("#000000"); + + // rgba(255, 0, 0, 0.5) on blue = purple + const redOnBlue = hexify("rgba(255, 0, 0, 0.5)", "rgb(0, 0, 255)"); + // Expected: R: 0.5 * 255 + 0.5 * 0 = 127.5 ≈ 128, G: 0, B: 0.5 * 0 + 0.5 * 255 = 127.5 ≈ 128 + expect(redOnBlue).toBe("#800080"); + }); + + it("blends with colored backgrounds correctly", () => { + // rgba(255, 255, 255, 0.5) on red = light red/pink + const whiteOnRed = hexify("rgba(255, 255, 255, 0.5)", "rgb(255, 0, 0)"); + // Expected: R: 0.5 * 255 + 0.5 * 255 = 255, G: 0.5 * 255 + 0.5 * 0 = 127.5 ≈ 128, B: 0.5 * 255 + 0.5 * 0 = 127.5 ≈ 128 + expect(whiteOnRed).toBe("#FF8080"); + }); + }); + + describe("semi-transparent colors with transparent background", () => { + it("falls back to white when background is transparent", () => { + // rgba(255, 255, 255, 0.5) on transparent = white (blended with white default) + const whiteOnTransparent = hexify( + "rgba(255, 255, 255, 0.5)", + "rgba(0, 0, 0, 0)", + ); + expect(whiteOnTransparent).toBe("#FFFFFF"); + + // rgba(0, 0, 0, 0.5) on transparent = gray (blended with white default, not black) + const blackOnTransparent = hexify( + "rgba(0, 0, 0, 0.5)", + "rgba(0, 0, 0, 0)", + ); + // Expected: 0.5 * 0 + 0.5 * 255 = 127.5 ≈ 128 (gray, not black) + expect(blackOnTransparent).toBe("#808080"); + }); + + it("falls back to white when background has very low alpha", () => { + // rgba(255, 255, 255, 0.5) on nearly transparent = white + const whiteOnNearTransparent = hexify( + "rgba(255, 255, 255, 0.5)", + "rgba(0, 0, 0, 0.001)", + ); + expect(whiteOnNearTransparent).toBe("#FFFFFF"); + + // rgba(0, 0, 0, 0.5) on nearly transparent = gray (not black) + const blackOnNearTransparent = hexify( + "rgba(0, 0, 0, 0.5)", + "rgba(0, 0, 0, 0.005)", + ); + expect(blackOnNearTransparent).toBe("#808080"); + }); + + it("uses background when alpha is above threshold", () => { + // rgba(0, 0, 0, 0.5) on semi-transparent black (alpha 0.1) = should use the background + const blackOnSemiTransparent = hexify( + "rgba(0, 0, 0, 0.5)", + "rgba(0, 0, 0, 0.1)", + ); + // Background alpha 0.1 is above 0.01 threshold, so it should be used + // But wait, the background itself is semi-transparent. Let me think... + // Actually, the background is parsed and if its alpha >= 0.01, we use its RGB values + // So rgba(0, 0, 0, 0.1) gives us RGB(0, 0, 0) as background + // Then rgba(0, 0, 0, 0.5) on rgb(0, 0, 0) = 0.5 * 0 + 0.5 * 0 = 0 = black + expect(blackOnSemiTransparent).toBe("#000000"); + }); + }); + + describe("edge cases", () => { + it("handles null and undefined inputs", () => { + expect(hexify(null as any)).toBeNull(); + expect(hexify(undefined as any)).toBeNull(); + expect(hexify("")).toBeNull(); + }); + + it("handles invalid color strings", () => { + expect(hexify("not a color")).toBeNull(); + expect(hexify("rgb(invalid)")).toBeNull(); + expect(hexify("#gggggg")).toBeNull(); + }); + + it("handles null background parameter", () => { + // Should default to white + const result = hexify("rgba(0, 0, 0, 0.5)", null); + expect(result).toBe("#808080"); + }); + + it("handles invalid background color", () => { + // Should default to white when background is invalid + const result = hexify("rgba(0, 0, 0, 0.5)", "invalid background"); + expect(result).toBe("#808080"); + }); + + it("clamps color values to valid range", () => { + // Colors outside 0-255 range should be clamped + // This is handled by culori, but we test the output is valid hex + const result = hexify("rgb(300, -10, 128)"); + expect(result).toMatch(/^#[0-9A-F]{6}$/); + }); + }); + + describe("regression tests for fixed issues", () => { + it("prevents translucent overlays from being treated as opaque black", () => { + // Before fix: rgba(0, 0, 0, 0.2) would be treated as #000000 + // After fix: rgba(0, 0, 0, 0.2) on white = #CCCCCC + const translucentBlack = hexify("rgba(0, 0, 0, 0.2)"); + expect(translucentBlack).not.toBe("#000000"); + expect(translucentBlack).toBe("#CCCCCC"); + }); + + it("prevents transparent backgrounds from being treated as black", () => { + // Before fix: rgba(255, 255, 255, 0.5) on transparent would blend with black + // After fix: rgba(255, 255, 255, 0.5) on transparent = white (blended with white) + const whiteOnTransparent = hexify( + "rgba(255, 255, 255, 0.5)", + "rgba(0, 0, 0, 0)", + ); + expect(whiteOnTransparent).toBe("#FFFFFF"); + }); + + it("handles opaque black backgrounds correctly", () => { + // rgb(0, 0, 0) is a valid opaque black background, not transparent + const blackBg = hexify("rgba(255, 255, 255, 0.5)", "rgb(0, 0, 0)"); + expect(blackBg).toBe("#808080"); + }); + + it("handles semi-transparent black backgrounds correctly", () => { + // rgba(0, 0, 0, 0.8) is a valid dark background, not transparent + const darkBg = hexify("rgba(255, 255, 255, 0.5)", "rgba(0, 0, 0, 0.8)"); + // Background alpha 0.8 >= 0.01, so use it + // rgba(255, 255, 255, 0.5) on rgb(0, 0, 0) = 0.5 * 255 + 0.5 * 0 = 127.5 ≈ 128 + expect(darkBg).toBe("#808080"); + }); + }); +}); diff --git a/apps/api/src/lib/branding/merge.ts b/apps/api/src/lib/branding/merge.ts index 1a4b5f2df..3cdcf68d3 100644 --- a/apps/api/src/lib/branding/merge.ts +++ b/apps/api/src/lib/branding/merge.ts @@ -6,9 +6,48 @@ export function mergeBrandingResults( js: BrandingProfile, llm: BrandingEnhancement, buttonSnapshots: ButtonSnapshot[], + logoCandidates?: Array<{ + src: string; + alt: string; + isSvg: boolean; + isVisible: boolean; + location: "header" | "body"; + position: { top: number; left: number; width: number; height: number }; + indicators: { + inHeader: boolean; + altMatch: boolean; + srcMatch: boolean; + classMatch: boolean; + }; + source: string; + }>, ): BrandingProfile { const merged: BrandingProfile = { ...js }; + // Use LLM-selected logo if available + if ( + llm.logoSelection && + llm.logoSelection.selectedLogoIndex !== undefined && + llm.logoSelection.selectedLogoIndex >= 0 && + logoCandidates && + logoCandidates.length > 0 && + llm.logoSelection.selectedLogoIndex < logoCandidates.length + ) { + const selectedLogo = logoCandidates[llm.logoSelection.selectedLogoIndex]; + if (selectedLogo) { + // Initialize images object if it doesn't exist + if (!merged.images) { + merged.images = {}; + } + merged.images.logo = selectedLogo.src; + (merged as any).__llm_logo_reasoning = { + selectedIndex: llm.logoSelection.selectedLogoIndex, + reasoning: llm.logoSelection.selectedLogoReasoning, + confidence: llm.logoSelection.confidence, + }; + } + } + if (buttonSnapshots.length > 0) { const primaryIdx = llm.buttonClassification.primaryButtonIndex; const secondaryIdx = llm.buttonClassification.secondaryButtonIndex; @@ -69,6 +108,17 @@ export function mergeBrandingResults( background: llm.colorRoles.backgroundColor || merged.colors?.background, textPrimary: llm.colorRoles.textPrimary || merged.colors?.textPrimary, }; + + // Add LLM-selected colors to debug output + if ((merged as any).__debug_colors) { + (merged as any).__debug_colors.llmSelectedColors = { + primary: llm.colorRoles.primaryColor, + accent: llm.colorRoles.accentColor, + background: llm.colorRoles.backgroundColor, + textPrimary: llm.colorRoles.textPrimary, + confidence: llm.colorRoles.confidence, + }; + } } if (llm.personality) { diff --git a/apps/api/src/lib/branding/processor.ts b/apps/api/src/lib/branding/processor.ts index 3056ea6fc..cc2f1af0f 100644 --- a/apps/api/src/lib/branding/processor.ts +++ b/apps/api/src/lib/branding/processor.ts @@ -2,7 +2,11 @@ import { BrandingProfile } from "../../types/branding"; import { BrandingScriptReturn } from "./types"; import { parse, rgb, formatHex } from "culori"; -function hexify(rgba: string): string | null { +// Export for testing +export function hexify( + rgba: string, + background?: string | null, +): string | null { if (!rgba) return null; try { @@ -15,20 +19,63 @@ function hexify(rgba: string): string | null { return null; } - let r = rgbColor.r ?? 0; - let g = rgbColor.g ?? 0; - let b = rgbColor.b ?? 0; + let r = Math.round((rgbColor.r ?? 0) * 255); + let g = Math.round((rgbColor.g ?? 0) * 255); + let b = Math.round((rgbColor.b ?? 0) * 255); const alpha = rgbColor.alpha ?? 1; - if (alpha < 1) { - r = r * alpha + (1 - alpha); - g = g * alpha + (1 - alpha); - b = b * alpha + (1 - alpha); + // Treat fully transparent colors as absent (not as black) + if (alpha < 0.01) { + return null; } - const blendedColor = { mode: "rgb" as const, r, g, b }; - const hex = formatHex(blendedColor); - return hex ? hex.toUpperCase() : null; + // If color has alpha < 1, blend it with background before converting to hex + // This prevents translucent overlays from being treated as opaque + if (alpha < 1) { + let bgR = 255; // Default to white + let bgG = 255; + let bgB = 255; + + // Try to parse background color if provided + if (background) { + try { + const bgColor = parse(background); + if (bgColor) { + const bgRgb = rgb(bgColor); + if (bgRgb && bgRgb.mode === "rgb") { + const bgAlpha = bgRgb.alpha ?? 1; + // Only use background color if it's not transparent + // Transparent backgrounds should fall back to default white + if (bgAlpha >= 0.01) { + bgR = Math.round((bgRgb.r ?? 1) * 255); + bgG = Math.round((bgRgb.g ?? 1) * 255); + bgB = Math.round((bgRgb.b ?? 1) * 255); + } + // If bgAlpha < 0.01, keep default white values + } + } + } catch (e) { + // If background parsing fails, use white default + } + } + + // Alpha compositing: blend foreground with background + // result = alpha * foreground + (1 - alpha) * background + r = Math.round(alpha * r + (1 - alpha) * bgR); + g = Math.round(alpha * g + (1 - alpha) * bgG); + b = Math.round(alpha * b + (1 - alpha) * bgB); + } + + // Clamp values to valid range + r = Math.max(0, Math.min(255, r)); + g = Math.max(0, Math.min(255, g)); + b = Math.max(0, Math.min(255, b)); + + // Format as hex (always return #RRGGBB after blending) + const rHex = r.toString(16).padStart(2, "0"); + const gHex = g.toString(16).padStart(2, "0"); + const bHex = b.toString(16).padStart(2, "0"); + return `#${rHex}${gHex}${bHex}`.toUpperCase(); } catch (e) { return null; } @@ -49,6 +96,8 @@ function contrastYIQ(hex: string): number { function inferPalette( snapshots: BrandingScriptReturn["snapshots"], cssColors: string[], + colorScheme?: "light" | "dark", + pageBackground?: string | null, ) { const freq = new Map(); const bump = (hex: string | null, weight = 1) => { @@ -56,14 +105,26 @@ function inferPalette( freq.set(hex, (freq.get(hex) || 0) + weight); }; - for (const s of snapshots) { - const area = Math.max(1, s.rect.w * s.rect.h); - bump(hexify(s.colors.background), 0.5 + Math.log10(area + 10)); - bump(hexify(s.colors.text), 1.0); - bump(hexify(s.colors.border), 0.3); + // Give very high weight to page background if available + if (pageBackground) { + const pageBgHex = hexify(pageBackground); + if (pageBgHex) { + bump(pageBgHex, 1000); // Much higher weight than other colors + } } - for (const c of cssColors) bump(hexify(c), 0.5); + for (const s of snapshots) { + const area = Math.max(1, s.rect.w * s.rect.h); + // Blend semi-transparent colors with page background if available + bump( + hexify(s.colors.background, pageBackground), + 0.5 + Math.log10(area + 10), + ); + bump(hexify(s.colors.text, pageBackground), 1.0); + bump(hexify(s.colors.border, pageBackground), 0.3); + } + + for (const c of cssColors) bump(hexify(c, pageBackground), 0.5); const ranked = Array.from(freq.entries()) .sort((a, b) => b[1] - a[1]) @@ -80,23 +141,60 @@ function inferPalette( return max - min < 15; }; - const background = - ranked.find(h => isGrayish(h) && contrastYIQ(h) > 180) || "#FFFFFF"; + // Improved background detection that considers color scheme + let background = "#FFFFFF"; // Default fallback + + if (pageBackground) { + const pageBgHex = hexify(pageBackground); + if (pageBgHex && isGrayish(pageBgHex)) { + background = pageBgHex; + } + } + + if (background === "#FFFFFF" || (!pageBackground && ranked.length > 0)) { + // If we don't have a good page background, infer from ranked colors + if (colorScheme === "dark") { + // For dark mode: look for dark grayish colors (low YIQ, but grayish) + background = + ranked.find( + h => isGrayish(h) && contrastYIQ(h) < 128 && contrastYIQ(h) > 0, + ) || + ranked.find(h => isGrayish(h) && contrastYIQ(h) < 180) || + "#1A1A1A"; + } else { + // For light mode: look for light grayish colors (high YIQ and grayish) + background = + ranked.find(h => isGrayish(h) && contrastYIQ(h) > 180) || "#FFFFFF"; + } + } + const textPrimary = ranked.find(h => !/^#FFFFFF$/i.test(h) && contrastYIQ(h) < 160) || - "#111111"; + (colorScheme === "dark" ? "#FFFFFF" : "#111111"); const primary = ranked.find(h => !isGrayish(h) && h !== textPrimary && h !== background) || - "#000000"; + (colorScheme === "dark" ? "#FFFFFF" : "#000000"); const accent = ranked.find(h => h !== primary && !isGrayish(h)) || primary; - return { + // Collect all detected colors with their frequencies for debugging + const allDetectedColors = Array.from(freq.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([hex, count]) => ({ + hex, + frequency: count, + isGrayish: isGrayish(hex), + yiq: contrastYIQ(hex), + })); + + const paletteResult = { primary, accent, background, textPrimary: textPrimary, link: accent, }; + + return paletteResult; } // Infer spacing base unit @@ -158,7 +256,12 @@ function pickLogo(images: Array<{ type: string; src: string }>): string | null { // Process raw branding data into BrandingProfile export function processRawBranding(raw: BrandingScriptReturn): BrandingProfile { - const palette = inferPalette(raw.snapshots, raw.cssData.colors); + const palette = inferPalette( + raw.snapshots, + raw.cssData.colors, + raw.colorScheme, + raw.pageBackground, + ); // Typography const typography = { @@ -205,14 +308,25 @@ export function processRawBranding(raw: BrandingScriptReturn): BrandingProfile { }, }; - const buttonSnapshots = raw.snapshots + // Filter and score buttons + const candidateButtons = raw.snapshots .filter(s => { if (!s.isButton) return false; if (s.rect.w < 30 || s.rect.h < 30) return false; if (!s.text || s.text.trim().length === 0) return false; - const bgHex = hexify(s.colors.background); - if (!bgHex) return false; + // Include buttons with valid background OR buttons with borders (transparent bg + border is valid) + const bgHex = hexify(s.colors.background, raw.pageBackground); + + // Check for borders: has borderWidth > 0 AND border color is not transparent + const hasBorder = s.colors.borderWidth && s.colors.borderWidth > 0; + const borderHex = hasBorder + ? hexify(s.colors.border, raw.pageBackground) + : null; + + // Include if has background OR has border (transparent buttons with borders are valid) + // Note: borderHex might be null if border is transparent, but we still check hasBorder + if (!bgHex && !hasBorder) return false; return true; }) @@ -241,7 +355,13 @@ export function processRawBranding(raw: BrandingScriptReturn): BrandingProfile { ]; if (ctaKeywords.some(kw => text.includes(kw))) score += 500; - const bgHex = hexify(s.colors.background); + const bgHex = hexify(s.colors.background, raw.pageBackground); + const borderHex = + s.colors.borderWidth && s.colors.borderWidth > 0 + ? hexify(s.colors.border, raw.pageBackground) + : null; + + // Score for non-white backgrounds if ( bgHex && bgHex !== "#FFFFFF" && @@ -251,6 +371,11 @@ export function processRawBranding(raw: BrandingScriptReturn): BrandingProfile { score += 300; } + // Score for buttons with borders (transparent bg + border is a valid style) + if (borderHex && !bgHex) { + score += 200; // Less than colored background but still valid + } + if (text.length > 0 && text.length < 50) score += 100; const area = (s.rect.w || 0) * (s.rect.h || 0); @@ -258,31 +383,70 @@ export function processRawBranding(raw: BrandingScriptReturn): BrandingProfile { return { ...s, _score: score }; }) - .sort((a: any, b: any) => (b._score || 0) - (a._score || 0)) - .slice(0, 50) - .map((s, idx) => { - let bgHex = hexify(s.colors.background); - const borderHex = - s.colors.borderWidth && s.colors.borderWidth > 0 - ? hexify(s.colors.border) - : null; + .sort((a: any, b: any) => (b._score || 0) - (a._score || 0)); - if (!bgHex) { - bgHex = "transparent"; - } + // Deduplicate buttons: same text + background + border + similar classes = same button + const seenButtons = new Map(); + const uniqueButtons: typeof candidateButtons = []; - return { - index: idx, - text: s.text || "", - html: "", - classes: s.classes || "", - background: bgHex, - textColor: hexify(s.colors.text) || "#000000", - borderColor: borderHex, - borderRadius: s.radius ? `${s.radius}px` : "0px", - shadow: s.shadow || null, - }; - }); + for (const button of candidateButtons) { + const bgHex = + hexify(button.colors.background, raw.pageBackground) || "transparent"; + const borderHex = + button.colors.borderWidth && button.colors.borderWidth > 0 + ? hexify(button.colors.border, raw.pageBackground) || + "transparent-border" + : "no-border"; + const textKey = (button.text || "").trim().toLowerCase().substring(0, 50); + const classKey = (button.classes || "") + .split(/\s+/) + .slice(0, 5) + .join(" ") + .toLowerCase(); + + // Create a signature: text + background + border + first 5 classes + // Include border to distinguish buttons with same background but different borders + const signature = `${textKey}|${bgHex}|${borderHex}|${classKey}`; + + if (!seenButtons.has(signature)) { + seenButtons.set(signature, 1); + uniqueButtons.push(button); + } else { + // If we've seen this button, increment count but don't add it + seenButtons.set(signature, seenButtons.get(signature)! + 1); + } + } + + // Take top unique buttons (increased limit for more diversity) + const topButtons = uniqueButtons.slice(0, 80); + + const buttonSnapshots = topButtons.map((s, idx) => { + let bgHex = hexify(s.colors.background, raw.pageBackground); + const borderHex = + s.colors.borderWidth && s.colors.borderWidth > 0 + ? hexify(s.colors.border, raw.pageBackground) + : null; + + if (!bgHex) { + bgHex = "transparent"; + } + + return { + index: idx, + text: s.text || "", + html: "", + classes: s.classes || "", + background: bgHex, + textColor: hexify(s.colors.text, raw.pageBackground) || "#000000", + borderColor: borderHex, + borderRadius: s.radius ? `${s.radius}px` : "0px", + shadow: s.shadow || null, + // Debug: original color values before hex conversion + originalBackgroundColor: s.colors.background || undefined, + originalTextColor: s.colors.text || undefined, + originalBorderColor: s.colors.border || undefined, + }; + }); return { colorScheme: raw.colorScheme, diff --git a/apps/api/src/lib/branding/prompt.ts b/apps/api/src/lib/branding/prompt.ts index 09c7c403f..dacafecab 100644 --- a/apps/api/src/lib/branding/prompt.ts +++ b/apps/api/src/lib/branding/prompt.ts @@ -1,15 +1,16 @@ import { BrandingProfile } from "../../types/branding"; -import { ButtonSnapshot } from "./types"; +import { ButtonSnapshot, BrandingLLMInput } from "./types"; import { parse, rgb } from "culori"; -type BrandingLLMInput = { - jsAnalysis: BrandingProfile; - buttons: ButtonSnapshot[]; - url: string; -}; - export function buildBrandingPrompt(input: BrandingLLMInput): string { - const { jsAnalysis, buttons, url } = input; + const { + jsAnalysis, + buttons, + logoCandidates, + brandName, + backgroundCandidates, + url, + } = input; let prompt = `Analyze the branding of this website: ${url}\n\n`; @@ -167,6 +168,73 @@ export function buildBrandingPrompt(input: BrandingLLMInput): string { }); } + // Add logo candidates section (optimized - compact format) + if (logoCandidates && logoCandidates.length > 0) { + prompt += `\n## Logo Candidates (${logoCandidates.length}):\n`; + + if (brandName) { + prompt += `Brand: "${brandName}" - Use this to match logos visually.\n\n`; + } + + // Compact format: index, location, visible, alt, indicators, href, truncated URL + logoCandidates.forEach((candidate, idx) => { + const indicators: string[] = []; + if (candidate.indicators.inHeader) indicators.push("header"); + if (candidate.indicators.altMatch) indicators.push("alt=logo"); + if (candidate.indicators.srcMatch) indicators.push("url=logo"); + if (candidate.indicators.classMatch) indicators.push("class=logo"); + if (candidate.indicators.hrefMatch) indicators.push("href=home"); + + const urlPreview = + candidate.src.length > 80 + ? candidate.src.substring(0, 80) + "..." + : candidate.src; + + const hrefInfo = candidate.href ? ` | href:${candidate.href}` : ""; + + prompt += `#${idx}: ${candidate.location} | ${candidate.isVisible ? "visible" : "hidden"} | ${candidate.isSvg ? "SVG" : "IMG"} | alt:"${candidate.alt || ""}" | [${indicators.join(", ")}]${hrefInfo} | ${urlPreview}\n`; + }); + + prompt += `\n**CRITICAL LOGO SELECTION RULES:**\n`; + prompt += `1. **Brand Name Match**: The logo MUST visually represent or match the brand name "${brandName || "unknown"}"\n`; + prompt += ` - If you see text/logos that say DIFFERENT brand names, those are CUSTOMER/PARTNER logos - SKIP THEM\n`; + prompt += ` - Only select logos that match "${brandName || "the website's main brand"}"\n`; + prompt += `2. **Location**: Prefer logos in header/navbar (highest priority)\n`; + prompt += `3. **Href Indicator**: Logos that link to "/" (homepage) are VERY LIKELY the main brand logo\n`; + prompt += ` - href="/" or href="/home" indicates homepage logo - STRONG indicator\n`; + prompt += ` - Logos with hrefMatch indicator are prioritized\n`; + prompt += `4. **Visibility**: Prefer visible logos (not hidden by dark/light mode)\n`; + prompt += `5. **AVOID**:\n`; + prompt += ` - Customer/client logos (different brand names)\n`; + prompt += ` - Partner logos (different brand names)\n`; + prompt += ` - Testimonial logos (usually in testimonials/case studies sections)\n`; + prompt += ` - GitHub stars/social media icons\n`; + prompt += ` - Logos in footer (unless no header logo exists)\n`; + prompt += ` - Logos in "customers", "partners", "clients", "case studies" sections\n`; + prompt += `6. **Use Screenshot**: Look at the screenshot to visually identify which logo is the MAIN brand logo at the top of the page\n`; + prompt += ` - The logo in the header/navbar is almost always the brand logo\n`; + prompt += ` - If multiple logos exist, choose the one that matches "${brandName || "the website brand"}"\n\n`; + } + + // Add background color candidates section + if (backgroundCandidates && backgroundCandidates.length > 0) { + prompt += `\n## Background Color Candidates (${backgroundCandidates.length}):\n`; + prompt += `Multiple background colors were detected. Use the screenshot to identify which is the actual page background:\n\n`; + + backgroundCandidates.forEach((candidate, idx) => { + const areaInfo = candidate.area + ? ` | area: ${Math.round(candidate.area)}px²` + : ""; + prompt += `#${idx}: ${candidate.color} | source: ${candidate.source} | priority: ${candidate.priority}${areaInfo}\n`; + }); + + prompt += `\n**Selection Rules:** Use the screenshot to visually identify the main page background. Consider:\n`; + prompt += `- Color scheme (dark mode should have dark background, light mode should have light background)\n`; + prompt += `- Most visible/largest area in the screenshot\n`; + prompt += `- Higher priority sources (body/html > CSS vars > containers)\n`; + prompt += `- Return the hex color in the colorRoles.backgroundColor field\n\n`; + } + // Add specific questions prompt += `\n## Your Task:\n`; prompt += `1. **PRIMARY Button**: Identify which button (by index 0-${buttons.length - 1}) is the main call-to-action.\n`; @@ -214,6 +282,22 @@ export function buildBrandingPrompt(input: BrandingLLMInput): string { prompt += ` - Prioritize by frequency (shown in usage count)\n`; prompt += ` - Assign appropriate roles (heading, body, monospace, display)\n\n`; + if (logoCandidates && logoCandidates.length > 0) { + prompt += `7. **Logo Selection**: Identify the best brand logo from the ${logoCandidates.length} candidates provided above.\n`; + prompt += ` - **CRITICAL**: The logo MUST match the brand name "${brandName || "unknown"}"\n`; + prompt += ` - **REJECT** any logo that shows a DIFFERENT brand name - those are customer/partner logos\n`; + prompt += ` - Use the screenshot to visually identify which logo appears at the top of the page\n`; + prompt += ` - The logo should visually represent "${brandName || "the website's main brand"}"\n`; + prompt += ` - Prefer visible logos in header/navbar locations (these are almost always the brand logo)\n`; + prompt += ` - **AVOID**:\n`; + prompt += ` * Customer/client logos (show different company names)\n`; + prompt += ` * Partner logos (show different company names)\n`; + prompt += ` * Logos in footer, testimonials, case studies, partners sections\n`; + prompt += ` * GitHub stars, social media icons, badges\n`; + prompt += ` - Return the logo INDEX (0-${logoCandidates.length - 1}) and explain your reasoning\n`; + prompt += ` - **If you see a logo with a different brand name than "${brandName || "the website brand"}", it's NOT the brand logo - return -1 if no suitable logo matches the brand\n\n`; + } + prompt += `## VALIDATION CHECKLIST - VERIFY BEFORE RESPONDING:\n`; prompt += `Before finalizing your answer, check:\n`; prompt += `1. ✓ Are primaryButtonIndex and secondaryButtonIndex DIFFERENT numbers?\n`; diff --git a/apps/api/src/lib/branding/schema.ts b/apps/api/src/lib/branding/schema.ts index 807486af1..c87b534f9 100644 --- a/apps/api/src/lib/branding/schema.ts +++ b/apps/api/src/lib/branding/schema.ts @@ -92,6 +92,28 @@ export const brandingEnhancementSchema = z.object({ .describe( "Top 5 cleaned fonts (remove obfuscation, fallbacks, generics, CSS vars)", ), + + // Logo selection - LLM picks the best logo from candidates + logoSelection: z + .object({ + selectedLogoIndex: z + .number() + .describe( + "Index of the selected logo in the provided candidates list (0-based), or -1 if none found", + ) + .optional(), + selectedLogoReasoning: z + .string() + .describe("Why this logo was selected as the brand logo") + .optional(), + confidence: z + .number() + .min(0) + .max(1) + .describe("Confidence in logo selection (0-1)") + .optional(), + }) + .optional(), }); export type BrandingEnhancement = z.infer; diff --git a/apps/api/src/lib/branding/transformer.ts b/apps/api/src/lib/branding/transformer.ts index 07af61428..adc47b5c7 100644 --- a/apps/api/src/lib/branding/transformer.ts +++ b/apps/api/src/lib/branding/transformer.ts @@ -25,13 +25,46 @@ export async function brandingTransformer( const buttonSnapshots: ButtonSnapshot[] = (jsBranding as any).__button_snapshots || []; + const logoCandidates = rawBranding.logoCandidates || []; + const brandName = rawBranding.brandName; + const backgroundCandidates = rawBranding.backgroundCandidates || []; + + // Optimize logo candidates: limit to top 15, prioritize by indicators + const optimizedCandidates = logoCandidates + .sort((a, b) => { + // Score candidates: higher score = better + const scoreA = + (a.indicators.inHeader ? 10 : 0) + + (a.indicators.hrefMatch ? 8 : 0) + // href="/" is strong indicator + (a.isVisible ? 5 : 0) + + (a.indicators.altMatch ? 3 : 0) + + (a.indicators.srcMatch ? 2 : 0) + + (a.indicators.classMatch ? 2 : 0) + + (a.location === "header" ? 5 : 0); + const scoreB = + (b.indicators.inHeader ? 10 : 0) + + (b.indicators.hrefMatch ? 8 : 0) + // href="/" is strong indicator + (b.isVisible ? 5 : 0) + + (b.indicators.altMatch ? 3 : 0) + + (b.indicators.srcMatch ? 2 : 0) + + (b.indicators.classMatch ? 2 : 0) + + (b.location === "header" ? 5 : 0); + return scoreB - scoreA; + }) + .slice(0, 15); // Limit to top 15 candidates + meta.logger.info( - `Sending ${buttonSnapshots.length} buttons to LLM for classification`, + `Sending ${buttonSnapshots.length} buttons and ${optimizedCandidates.length} logo candidates (from ${logoCandidates.length} total) to LLM for classification`, ); const llmEnhancement = await enhanceBrandingWithLLM({ jsAnalysis: jsBranding, buttons: buttonSnapshots, + logoCandidates: + optimizedCandidates.length > 0 ? optimizedCandidates : undefined, + brandName, + backgroundCandidates: + backgroundCandidates.length > 0 ? backgroundCandidates : undefined, screenshot: document.screenshot, url: document.url || meta.url, }); @@ -42,12 +75,15 @@ export async function brandingTransformer( llmEnhancement.buttonClassification.secondaryButtonIndex, button_confidence: llmEnhancement.buttonClassification.confidence, color_confidence: llmEnhancement.colorRoles.confidence, + logo_selected_index: llmEnhancement.logoSelection?.selectedLogoIndex, + logo_confidence: llmEnhancement.logoSelection?.confidence, }); brandingProfile = mergeBrandingResults( jsBranding, llmEnhancement, buttonSnapshots, + logoCandidates.length > 0 ? logoCandidates : undefined, ); } catch (error) { meta.logger.error( diff --git a/apps/api/src/lib/branding/types.ts b/apps/api/src/lib/branding/types.ts index d6e56e4fc..b2ceb71ce 100644 --- a/apps/api/src/lib/branding/types.ts +++ b/apps/api/src/lib/branding/types.ts @@ -10,12 +10,39 @@ export interface ButtonSnapshot { borderColor?: string | null; borderRadius?: string; shadow?: string | null; + // Debug: original color values before conversion to hex + originalBackgroundColor?: string; + originalTextColor?: string; + originalBorderColor?: string; } export interface BrandingLLMInput { jsAnalysis: BrandingProfile; buttons: ButtonSnapshot[]; - + logoCandidates?: Array<{ + src: string; + alt: string; + isSvg: boolean; + isVisible: boolean; + location: "header" | "body"; + position: { top: number; left: number; width: number; height: number }; + indicators: { + inHeader: boolean; + altMatch: boolean; + srcMatch: boolean; + classMatch: boolean; + hrefMatch: boolean; + }; + href?: string; + source: string; + }>; + brandName?: string; + backgroundCandidates?: Array<{ + color: string; + source: string; + priority: number; + area?: number; + }>; screenshot?: string; url: string; } @@ -54,6 +81,24 @@ export interface BrandingScriptReturn { isLink: boolean; }>; images: Array<{ type: string; src: string }>; + logoCandidates?: Array<{ + src: string; + alt: string; + isSvg: boolean; + isVisible: boolean; + location: "header" | "body"; + position: { top: number; left: number; width: number; height: number }; + indicators: { + inHeader: boolean; + altMatch: boolean; + srcMatch: boolean; + classMatch: boolean; + hrefMatch: boolean; + }; + href?: string; + source: string; + }>; + brandName?: string; typography: { stacks: { body: string[]; @@ -68,4 +113,11 @@ export interface BrandingScriptReturn { }; frameworkHints: string[]; colorScheme: "light" | "dark"; + pageBackground?: string | null; + backgroundCandidates?: Array<{ + color: string; + source: string; + priority: number; + area?: number; + }>; } diff --git a/apps/api/src/scraper/scrapeURL/engines/fire-engine/brandingScript.ts b/apps/api/src/scraper/scrapeURL/engines/fire-engine/brandingScript.ts index 1c1a95946..e06d77ed4 100644 --- a/apps/api/src/scraper/scrapeURL/engines/fire-engine/brandingScript.ts +++ b/apps/api/src/scraper/scrapeURL/engines/fire-engine/brandingScript.ts @@ -157,6 +157,67 @@ export const getBrandingScript = () => String.raw` return data; }; + // Helper to check if an element looks like a button (has button-like styling) + const looksLikeButton = (el) => { + if (!el || typeof el.matches !== 'function') return false; + + // Check explicit button indicators + if (el.matches('button, [role=button], [data-primary-button], [data-secondary-button], [data-cta], a.button, a.btn, [class*="btn"], [class*="button"], a[class*="bg-brand"], a[class*="bg-primary"], a[class*="bg-accent"], a[type="button"]')) { + return true; + } + + // For links, check if they have button-like styling + if (el.tagName.toLowerCase() === 'a') { + try { + const classes = (el.className || '').toLowerCase(); + const classStr = classes; + + // Check for common button class patterns (Tailwind, Bootstrap, etc.) + const hasButtonClasses = + /rounded(-md|-lg|-xl|-full)?/.test(classStr) || // rounded corners + /px-\d+/.test(classStr) || // horizontal padding (px-2, px-4, etc.) + /py-\d+/.test(classStr) || // vertical padding (py-2, py-4, etc.) + /p-\d+/.test(classStr) || // padding (p-2, p-4, etc.) + (/border/.test(classStr) && /rounded/.test(classStr)) || // border + rounded + (/inline-flex/.test(classStr) && /items-center/.test(classStr) && /justify-center/.test(classStr)); // flexbox button pattern + + if (hasButtonClasses) { + const cs = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + + // Verify it has reasonable button dimensions + if (rect.width > 50 && rect.height > 25) { + return true; + } + } + + // Also check computed styles for button-like appearance + const cs = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + + // Check for button-like padding and dimensions + const paddingTop = parseFloat(cs.paddingTop) || 0; + const paddingBottom = parseFloat(cs.paddingBottom) || 0; + const paddingLeft = parseFloat(cs.paddingLeft) || 0; + const paddingRight = parseFloat(cs.paddingRight) || 0; + const hasPadding = paddingTop > 3 || paddingBottom > 3 || paddingLeft > 6 || paddingRight > 6; + const hasMinSize = rect.width > 50 && rect.height > 25; + const hasRounded = parseFloat(cs.borderRadius) > 0; + const hasBorder = parseFloat(cs.borderTopWidth) > 0 || parseFloat(cs.borderBottomWidth) > 0 || + parseFloat(cs.borderLeftWidth) > 0 || parseFloat(cs.borderRightWidth) > 0; + + // Button-like if has padding + (rounded or border) and reasonable size + if (hasPadding && hasMinSize && (hasRounded || hasBorder)) { + return true; + } + } catch (e) { + // If we can't check styles, fall back to class matching + } + } + + return false; + }; + const sampleElements = () => { const picks = []; const pushQ = (q, limit = 10) => { @@ -165,10 +226,21 @@ export const getBrandingScript = () => String.raw` }; pushQ('header img, .site-logo img, img[alt*=logo i], img[src*="logo"]', 5); + + // First, get explicit buttons pushQ( 'button, [role=button], [data-primary-button], [data-secondary-button], [data-cta], a.button, a.btn, [class*="btn"], [class*="button"], a[class*="bg-brand"], a[class*="bg-primary"], a[class*="bg-accent"], a[type="button"], a[type="button"][class*="bg-"]', - 50, + 100, ); + + // Also check all links for button-like styling + const allLinks = Array.from(document.querySelectorAll('a')); + for (const link of allLinks.slice(0, 100)) { + if (looksLikeButton(link)) { + picks.push(link); + } + } + pushQ('input, select, textarea, [class*="form-control"]', 25); pushQ("h1, h2, h3, p, a", 50); @@ -209,12 +281,75 @@ export const getBrandingScript = () => String.raw` } } + // Get colors as-is from computed style let bgColor = cs.getPropertyValue("background-color"); const textColor = cs.getPropertyValue("color"); + + // For transparent backgrounds, try to get the background from parent container + const isTransparent = bgColor === "transparent" || bgColor === "rgba(0, 0, 0, 0)"; + const alphaMatch = bgColor.match(/rgba?\([^,]*,[^,]*,[^,]*,\s*([\d.]+)\)/); + const hasZeroAlpha = alphaMatch && parseFloat(alphaMatch[1]) === 0; + + if (isTransparent || hasZeroAlpha) { + // Walk up the DOM to find a non-transparent background + let parent = el.parentElement; + let depth = 0; + while (parent && depth < 5) { + const parentBg = getComputedStyle(parent).getPropertyValue("background-color"); + if (parentBg && parentBg !== "transparent" && parentBg !== "rgba(0, 0, 0, 0)") { + const parentAlphaMatch = parentBg.match(/rgba?\([^,]*,[^,]*,[^,]*,\s*([\d.]+)\)/); + const parentAlpha = parentAlphaMatch ? parseFloat(parentAlphaMatch[1]) : 1; + if (parentAlpha > 0.1) { + bgColor = parentBg; + break; + } + } + parent = parent.parentElement; + depth++; + } + } - const isButton = el.matches( - 'button,[role=button],[data-primary-button],[data-secondary-button],[data-cta],a.button,a.btn,[class*="btn"],[class*="button"],a[class*="bg-brand"],a[class*="bg-primary"],a[class*="bg-accent"],a[type="button"],a[type="button"][class*="bg-"]', - ); + // Check if element is a button - use same logic as sampleElements + let isButton = false; + if (el.matches('button,[role=button],[data-primary-button],[data-secondary-button],[data-cta],a.button,a.btn,[class*="btn"],[class*="button"],a[class*="bg-brand"],a[class*="bg-primary"],a[class*="bg-accent"],a[type="button"],a[type="button"][class*="bg-"]')) { + isButton = true; + } else if (el.tagName.toLowerCase() === 'a') { + // Check if link looks like a button (has button-like styling) + try { + const classes = classNames; + + // Check for common button class patterns (Tailwind, Bootstrap, etc.) + const hasButtonClasses = + /rounded(-md|-lg|-xl|-full)?/.test(classes) || // rounded corners + /px-\d+/.test(classes) || // horizontal padding (px-2, px-4, etc.) + /py-\d+/.test(classes) || // vertical padding (py-2, py-4, etc.) + /p-\d+/.test(classes) || // padding (p-2, p-4, etc.) + (/border/.test(classes) && /rounded/.test(classes)) || // border + rounded + (/inline-flex/.test(classes) && /items-center/.test(classes) && /justify-center/.test(classes)); // flexbox button pattern + + if (hasButtonClasses && rect.width > 50 && rect.height > 25) { + isButton = true; + } else { + // Also check computed styles for button-like appearance + const paddingTop = parseFloat(cs.paddingTop) || 0; + const paddingBottom = parseFloat(cs.paddingBottom) || 0; + const paddingLeft = parseFloat(cs.paddingLeft) || 0; + const paddingRight = parseFloat(cs.paddingRight) || 0; + const hasPadding = paddingTop > 3 || paddingBottom > 3 || paddingLeft > 6 || paddingRight > 6; + const hasMinSize = rect.width > 50 && rect.height > 25; + const hasRounded = parseFloat(cs.borderRadius) > 0; + const hasBorder = parseFloat(cs.borderTopWidth) > 0 || parseFloat(cs.borderBottomWidth) > 0 || + parseFloat(cs.borderLeftWidth) > 0 || parseFloat(cs.borderRightWidth) > 0; + + // Button-like if has padding + (rounded or border) and reasonable size + if (hasPadding && hasMinSize && (hasRounded || hasBorder)) { + isButton = true; + } + } + } catch (e) { + // If we can't check styles, not a button + } + } let isNavigation = false; let hasCTAIndicator = false; @@ -228,56 +363,42 @@ export const getBrandingScript = () => String.raw` el.getAttribute("data-secondary-button") === "true"; if (!hasCTAIndicator) { - isNavigation = - el.matches( - '[role="tab"],[role="menuitem"],[aria-haspopup],[class*="nav-"],[class*="-nav"],[class*="menu-"],[class*="-menu"],[class*="toggle"],[class*="trigger"]', - ) || - !!el.closest( - 'nav, [role="navigation"], [class*="navigation"], [class*="dropdown"], [role="menu"]', - ); - } - } catch (e) {} - - if (isButton && bgColor) { - let isTransparent = - bgColor === "transparent" || bgColor === "rgba(0, 0, 0, 0)"; - let hasLowAlpha = false; - - const alphaMatch = bgColor.match( - /(?:rgba?\([^,]*,[^,]*,[^,]*,\s*|color\([^/]*\/\s*)([\d.]+)\)?$/, - ); - if (alphaMatch) { - const alpha = parseFloat(alphaMatch[1]); - hasLowAlpha = alpha < 0.1; - } - - if (isTransparent || hasLowAlpha) { - let parent = el.parentElement; - let depth = 0; - while (parent && depth < 5) { - const parentBg = - getComputedStyle(parent).getPropertyValue("background-color"); - if ( - parentBg && - parentBg !== "transparent" && - parentBg !== "rgba(0, 0, 0, 0)" - ) { - const parentAlphaMatch = parentBg.match( - /(?:rgba?\([^,]*,[^,]*,[^,]*,\s*|color\([^/]*\/\s*)([\d.]+)\)?$/, - ); - const parentAlpha = parentAlphaMatch - ? parseFloat(parentAlphaMatch[1]) - : 1; - if (parentAlpha >= 0.1) { - bgColor = parentBg; - break; + // Check for navigation-related classes and attributes + const hasNavClass = classNames.includes("nav-") || + classNames.includes("-nav") || + classNames.includes("nav-anchor") || + classNames.includes("nav-link") || + classNames.includes("sidebar-") || + classNames.includes("-sidebar") || + classNames.includes("menu-") || + classNames.includes("-menu") || + classNames.includes("toggle") || + classNames.includes("trigger"); + + // Check for navigation-related roles and attributes + const hasNavRole = el.matches( + '[role="tab"],[role="menuitem"],[role="menuitemcheckbox"],[aria-haspopup],[aria-expanded]', + ); + + // Check if in navigation contexts (sidebar, nav, menu, etc.) + const inNavContext = !!el.closest( + 'nav, [role="navigation"], [role="menu"], [role="menubar"], [class*="navigation"], [class*="dropdown"], [class*="sidebar"], [id*="sidebar"], [id*="navigation"], [id*="nav-"], aside[class*="nav"], aside[id*="nav"]', + ); + + // Check if it's a link in a list item (common nav pattern) + let isNavLink = false; + if (el.tagName.toLowerCase() === "a" && el.parentElement) { + if (el.parentElement.tagName.toLowerCase() === "li") { + const listEl = el.closest("ul, ol"); + if (listEl && listEl.closest('[class*="nav"], [id*="nav"], [class*="sidebar"], [id*="sidebar"]')) { + isNavLink = true; } } - parent = parent.parentElement; - depth++; } + + isNavigation = hasNavClass || hasNavRole || inNavContext || isNavLink; } - } + } catch (e) {} return { tag: el.tagName.toLowerCase(), @@ -307,6 +428,7 @@ export const getBrandingScript = () => String.raw` const findImages = () => { const imgs = []; + const logoCandidates = []; const push = (src, type) => { if (src) imgs.push({ type, src }); }; @@ -318,82 +440,230 @@ export const getBrandingScript = () => String.raw` "twitter", ); - const headerLinkImg = document.querySelector( - 'header a img, header a svg, nav a img, nav a svg, [role="banner"] a img, [role="banner"] a svg, .header a img, .header a svg', - ); + // Helper to collect logo candidate metadata + const collectLogoCandidate = (el, source) => { + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + const isVisible = ( + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" + ); - if (headerLinkImg) { - if (headerLinkImg.tagName.toLowerCase() === "svg") { - const resolvedSvg = resolveSvgStyles(headerLinkImg); - const serializer = new XMLSerializer(); - const svgStr = - "data:image/svg+xml;utf8," + - encodeURIComponent(serializer.serializeToString(resolvedSvg)); - push(svgStr, "logo-svg"); + const inHeader = el.closest('header, nav, [role="banner"], #navbar, [id*="navbar"], [class*="navbar"], [class*="header"]'); + + // Check if logo is inside an anchor tag and get its href + const anchorParent = el.closest('a'); + const href = anchorParent ? (anchorParent.getAttribute('href') || '') : ''; + + const isSvg = el.tagName.toLowerCase() === "svg"; + + // For SVGs, check different properties + let alt = ""; + let srcMatch = false; + let altMatch = false; + let classMatch = false; + let hrefMatch = false; + + if (isSvg) { + // SVGs don't have alt/src, check id, className, aria-label, and title + const svgId = el.id || ""; + const svgClass = el.className?.baseVal || el.className || ""; + const svgAriaLabel = el.getAttribute("aria-label") || ""; + const svgTitle = el.querySelector("title")?.textContent || ""; + const svgText = el.textContent?.trim() || ""; + + alt = svgAriaLabel || svgTitle || svgText || svgId || ""; + altMatch = /logo/i.test(svgId) || /logo/i.test(svgAriaLabel) || /logo/i.test(svgTitle); + classMatch = /logo/i.test(svgClass); + // For SVGs, we'll check if it's in a logo container + srcMatch = el.closest('[class*="logo"], [id*="logo"]') !== null; } else { - push(headerLinkImg.src, "logo"); + // For images + alt = el.alt || ""; + srcMatch = el.src ? /logo/i.test(el.src) : false; + altMatch = /logo/i.test(alt); + const imgClass = el.className || ""; + classMatch = /logo/i.test(imgClass); + } + + let src = ""; + + if (isSvg) { + try { + const resolvedSvg = resolveSvgStyles(el); + const serializer = new XMLSerializer(); + src = "data:image/svg+xml;utf8," + encodeURIComponent(serializer.serializeToString(resolvedSvg)); + } catch (e) { + // If serialization fails, try to serialize the original SVG + try { + const serializer = new XMLSerializer(); + src = "data:image/svg+xml;utf8," + encodeURIComponent(serializer.serializeToString(el)); + } catch (e2) { + // If that also fails, skip this candidate + return; + } + } + } else { + src = el.src || ""; } - } else { - const logoImgCandidates = Array.from(document.images) - .filter( - img => - /logo/i.test(img.alt || "") || - /logo/i.test(img.src) || - img.closest('[class*="logo"]'), - ) - .filter( - img => - !img.closest( - '[class*="testimonial"], [class*="client"], [class*="partner"]', - ), - ); - const logoImg = logoImgCandidates.reduce((best, img) => { - if (!best) return img; - const imgInHeader = img.closest('header, nav, [role="banner"]'); - const bestInHeader = best.closest('header, nav, [role="banner"]'); - if (imgInHeader && !bestInHeader) return img; - if (!imgInHeader && bestInHeader) return best; - const imgRect = img.getBoundingClientRect(); - const bestRect = best.getBoundingClientRect(); - return imgRect.top < bestRect.top ? img : best; + // Check if href indicates homepage/root (common logo pattern) + if (href) { + const normalizedHref = href.toLowerCase().trim(); + // Logos typically link to homepage: "/", "/home", "/index", or just "#" or empty + hrefMatch = normalizedHref === '/' || + normalizedHref === '/home' || + normalizedHref === '/index' || + normalizedHref === '' || + normalizedHref === '#'; + } + + if (src) { + logoCandidates.push({ + src, + alt, + isSvg, + isVisible, + location: inHeader ? "header" : "body", + position: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }, + indicators: { + inHeader: !!inHeader, + altMatch, + srcMatch, + classMatch, + hrefMatch, + }, + href: href || undefined, + source, + }); + } + }; + + // Collect all potential logo candidates (including hidden ones for LLM to decide) + // More comprehensive selectors - include SVGs directly in header/nav, not just in anchors + // Also check for elements with class="header" (not just
tag) + const allLogoSelectors = [ + 'header a img, header a svg, header img, header svg', + '[class*="header"] a img, [class*="header"] a svg, [class*="header"] img, [class*="header"] svg', + 'nav a img, nav a svg, nav img, nav svg', + '[role="banner"] a img, [role="banner"] a svg, [role="banner"] img, [role="banner"] svg', + '#navbar a img, #navbar a svg, #navbar img, #navbar svg', + '[id*="navbar"] a img, [id*="navbar"] a svg, [id*="navbar"] img, [id*="navbar"] svg', + '[class*="navbar"] a img, [class*="navbar"] a svg, [class*="navbar"] img, [class*="navbar"] svg', + 'a[class*="logo"] img, a[class*="logo"] svg', + 'img[class*="nav-logo"], svg[class*="nav-logo"]', + 'img[class*="logo"], svg[class*="logo"]', + ]; + + allLogoSelectors.forEach(selector => { + Array.from(document.querySelectorAll(selector)).forEach(el => { + collectLogoCandidate(el, selector); + }); + }); + + // Also collect from document.images and SVGs + const excludeSelectors = '[class*="testimonial"], [class*="client"], [class*="partner"], [class*="customer"], [class*="case-study"], [id*="testimonial"], [id*="client"], [id*="partner"], [id*="customer"], [id*="case-study"], footer, [class*="footer"]'; + + Array.from(document.images).forEach(img => { + if ( + /logo/i.test(img.alt || "") || + /logo/i.test(img.src) || + img.closest('[class*="logo"]') + ) { + // Exclude customer/partner logos more aggressively + if (!img.closest(excludeSelectors)) { + collectLogoCandidate(img, "document.images"); + } + } + }); + + // Collect SVGs from various sources - catch any SVGs in header/nav that weren't caught by selectors + // This is a fallback to ensure we catch all SVGs that the original logic would have found + Array.from(document.querySelectorAll("svg")).forEach(svg => { + // Skip if already collected by selectors above (check by position to avoid re-serialization) + const svgRect = svg.getBoundingClientRect(); + const alreadyCollected = logoCandidates.some(c => { + if (!c.isSvg) return false; + // Check if position matches (same SVG element) + return Math.abs(c.position.top - svgRect.top) < 1 && + Math.abs(c.position.left - svgRect.left) < 1 && + Math.abs(c.position.width - svgRect.width) < 1 && + Math.abs(c.position.height - svgRect.height) < 1; + }); + if (alreadyCollected) return; + + // Check if SVG matches logo criteria - VERY permissive (like old code) + const hasLogoId = /logo/i.test(svg.id || ""); + const svgClass = svg.className?.baseVal || svg.className || ""; + const hasLogoClass = /logo/i.test(svgClass); + const hasLogoAriaLabel = /logo/i.test(svg.getAttribute("aria-label") || ""); + const hasLogoTitle = /logo/i.test(svg.querySelector("title")?.textContent || ""); + const inHeaderNav = svg.closest('header, nav, [role="banner"], #navbar, [id*="navbar"], [class*="navbar"], [class*="header"]'); + const inLogoContainer = svg.closest('[class*="logo"], [id*="logo"]'); + const inHeaderNavArea = !!inHeaderNav; + const inAnchorInHeader = svg.closest('a') && inHeaderNav; + + // VERY PERMISSIVE: Collect if: + // 1. Has logo indicators (id, class, aria-label, title) + // 2. Is in logo container + // 3. Is in header/nav (most common case - no size constraint, like old code) + // 4. Is in anchor in header/nav + // This matches the original logic which was very permissive + const shouldCollect = + hasLogoId || + hasLogoClass || + hasLogoAriaLabel || + hasLogoTitle || + inLogoContainer || + inHeaderNavArea || + inAnchorInHeader; + + if (shouldCollect) { + // Exclude customer/partner logos more aggressively + const excludeSelectors = '[class*="testimonial"], [class*="client"], [class*="partner"], [class*="customer"], [class*="case-study"], [id*="testimonial"], [id*="client"], [id*="partner"], [id*="customer"], [id*="case-study"], footer, [class*="footer"]'; + if (!svg.closest(excludeSelectors)) { + collectLogoCandidate(svg, "document.querySelectorAll(svg)"); + } + } + }); + + // Remove duplicates (same src) + const seen = new Set(); + const uniqueCandidates = logoCandidates.filter(candidate => { + if (seen.has(candidate.src)) return false; + seen.add(candidate.src); + return true; + }); + + // For backward compatibility, still pick one logo using the old logic + // Try visible candidates first, but fall back to any candidate if none visible + let candidatesToPick = uniqueCandidates.filter(c => c.isVisible); + if (candidatesToPick.length === 0 && uniqueCandidates.length > 0) { + // If no visible candidates, use all candidates (maybe hidden for dark/light mode) + candidatesToPick = uniqueCandidates; + } + + if (candidatesToPick.length > 0) { + const best = candidatesToPick.reduce((best, candidate) => { + if (!best) return candidate; + if (candidate.indicators.inHeader && !best.indicators.inHeader) return candidate; + if (!candidate.indicators.inHeader && best.indicators.inHeader) return best; + return candidate.position.top < best.position.top ? candidate : best; }, null); - if (logoImg) push(logoImg.src, "logo"); - - const svgLogoCandidates = Array.from(document.querySelectorAll("svg")) - .filter( - s => /logo/i.test(s.id) || /logo/i.test(s.className?.baseVal || ""), - ) - .filter( - svg => - !svg.closest( - '[class*="testimonial"], [class*="client"], [class*="partner"]', - ), - ); - - const svgLogo = svgLogoCandidates.reduce((best, svg) => { - if (!best) return svg; - const svgInHeader = svg.closest('header, nav, [role="banner"]'); - const bestInHeader = best.closest('header, nav, [role="banner"]'); - if (svgInHeader && !bestInHeader) return svg; - if (!svgInHeader && bestInHeader) return best; - const svgRect = svg.getBoundingClientRect(); - const bestRect = best.getBoundingClientRect(); - return svgRect.top < bestRect.top ? svg : best; - }, null); - - if (svgLogo) { - const resolvedSvg = resolveSvgStyles(svgLogo); - const serializer = new XMLSerializer(); - const svgStr = - "data:image/svg+xml;utf8," + - encodeURIComponent(serializer.serializeToString(resolvedSvg)); - push(svgStr, "logo-svg"); + if (best) { + if (best.isSvg) { + push(best.src, "logo-svg"); + } else { + push(best.src, "logo"); + } } } - return imgs; + return { images: imgs, logoCandidates: uniqueCandidates }; }; const getTypography = () => { @@ -454,62 +724,365 @@ export const getBrandingScript = () => String.raw` const body = document.body; const html = document.documentElement; - if ( + // Check for explicit dark mode indicators + const hasDarkIndicator = html.classList.contains("dark") || body.classList.contains("dark") || html.classList.contains("dark-mode") || body.classList.contains("dark-mode") || html.getAttribute("data-theme") === "dark" || body.getAttribute("data-theme") === "dark" || - html.getAttribute("data-bs-theme") === "dark" - ) { - return "dark"; - } + html.getAttribute("data-bs-theme") === "dark"; - const bg = - getComputedStyle(body).backgroundColor || - getComputedStyle(html).backgroundColor; - const match = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - if (match) { - const [r, g, b] = match.slice(1, 4).map(n => parseInt(n, 10)); + // Check for explicit light mode indicators + const hasLightIndicator = + html.classList.contains("light") || + body.classList.contains("light") || + html.classList.contains("light-mode") || + body.classList.contains("light-mode") || + html.getAttribute("data-theme") === "light" || + body.getAttribute("data-theme") === "light" || + html.getAttribute("data-bs-theme") === "light"; + + // Check prefers-color-scheme media query + let prefersDark = false; + try { + prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + } catch (e) {} + + // If explicit indicators exist, use them (explicit overrides media query) + if (hasDarkIndicator) return "dark"; + if (hasLightIndicator) return "light"; + + // Analyze background colors from body/html and walk up the DOM if transparent + const getEffectiveBackground = (el) => { + let current = el; + let depth = 0; + while (current && depth < 10) { + const bg = getComputedStyle(current).backgroundColor; + const match = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); + if (match) { + const r = parseInt(match[1], 10); + const g = parseInt(match[2], 10); + const b = parseInt(match[3], 10); + const alpha = match[4] ? parseFloat(match[4]) : 1; + + // Only consider if not fully transparent + if (alpha > 0.1) { + return { r, g, b, alpha }; + } + } + current = current.parentElement; + depth++; + } + return null; + }; + + const bodyBg = getEffectiveBackground(body); + const htmlBg = getEffectiveBackground(html); + const effectiveBg = bodyBg || htmlBg; + + if (effectiveBg) { + const { r, g, b } = effectiveBg; const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance < 0.5 ? "dark" : "light"; + + // Use luminance threshold: < 0.4 = dark, > 0.6 = light, 0.4-0.6 = use media query + if (luminance < 0.4) return "dark"; + if (luminance > 0.6) return "light"; + + // Ambiguous luminance: fall back to prefers-color-scheme + return prefersDark ? "dark" : "light"; } - return "light"; + // No background color found: use prefers-color-scheme, default to light + return prefersDark ? "dark" : "light"; + }; + + const extractBrandName = () => { + // Try multiple sources for brand name + const ogSiteName = document.querySelector('meta[property="og:site_name"]')?.getAttribute("content"); + const title = document.title; + const h1 = document.querySelector("h1")?.textContent?.trim(); + + // Extract domain name as fallback + let domainName = ""; + try { + const hostname = window.location.hostname; + domainName = hostname.replace(/^www\./, "").split(".")[0]; + // Capitalize first letter + domainName = domainName.charAt(0).toUpperCase() + domainName.slice(1); + } catch (e) {} + + // Try to extract brand from title (e.g., "Firecrawl - Documentation" -> "Firecrawl") + let titleBrand = ""; + if (title) { + // Remove common suffixes + titleBrand = title + .replace(/\s*[-|–|—]\s*.*$/, "") // Remove after dash + .replace(/\s*:\s*.*$/, "") // Remove after colon + .replace(/\s*\|.*$/, "") // Remove after pipe + .trim(); + } + + return ogSiteName || titleBrand || h1 || domainName || ""; + }; + + // Helper to check if a color is valid (not transparent) + const isValidBackgroundColor = (color) => { + if (!color || typeof color !== "string") return false; + const normalized = color.toLowerCase().trim(); + // Explicitly transparent + if (normalized === "transparent" || normalized === "rgba(0, 0, 0, 0)") { + return false; + } + // Check for rgba with alpha exactly 0 (not just starting with 0) + const rgbaMatch = normalized.match(/rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*([\d.]+)\s*\)/); + if (rgbaMatch) { + const alpha = parseFloat(rgbaMatch[1]); + // Only filter if alpha is exactly 0 (or very close to 0 due to floating point) + if (alpha < 0.01) { + return false; + } + // rgba(0, 0, 0, 0.8) and similar opaque black backgrounds are valid + return true; + } + // rgb(0, 0, 0) is a valid black background, not transparent + // Check for color() format with alpha 0 + const colorMatch = normalized.match(/color\([^)]+\)/); + if (colorMatch) { + // If it's a color() format, include it (let the processor handle it) + // Modern formats like color(display-p3 0 0 0 / 0.039216) are valid + return true; + } + // Any other non-empty string is valid + return normalized.length > 0; + }; + + const getBackgroundCandidates = () => { + const candidates = []; + + // First, sample actual visible background colors from elements to find the most common + const colorFrequency = new Map(); + const sampleElements = document.querySelectorAll("body, html, main, article, [role='main'], div, section"); + + sampleElements.forEach((el, idx) => { + if (idx < 100) { // Limit to first 100 elements + try { + const bg = getComputedStyle(el).backgroundColor; + if (isValidBackgroundColor(bg)) { + const rect = el.getBoundingClientRect(); + const area = rect.width * rect.height; + // Only count if element has significant area + if (area > 1000) { + const normalized = bg.toLowerCase().trim(); + const currentCount = colorFrequency.get(normalized) || 0; + colorFrequency.set(normalized, currentCount + area); + } + } + } catch (e) { + // Skip errors + } + } + }); + + // Find the most common background color by total area + let mostCommonColor = null; + let maxArea = 0; + for (const [color, area] of colorFrequency.entries()) { + if (area > maxArea) { + maxArea = area; + mostCommonColor = color; + } + } + + // Sample body and html background colors directly + const bodyBg = getComputedStyle(document.body).backgroundColor; + const htmlBg = getComputedStyle(document.documentElement).backgroundColor; + + // Prefer body/html if they're valid, but give extra boost if they match the most common color + if (isValidBackgroundColor(bodyBg)) { + const normalized = bodyBg.toLowerCase().trim(); + const priority = normalized === mostCommonColor ? 15 : 10; + candidates.push({ + color: bodyBg, + source: "body", + priority: priority, + }); + } + + if (isValidBackgroundColor(htmlBg)) { + const normalized = htmlBg.toLowerCase().trim(); + const priority = normalized === mostCommonColor ? 14 : 9; + candidates.push({ + color: htmlBg, + source: "html", + priority: priority, + }); + } + + // Add the most common color as a candidate if it's different from body/html + if (mostCommonColor && mostCommonColor !== bodyBg?.toLowerCase().trim() && mostCommonColor !== htmlBg?.toLowerCase().trim()) { + candidates.push({ + color: mostCommonColor, + source: "most-common-visible", + priority: 12, // High priority but below body/html + area: maxArea, + }); + } + + // Try to get CSS custom properties (common in Tailwind/modern frameworks) + // Create a temporary element to resolve CSS variables + let tempEl = null; + try { + tempEl = document.createElement("div"); + tempEl.style.setProperty("background-color", "var(--background)"); + document.body.appendChild(tempEl); + const tempStyle = getComputedStyle(tempEl); + + const cssVars = [ + "--background", + "--background-light", + "--background-dark", + "--bg-background", + "--bg-background-light", + "--bg-background-dark", + "--color-background", + "--color-background-light", + "--color-background-dark", + ]; + + cssVars.forEach(varName => { + try { + // Try to resolve the CSS variable + tempEl.style.setProperty("background-color", "var(" + varName + ")"); + const resolved = tempStyle.backgroundColor; + + if (isValidBackgroundColor(resolved)) { + candidates.push({ + color: resolved, + source: "css-var:" + varName, + priority: 8, + }); + } + } catch (e) { + // Skip this variable if there's an error + } + }); + + if (tempEl && tempEl.parentElement) { + document.body.removeChild(tempEl); + } + } catch (e) { + // If temp element creation fails, continue without CSS variables + if (tempEl && tempEl.parentElement) { + try { + document.body.removeChild(tempEl); + } catch (e2) { + // Ignore cleanup errors + } + } + } + + // Sample from main containers (header, main, article, etc.) + try { + const mainContainers = document.querySelectorAll("main, article, [role='main'], header, .main, .container"); + mainContainers.forEach((el, idx) => { + if (idx < 5) { // Limit to first 5 + try { + const bg = getComputedStyle(el).backgroundColor; + if (isValidBackgroundColor(bg)) { + const rect = el.getBoundingClientRect(); + const area = rect.width * rect.height; + // Only include if it's a significant area + if (area > 10000) { + candidates.push({ + color: bg, + source: el.tagName.toLowerCase() + "-container", + priority: 5, + area: area, + }); + } + } + } catch (e) { + // Skip this element if there's an error + } + } + }); + } catch (e) { + // If container selection fails, continue + } + + // Helper to normalize white color variants + const normalizeWhite = (color) => { + if (!color) return null; + const normalized = color.toLowerCase().trim(); + // Check for various white formats + if (normalized === "#ffffff" || normalized === "#fff" || + normalized === "rgb(255, 255, 255)" || normalized === "rgba(255, 255, 255, 1)" || + normalized === "rgba(255, 255, 255, 1.0)" || normalized.startsWith("rgba(255, 255, 255")) { + return "rgb(255, 255, 255)"; + } + return normalized; + }; + + // Normalize white variants and boost priority for white backgrounds + const normalizedCandidates = candidates.map(c => { + const normalized = normalizeWhite(c.color); + if (normalized === "rgb(255, 255, 255)") { + // Boost white backgrounds slightly + return { + ...c, + color: normalized, + priority: (c.priority || 0) + 1, + }; + } + return { + ...c, + color: normalized || c.color, + }; + }); + + // Remove duplicates (same color value) + const seen = new Set(); + const unique = normalizedCandidates.filter(c => { + if (!c || !c.color) return false; + const key = c.color.toLowerCase().trim(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); + + // Sort by priority (highest first) + unique.sort((a, b) => (b.priority || 0) - (a.priority || 0)); + + return unique; }; const cssData = collectCSSData(); const elements = sampleElements(); const snapshots = elements.map(getStyleSnapshot); - const images = findImages(); + const imageData = findImages(); const typography = getTypography(); const frameworkHints = detectFrameworkHints(); const colorScheme = detectColorScheme(); - - const buttonDebug = snapshots - .filter(s => s.isButton) - .slice(0, 10) - .map((s, idx) => ({ - index: idx, - text: (s.text || "").substring(0, 50), - bgColor: s.colors.background, - textColor: s.colors.text, - classes: s.classes, - rect: s.rect, - })); + const brandName = extractBrandName(); + const backgroundCandidates = getBackgroundCandidates(); + + // Keep pageBackground for backward compatibility (first candidate) + const pageBackground = backgroundCandidates.length > 0 ? backgroundCandidates[0].color : null; return { branding: { cssData, snapshots, - images, + images: imageData.images, + logoCandidates: imageData.logoCandidates, + brandName, typography, frameworkHints, colorScheme, - debug: { - buttonColors: buttonDebug, - }, + pageBackground, + backgroundCandidates, }, }; })();`;