Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## [0.3.0] - 2026-06-20

### Added

- **Typography extraction:** `extractTypography()` now extracts font families and heading/body styles from any website
- `BrandExtractionResult.typography` field is now part of the standard response shape (non-optional; defaults to `{ fonts: [], styles: {} }` for legacy cache entries)
- Each font asset includes `family`, `source` (`google-fonts` | `typekit` | `self-hosted` | `system`), optional `weights`, and optional `url` (omitted when multiple URLs are present)
- New `TypographyDisplay` component in the web UI renders fonts and live heading/body samples
- New `mcp` tool description mentions typography as part of the brand asset extraction surface

### Scope

- CSS sources: inline `<style>` blocks, Google Fonts (`fonts.googleapis.com` / `fonts.gstatic.com`), Typekit (`use.typekit.net`), and same-origin stylesheets
- Generic CSS family names (sans-serif, serif, monospace, system-ui, etc.) are filtered
- Fonts are deduplicated case-insensitively with weights merged across `@font-face` rules
- External CSS fetches are bounded by a 5-second timeout via `Promise.allSettled` (failures are silently tolerated)

## [0.2.0] - 2026-03-14

### Changed
Expand Down
15 changes: 13 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: openbrand
description: Extract brand assets (logos, colors, backdrop images, brand name) from any website URL. Use when building branded interfaces, generating style guides, or needing brand identity data from a URL.
description: Extract brand assets (logos, colors, backdrop images, brand name, typography) from any website URL. Use when building branded interfaces, generating style guides, or needing brand identity data from a URL.
---

# OpenBrand
Expand Down Expand Up @@ -36,6 +36,7 @@ if (result.ok) {
// result.data.logos → LogoAsset[]
// result.data.colors → ColorAsset[]
// result.data.backdrop_images → BackdropAsset[]
// result.data.typography → TypographyAsset
}
```

Expand All @@ -54,6 +55,7 @@ curl "https://openbrand.sh/api/extract?url=https://stripe.com" \
| **Brand colors** | theme-color meta tags, manifest.json, dominant colors from logo imagery |
| **Backdrop images** | og:image, CSS backgrounds, hero/banner images |
| **Brand name** | og:site_name, application-name, logo alt text, page title |
| **Typography** | Inline `<style>` blocks, Google Fonts, Typekit, same-origin stylesheets (font families with source classification + h1/h2/body styles) |

## Response format

Expand All @@ -69,7 +71,16 @@ curl "https://openbrand.sh/api/extract?url=https://stripe.com" \
],
"backdrops": [
{ "url": "https://...", "description": "Hero image" }
]
],
"typography": {
"fonts": [
{ "family": "Inter", "source": "self-hosted", "weights": [400, 700], "url": "https://..." }
],
"styles": {
"h1": { "fontFamily": "Inter", "fontSize": "48px", "fontWeight": 700 },
"body": { "fontFamily": "Inter", "fontSize": "16px", "fontWeight": 400 }
}
}
}
```

Expand Down
7 changes: 7 additions & 0 deletions app/api/extract/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export async function GET(request: NextRequest) {
if (cached) {
const result = cached.result as BrandExtractionResult;

// Normalize legacy cache entries (pre-typography) so the response shape
// is always complete even for results cached before this field existed.
if (!result.typography) {
result.typography = { fonts: [], styles: {} };
}

console.log(JSON.stringify({ event: "extract_cache_hit", url, source, user_id: userId, brandName: result.brandName }));

// Log the cache hit (fire-and-forget)
Expand Down Expand Up @@ -117,6 +123,7 @@ export async function GET(request: NextRequest) {
logos: extracted.data.logos || [],
colors: extracted.data.colors || [],
backdrops: extracted.data.backdrop_images || [],
typography: extracted.data.typography || { fonts: [], styles: {} },
};

console.log(JSON.stringify({
Expand Down
2 changes: 2 additions & 0 deletions components/brand-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { BrandExtractionResult } from "@/src/types";
import { ColorPalette } from "./color-palette";
import { LogoDisplay } from "./logo-display";
import { BackdropGallery } from "./backdrop-gallery";
import { TypographyDisplay } from "./typography-display";
import { JsonView } from "./json-view";

export function BrandResults({ data }: { data: BrandExtractionResult }) {
Expand Down Expand Up @@ -79,6 +80,7 @@ export function BrandResults({ data }: { data: BrandExtractionResult }) {
<>
<LogoDisplay logos={data.logos} />
<ColorPalette colors={data.colors} />
<TypographyDisplay typography={data.typography} />
<BackdropGallery backdrops={data.backdrops} />
</>
) : (
Expand Down
129 changes: 129 additions & 0 deletions components/typography-display.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

import type { TypographyAsset, FontAsset, HeadingStyle, BodyStyle } from "@/src/types";

const SOURCE_LABELS: Record<FontAsset["source"], string> = {
"google-fonts": "Google Fonts",
typekit: "Typekit",
"self-hosted": "Self-hosted",
system: "System",
};

const SOURCE_STYLES: Record<FontAsset["source"], string> = {
"google-fonts": "bg-blue-50 text-blue-700 border-blue-200",
typekit: "bg-purple-50 text-purple-700 border-purple-200",
"self-hosted": "bg-emerald-50 text-emerald-700 border-emerald-200",
system: "bg-neutral-100 text-neutral-600 border-neutral-200",
};

function styleToString(style: { fontFamily?: string; fontSize?: string; fontWeight?: number }): string {
const parts: string[] = [];
if (style.fontSize) parts.push(`font-size: ${style.fontSize}`);
if (style.fontWeight !== undefined) parts.push(`font-weight: ${style.fontWeight}`);
if (style.fontFamily) parts.push(`font-family: ${style.fontFamily}`);
return parts.join("; ");
}

function FontRow({ font }: { font: FontAsset }) {
return (
<div className="flex items-baseline justify-between gap-3 py-2 border-b border-neutral-100 last:border-0">
<div className="min-w-0 flex-1">
<div className="font-mono text-sm text-neutral-900 truncate">{font.family}</div>
<div className="text-xs text-neutral-500 mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5">
{font.weights && font.weights.length > 0 && (
<span>weights: {font.weights.join(", ")}</span>
)}
{font.url && (
<a
href={font.url}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-600 underline truncate"
>
{font.url}
</a>
)}
</div>
</div>
<span
className={`text-[10px] font-medium px-2 py-0.5 rounded border uppercase tracking-wider shrink-0 ${SOURCE_STYLES[font.source]}`}
>
{SOURCE_LABELS[font.source]}
</span>
</div>
);
}

function StyleSample({
label,
style,
text,
sizeOverride,
}: {
label: string;
style: HeadingStyle | BodyStyle;
text: string;
sizeOverride?: string;
}) {
const cssStyle: React.CSSProperties = {};
if (style.fontFamily) cssStyle.fontFamily = style.fontFamily;
if (style.fontWeight !== undefined) cssStyle.fontWeight = style.fontWeight;
cssStyle.fontSize = sizeOverride ?? style.fontSize ?? "1rem";

return (
<div className="space-y-1.5">
<div className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">
{label}
</div>
<div style={cssStyle} className="text-neutral-900">
{text}
</div>
<code className="text-[10px] text-neutral-400 font-mono block truncate">
{styleToString(style)}
</code>
</div>
);
}

export function TypographyDisplay({ typography }: { typography: TypographyAsset }) {
const { fonts, styles } = typography;
const hasFonts = fonts.length > 0;
const hasStyles = !!(styles.h1 || styles.h2 || styles.body);

if (!hasFonts && !hasStyles) return null;

return (
<div>
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-3">
Typography
</h3>

{hasFonts && (
<div className="mb-5">
<div className="text-xs font-medium text-neutral-700 mb-1.5">
Fonts ({fonts.length})
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-3 py-1">
{fonts.map((font, i) => (
<FontRow key={`${font.family}-${i}`} font={font} />
))}
</div>
</div>
)}

{hasStyles && (
<div className="space-y-4">
{styles.h1 && (
<StyleSample label="H1" style={styles.h1} text="The quick brown fox" sizeOverride="2rem" />
)}
{styles.h2 && (
<StyleSample label="H2" style={styles.h2} text="The quick brown fox" sizeOverride="1.5rem" />
)}
{styles.body && (
<StyleSample label="Body" style={styles.body} text="The quick brown fox jumps over the lazy dog." />
)}
</div>
)}
</div>
);
}
Loading