diff --git a/packages/cli/src/capture/assetDownloader.test.ts b/packages/cli/src/capture/assetDownloader.test.ts
index 782584f83c..857c898baa 100644
--- a/packages/cli/src/capture/assetDownloader.test.ts
+++ b/packages/cli/src/capture/assetDownloader.test.ts
@@ -12,6 +12,7 @@ import {
import type { DesignTokens } from "./types.js";
import type { IconCandidate } from "./faviconRanker.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
+import sharp from "sharp";
describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
it("blocks loopback, private, and metadata IPv4", () => {
@@ -199,6 +200,31 @@ function tokensWithNoSvgs(): DesignTokens {
return { svgs: [], sections: [], ogImage: "" } as unknown as DesignTokens;
}
+/**
+ * openai.com's declared icons, in DOM order, with the attributes the page really carries.
+ * `rankIconCandidates` puts `favicon.svg` first, so that is the file a capture must land.
+ */
+const OPENAI_ICONS: IconCandidate[] = [
+ {
+ rel: "icon",
+ href: "https://openai.example/favicon.svg",
+ sizes: null,
+ type: "image/svg+xml",
+ },
+ {
+ rel: "icon",
+ href: "https://openai.example/favicon.ico",
+ sizes: "48x48",
+ type: "image/x-icon",
+ },
+ {
+ rel: "apple-touch-icon",
+ href: "https://openai.example/apple-icon.png",
+ sizes: "180x180",
+ type: "image/png",
+ },
+];
+
describe("drop counts — why a referenced asset is not in the capture", () => {
afterEach(() => vi.unstubAllGlobals());
@@ -334,31 +360,6 @@ describe("drop counts — why a referenced asset is not in the capture", () => {
describe("asset fetches present the same identity as the page navigation", () => {
afterEach(() => vi.unstubAllGlobals());
- /**
- * openai.com's declared icons, in DOM order, with the attributes the page really carries.
- * `rankIconCandidates` puts `favicon.svg` first, so that is the file a capture must land.
- */
- const OPENAI_ICONS: IconCandidate[] = [
- {
- rel: "icon",
- href: "https://openai.example/favicon.svg",
- sizes: null,
- type: "image/svg+xml",
- },
- {
- rel: "icon",
- href: "https://openai.example/favicon.ico",
- sizes: "48x48",
- type: "image/x-icon",
- },
- {
- rel: "apple-touch-icon",
- href: "https://openai.example/apple-icon.png",
- sizes: "180x180",
- type: "image/png",
- },
- ];
-
/**
* Measured against the real origin: `GET /favicon.svg` answers `403 text/html` to
* `User-Agent: HyperFrames/1.0` and `200 image/svg+xml` to the browser UA the capture
@@ -383,9 +384,9 @@ describe("asset fetches present the same identity as the page navigation", () =>
// next candidate, so the file on disk is chosen by the CDN rather than by the ranker.
await withTempDir(async (dir) => {
vi.stubGlobal("fetch", serveLikeAnAntiBotEdge());
- const { assets } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS);
- expect(assets.map((a) => a.localPath)).toEqual(["assets/favicon.svg"]);
- expect(assets[0]?.url).toBe("https://openai.example/favicon.svg");
+ const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS);
+ expect(icons.headline?.file).toBe("assets/favicon.svg");
+ expect(icons.icons[0]?.url).toBe("https://openai.example/favicon.svg");
});
});
@@ -402,3 +403,137 @@ describe("asset fetches present the same identity as the page navigation", () =>
});
});
});
+
+describe("declared icons — keep them all, headline the bare mark", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ /** A mark knocked out of a full-bleed rounded rect: the shape openai.com's favicon.svg has. */
+ const BADGE_SVG = ``;
+
+ /** One shape with transparent margins. */
+ const BARE_SVG = ``;
+
+ function solidPng(alpha: number): Promise {
+ return sharp({
+ create: { width: 64, height: 64, channels: 4, background: { r: 20, g: 20, b: 20, alpha } },
+ })
+ .png()
+ .toBuffer();
+ }
+
+ const CONTENT_TYPE: Record = {
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".ico": "image/vnd.microsoft.icon",
+ };
+
+ /** Serve each URL the bytes the test names for it; 404 anything unexpected. */
+ function serve(bodies: Record) {
+ return vi.fn(async (url: string) => {
+ const body = Object.entries(bodies).find(([suffix]) => url.endsWith(suffix))?.[1];
+ if (body === undefined) return new Response("nope", { status: 404 });
+ const ext = url.slice(url.lastIndexOf("."));
+ const bytes = typeof body === "string" ? Buffer.from(body) : body;
+ return new Response(new Uint8Array(bytes), {
+ status: 200,
+ headers: { "content-type": CONTENT_TYPE[ext] ?? "application/octet-stream" },
+ });
+ });
+ }
+
+ it("writes one file per declared icon, named for its rel and sizes", async () => {
+ await withTempDir(async (dir) => {
+ vi.stubGlobal(
+ "fetch",
+ serve({
+ "favicon.svg": BADGE_SVG,
+ "favicon.ico": Buffer.from("not a decodable ico"),
+ "apple-icon.png": await solidPng(1),
+ }),
+ );
+ const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS);
+ expect(icons.icons.map((i) => i.file)).toEqual([
+ "assets/icon-icon-unsized.svg",
+ "assets/icon-apple-touch-icon-180x180.png",
+ "assets/icon-icon-48x48.ico",
+ ]);
+ });
+ });
+
+ it("records openai.com's icons as badges, and says the headline had no bare mark to pick", async () => {
+ // Measured against the real files: the SVG is a mark knocked out of a full-bleed disc, and
+ // the apple-touch PNG is an opaque white square, because Apple composites those onto an
+ // opaque tile. openai.com declares no bare mark at all, so the headline falls back to the
+ // ranker and the manifest has to say so rather than implying a preference was satisfied.
+ await withTempDir(async (dir) => {
+ vi.stubGlobal(
+ "fetch",
+ serve({
+ "favicon.svg": BADGE_SVG,
+ "favicon.ico": Buffer.from("not a decodable ico"),
+ "apple-icon.png": await solidPng(1),
+ }),
+ );
+ const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS);
+ expect(icons.icons.map((i) => i.shape)).toEqual(["badge", "badge", "unknown"]);
+ expect(icons.headline).toMatchObject({
+ file: "assets/favicon.svg",
+ source: "assets/icon-icon-unsized.svg",
+ shape: "badge",
+ });
+ expect(icons.headline?.reason).toContain("no bare-mark candidate");
+ });
+ });
+
+ it("headlines a lower-ranked bare mark over the better-ranked badge", async () => {
+ // The ordering rule, on its own. By declared quality the SVG wins outright; by shape the
+ // PNG does, and shape is what the brand band needs. Under the old rule this lands
+ // assets/favicon.svg.
+ await withTempDir(async (dir) => {
+ vi.stubGlobal(
+ "fetch",
+ serve({ "favicon.svg": BADGE_SVG, "apple-icon.png": await solidPng(0) }),
+ );
+ const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], [
+ { rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" },
+ {
+ rel: "apple-touch-icon",
+ href: "https://x.test/apple-icon.png",
+ sizes: "180x180",
+ type: "image/png",
+ },
+ ] as IconCandidate[]);
+ expect(icons.headline).toMatchObject({
+ file: "assets/favicon.png",
+ source: "assets/icon-apple-touch-icon-180x180.png",
+ shape: "bare-mark",
+ rank: 1,
+ });
+ expect(icons.headline?.reason).toContain("bare mark preferred over 1 badge");
+ });
+ });
+
+ it("headlines the SVG when it is the bare mark", async () => {
+ await withTempDir(async (dir) => {
+ vi.stubGlobal("fetch", serve({ "favicon.svg": BARE_SVG }));
+ const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], [
+ { rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" },
+ ] as IconCandidate[]);
+ expect(icons.headline).toMatchObject({ file: "assets/favicon.svg", shape: "bare-mark" });
+ });
+ });
+
+ it("keeps assets/favicon. so a stem match still finds the icon", async () => {
+ await withTempDir(async (dir) => {
+ vi.stubGlobal("fetch", serve({ "favicon.svg": BARE_SVG }));
+ const { assets } = await downloadAssets(tokensWithNoSvgs(), dir, [], [
+ { rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" },
+ ] as IconCandidate[]);
+ expect(assets.map((a) => a.localPath)).toContain("assets/favicon.svg");
+ });
+ });
+});
diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts
index 223d6ab03f..0f827be6d2 100644
--- a/packages/cli/src/capture/assetDownloader.ts
+++ b/packages/cli/src/capture/assetDownloader.ts
@@ -12,6 +12,7 @@ import type { DesignTokens, DownloadedAsset } from "./types.js";
import type { CatalogedAsset } from "./assetCataloger.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
import { rankIconCandidates, type IconCandidate } from "./faviconRanker.js";
+import { classifyIcon, type IconShape } from "./iconClassifier.js";
interface DownloadBudgetOptions {
remainingMs?: () => number;
@@ -90,6 +91,209 @@ export function toStandaloneSvg(outerHTML: string): string {
return outerHTML.replace(original, tag);
}
+/** One icon the page declared, as downloaded and inspected. */
+export interface IconRecord {
+ /** Path inside the capture, e.g. `assets/icon-apple-touch-icon-180x180.png`. */
+ file: string;
+ url: string;
+ rel: string;
+ sizes: string | null;
+ type: string | null;
+ /** Position in the declared-quality ranking; 0 is the best-ranked icon. */
+ rank: number;
+ shape: IconShape;
+ shapeReason: string;
+}
+
+/**
+ * Every icon a page declared, plus which one became `assets/favicon.` and why.
+ *
+ * The `reason` field is the point of this file. The downloader chooses among candidates, and a
+ * choice whose losers are invisible is indistinguishable from having had no choice at all — the
+ * failure mode that let a silent 403 substitute a worse icon without anything recording it.
+ */
+export interface IconManifest {
+ schema: "hyperframes.capture.icons.v1";
+ headline: {
+ /** The backwards-compatible stem, e.g. `assets/favicon.png`. */
+ file: string;
+ /** The `icons[].file` it was copied from, so a consumer can avoid showing it twice. */
+ source: string;
+ rank: number;
+ shape: IconShape;
+ reason: string;
+ } | null;
+ icons: IconRecord[];
+}
+
+function emptyIconManifest(): IconManifest {
+ return { schema: "hyperframes.capture.icons.v1", headline: null, icons: [] };
+}
+
+/** Icons downloaded per capture. Pages declare up to a dozen apple-touch sizes; a few is plenty. */
+const MAX_ICONS = 8;
+
+/**
+ * Headline preference, deliberately BINARY: a positively identified bare mark first, then the
+ * existing declared-quality ranking for everything else.
+ *
+ * `unknown` is not promoted above `badge`. Ranking it in between looked reasonable and is wrong:
+ * a `.ico` cannot be decoded for inspection, so on a site whose icons are all badges the
+ * undecodable legacy file would outrank the good SVG purely for being unexaminable. Absence of
+ * evidence is not evidence of a bare mark.
+ */
+const SHAPE_PREFERENCE: Record = { "bare-mark": 0, unknown: 1, badge: 1 };
+
+/** `` -> `icon-apple-touch-icon-180x180`. */
+function iconFileStem(icon: IconCandidate): string {
+ const slug = `${icon.rel} ${icon.sizes ?? "unsized"}`
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return `icon-${slug || "unknown"}`;
+}
+
+function headlineReason(chosen: IconRecord, all: IconRecord[]): string {
+ const badges = all.filter((i) => i.shape === "badge").length;
+ if (chosen.shape === "bare-mark") {
+ return badges > 0
+ ? `bare mark preferred over ${badges} badge(s)`
+ : "the only candidate is a bare mark";
+ }
+ const bare = all.filter((i) => i.shape === "bare-mark").length;
+ if (bare > 0) return `best-ranked bare mark was unavailable; used a ${chosen.shape}`;
+ return `no bare-mark candidate among ${all.length} icon(s); used the best-ranked ${chosen.shape}`;
+}
+
+/**
+ * Download every icon the page declared, classify each, and copy the best onto the historical
+ * `assets/favicon.` stem.
+ *
+ * Downloading all of them rather than stopping at the first success is what makes the choice
+ * possible: shape can only be read from bytes, so a ranking that stops early can never know
+ * whether the candidate it skipped was the bare mark the brand band actually wants.
+ */
+/** A stem not yet used in this capture; sites declare the same rel+sizes pair more than once. */
+function uniqueStem(icon: IconCandidate, used: Set): string {
+ const base = iconFileStem(icon);
+ let stem = base;
+ for (let n = 2; used.has(stem); n++) stem = `${base}-${n}`;
+ used.add(stem);
+ return stem;
+}
+
+/** Fetch one icon, write it under `stem`, and inspect what it is. Null when it did not arrive. */
+async function fetchAndInspectIcon(
+ icon: IconCandidate,
+ rank: number,
+ stem: string,
+ outputDir: string,
+ timeoutMs: number,
+): Promise<{ record: IconRecord; buffer: Buffer } | null> {
+ const ext = extname(new URL(icon.href).pathname) || ".ico";
+ const buffer = await fetchBuffer(icon.href, timeoutMs);
+ if (!buffer) return null;
+
+ const file = `assets/${stem}${ext}`;
+ writeFileSync(join(outputDir, file), buffer);
+ const verdict = await classifyIcon(buffer, ext);
+ return {
+ buffer,
+ record: {
+ file,
+ url: icon.href,
+ rel: icon.rel,
+ sizes: icon.sizes ?? null,
+ type: icon.type ?? null,
+ rank,
+ shape: verdict.shape,
+ shapeReason: verdict.reason,
+ },
+ };
+}
+
+/** Copy the winning icon onto the historical `assets/favicon.` stem consumers match on. */
+function promoteHeadline(
+ manifest: IconManifest,
+ bytesByFile: Map,
+ outputDir: string,
+): DownloadedAsset | null {
+ const chosen = [...manifest.icons].sort(
+ (a, b) => SHAPE_PREFERENCE[a.shape] - SHAPE_PREFERENCE[b.shape] || a.rank - b.rank,
+ )[0];
+ if (!chosen) return null;
+
+ const file = `assets/favicon${extname(chosen.file)}`;
+ writeFileSync(join(outputDir, file), bytesByFile.get(chosen.file)!);
+ manifest.headline = {
+ file,
+ source: chosen.file,
+ rank: chosen.rank,
+ shape: chosen.shape,
+ reason: headlineReason(chosen, manifest.icons),
+ };
+ return { url: chosen.url, localPath: file, type: "favicon" };
+}
+
+/**
+ * Download every icon the page declared, classify each, and promote the best one.
+ *
+ * Downloading all of them rather than stopping at the first success is what makes the choice
+ * possible: shape can only be read from bytes, so a ranking that stops early can never know
+ * whether the candidate it skipped was the bare mark the brand band actually wants.
+ */
+async function downloadDeclaredIcons(
+ faviconLinks: IconCandidate[],
+ outputDir: string,
+ drops: AssetDropCounts,
+ options: DownloadBudgetOptions,
+): Promise<{ assets: DownloadedAsset[]; manifest: IconManifest }> {
+ const ranked = rankIconCandidates(faviconLinks);
+ const attempted = ranked.slice(0, MAX_ICONS);
+ drops["cap-reached"] += ranked.length - attempted.length;
+
+ const assets: DownloadedAsset[] = [];
+ const manifest = emptyIconManifest();
+ const bytesByFile = new Map();
+ const usedStems = new Set();
+
+ for (const [rank, icon] of attempted.entries()) {
+ const remainingMs = options.remainingMs?.() ?? 10_000;
+ if (remainingMs <= 0) {
+ drops["budget-exhausted"] += attempted.length - rank;
+ break;
+ }
+ try {
+ const stem = uniqueStem(icon, usedStems);
+ const got = await fetchAndInspectIcon(
+ icon,
+ rank,
+ stem,
+ outputDir,
+ Math.min(10_000, remainingMs),
+ );
+ if (!got) {
+ drops.unavailable++;
+ continue;
+ }
+ bytesByFile.set(got.record.file, got.buffer);
+ manifest.icons.push(got.record);
+ assets.push({ url: icon.href, localPath: got.record.file, type: "favicon" });
+ } catch {
+ drops.unavailable++;
+ }
+ }
+
+ try {
+ const headline = promoteHeadline(manifest, bytesByFile, outputDir);
+ if (headline) assets.push(headline);
+ } catch {
+ drops.unavailable++;
+ }
+
+ return { assets, manifest };
+}
+
// fallow-ignore-next-line complexity
export async function downloadAssets(
tokens: DesignTokens,
@@ -97,13 +301,14 @@ export async function downloadAssets(
catalogedAssets?: CatalogedAsset[],
faviconLinks?: IconCandidate[],
options: DownloadBudgetOptions = {},
-): Promise<{ assets: DownloadedAsset[]; drops: AssetDropCounts }> {
+): Promise<{ assets: DownloadedAsset[]; drops: AssetDropCounts; icons: IconManifest }> {
const assetsDir = join(outputDir, "assets");
mkdirSync(assetsDir, { recursive: true });
const assets: DownloadedAsset[] = [];
const drops = noDrops();
const downloadedUrls = new Set();
+ let icons: IconManifest = emptyIconManifest();
mkdirSync(join(outputDir, "assets", "svgs"), { recursive: true });
const usedSvgNames = new Set();
@@ -135,30 +340,10 @@ export async function downloadAssets(
}
}
- // 2. Favicon — best declared candidate first, falling back through the rest on failure.
- const icons = rankIconCandidates(faviconLinks || []);
- for (const [index, icon] of icons.entries()) {
- const remainingMs = options.remainingMs?.() ?? 10_000;
- if (remainingMs <= 0) {
- drops["budget-exhausted"] += icons.length - index;
- break;
- }
- if (!icon.href) continue;
- try {
- const ext = extname(new URL(icon.href).pathname) || ".ico";
- const name = `favicon${ext}`;
- const localPath = `assets/${name}`;
- const buffer = await fetchBuffer(icon.href, Math.min(10_000, remainingMs));
- if (buffer) {
- writeFileSync(join(outputDir, localPath), buffer);
- assets.push({ url: icon.href, localPath, type: "favicon" });
- break;
- }
- drops.unavailable++;
- } catch {
- drops.unavailable++;
- }
- }
+ // 2. Icons — keep every one the page declares, then choose the headline among them.
+ const iconPass = await downloadDeclaredIcons(faviconLinks || [], outputDir, drops, options);
+ assets.push(...iconPass.assets);
+ icons = iconPass.manifest;
// 3. Images — use the catalog as the single source of truth (highest resolution, deduplicated)
// If the catalog is empty, asset download produces zero images — this is surfaced as a warning
@@ -299,7 +484,7 @@ export async function downloadAssets(
}
}
- return { assets, drops };
+ return { assets, drops, icons };
}
/** Normalize URL for deduplication — unwrap Next.js image proxy, strip w/q params */
diff --git a/packages/cli/src/capture/iconClassifier.test.ts b/packages/cli/src/capture/iconClassifier.test.ts
new file mode 100644
index 0000000000..21b54515e6
--- /dev/null
+++ b/packages/cli/src/capture/iconClassifier.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vitest";
+import sharp from "sharp";
+import { classifyIcon, classifyRasterIcon, classifySvgIcon } from "./iconClassifier.js";
+
+/**
+ * openai.com's `/favicon.svg`, trimmed to the structure that decides the verdict: a full-bleed
+ * rounded rect, then the mark knocked out of it. Both fills are CSS custom properties behind a
+ * `prefers-color-scheme` query, which is why this is classified from markup — librvsg resolves
+ * neither and rasterises the whole file to transparent.
+ */
+const OPENAI_FAVICON_SVG = ``;
+
+const BARE_MARK_SVG = ``;
+
+/** A square canvas that is uniformly transparent or uniformly opaque. */
+function solidPng(alpha: number): Promise {
+ return sharp({
+ create: { width: 64, height: 64, channels: 4, background: { r: 20, g: 20, b: 20, alpha } },
+ })
+ .png()
+ .toBuffer();
+}
+
+/** An opaque disc that touches all four edges on an otherwise transparent canvas. */
+function discPng(): Promise {
+ const mask = Buffer.from(
+ ``,
+ );
+ return sharp({
+ create: { width: 64, height: 64, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
+ })
+ .composite([{ input: mask }])
+ .png()
+ .toBuffer();
+}
+
+describe("classifySvgIcon", () => {
+ it("calls a mark knocked out of a full-bleed rounded rect a badge", () => {
+ const verdict = classifySvgIcon(OPENAI_FAVICON_SVG);
+ expect(verdict.shape).toBe("badge");
+ expect(verdict.reason).toContain("full-bleed");
+ });
+
+ it("calls a single shape with margins a bare mark", () => {
+ expect(classifySvgIcon(BARE_MARK_SVG).shape).toBe("bare-mark");
+ });
+
+ it("does not mistake a clipPath's own rect for the backdrop", () => {
+ // The `` in the openai file contains a full-bleed ``; reading elements in
+ // document order without skipping non-painting containers would judge that one.
+ const clipOnly = ``;
+ expect(classifySvgIcon(clipOnly).shape).toBe("bare-mark");
+ });
+
+ it("treats a full-bleed circle as a badge", () => {
+ const disc = ``;
+ expect(classifySvgIcon(disc).shape).toBe("badge");
+ });
+
+ it("reports unknown rather than guessing when there is nothing to measure against", () => {
+ const noCanvas = ``;
+ expect(classifySvgIcon(noCanvas).shape).toBe("unknown");
+ });
+});
+
+describe("classifyRasterIcon", () => {
+ it("calls an opaque square a badge", async () => {
+ expect((await classifyRasterIcon(await solidPng(1))).shape).toBe("badge");
+ });
+
+ it("calls a transparent canvas a bare mark", async () => {
+ expect((await classifyRasterIcon(await solidPng(0))).shape).toBe("bare-mark");
+ });
+
+ it("calls a full-bleed disc a badge, which sampling only the corners would miss", async () => {
+ // All four corners of an inscribed disc are transparent. The edge midpoints are not.
+ expect((await classifyRasterIcon(await discPng())).shape).toBe("badge");
+ });
+
+ it("reports unknown for a format it cannot decode rather than throwing", async () => {
+ // .ico is the real case: libvips ships no decoder for it.
+ const verdict = await classifyRasterIcon(Buffer.from("not an image"));
+ expect(verdict.shape).toBe("unknown");
+ expect(verdict.reason).toContain("could not be decoded");
+ });
+});
+
+describe("classifyIcon", () => {
+ it("routes .svg to markup inspection and everything else to pixels", async () => {
+ expect((await classifyIcon(Buffer.from(BARE_MARK_SVG), ".svg")).shape).toBe("bare-mark");
+ expect((await classifyIcon(await solidPng(1), ".png")).shape).toBe("badge");
+ });
+});
diff --git a/packages/cli/src/capture/iconClassifier.ts b/packages/cli/src/capture/iconClassifier.ts
new file mode 100644
index 0000000000..92ff5a7f7d
--- /dev/null
+++ b/packages/cli/src/capture/iconClassifier.ts
@@ -0,0 +1,189 @@
+/**
+ * Tell a site's BARE MARK apart from its BADGE.
+ *
+ * A brand band wants the logo artwork, not the browser-tab tile. Those are usually the same
+ * drawing in two packagings:
+ *
+ * bare mark — the glyph alone, transparent margins. Reads on any background.
+ * badge — that glyph knocked out of, or sitting on, a full-bleed disc or square.
+ *
+ * Shown on a transparency checker a badge reads as a solid blob, which is exactly the complaint
+ * that produced this code. Neither is wrong; they belong in different tiles.
+ *
+ * Both rules below are deliberately cheap and deterministic — no rendering for SVG, one small
+ * resize for raster. They answer "does this drawing carry its own backdrop", nothing more.
+ */
+
+import sharp from "sharp";
+import { parseHTML } from "linkedom";
+
+/** What a downloaded icon is, as a drawing. `unknown` when the format cannot be inspected. */
+export type IconShape = "bare-mark" | "badge" | "unknown";
+
+export interface IconVerdict {
+ shape: IconShape;
+ /** Why, in one human-readable clause. Recorded in the manifest so a pick is never unexplained. */
+ reason: string;
+}
+
+/** Elements that describe paint without applying it; a shape inside one is never the backdrop. */
+const NON_PAINTING = new Set(["defs", "clippath", "mask", "style", "title", "desc", "metadata"]);
+
+/** Elements that paint. `g` is a container, so it is walked through rather than judged. */
+const PAINTING = new Set(["rect", "circle", "ellipse", "path", "polygon", "image", "use"]);
+
+function firstPaintedElement(root: Element): Element | null {
+ for (const child of Array.from(root.children)) {
+ const tag = child.tagName.toLowerCase();
+ if (NON_PAINTING.has(tag)) continue;
+ if (tag === "g" || tag === "svg") {
+ const nested = firstPaintedElement(child);
+ if (nested) return nested;
+ continue;
+ }
+ if (PAINTING.has(tag)) return child;
+ }
+ return null;
+}
+
+function num(el: Element, attr: string): number | null {
+ const raw = el.getAttribute(attr);
+ if (raw === null) return null;
+ const n = Number.parseFloat(raw);
+ return Number.isFinite(n) ? n : null;
+}
+
+function positiveBox(w: number | null, h: number | null): { w: number; h: number } | null {
+ return w !== null && h !== null && w > 0 && h > 0 ? { w, h } : null;
+}
+
+/** The `viewBox`'s own width/height, ignoring its origin. */
+function canvasFromViewBox(viewBox: string | null): { w: number; h: number } | null {
+ if (!viewBox) return null;
+ const parts = viewBox
+ .trim()
+ .split(/[\s,]+/)
+ .map(Number);
+ if (parts.length !== 4 || !parts.every(Number.isFinite)) return null;
+ return positiveBox(parts[2]!, parts[3]!);
+}
+
+/** viewBox width/height, falling back to the width/height attributes. */
+function canvasOf(svg: Element): { w: number; h: number } | null {
+ return (
+ canvasFromViewBox(svg.getAttribute("viewBox")) ??
+ positiveBox(num(svg, "width"), num(svg, "height"))
+ );
+}
+
+/** A backdrop covers essentially the whole canvas; anything smaller leaves margins. */
+const FULL_BLEED_RATIO = 0.95;
+
+/**
+ * Classify an SVG by MARKUP, not by rendering it.
+ *
+ * Rendering would be the obvious approach and it does not work here: these files routinely fill
+ * with CSS custom properties under a `prefers-color-scheme` query, and librsvg (what sharp uses)
+ * resolves neither — openai.com's favicon rasterises to a fully transparent image through sharp
+ * while Chrome draws it correctly. Markup is the only reliable signal available offline.
+ */
+export function classifySvgIcon(source: string): IconVerdict {
+ const { document } = parseHTML(`${source}`);
+ const svg = document.querySelector("svg");
+ if (!svg) return { shape: "unknown", reason: "no