diff --git a/packages/cli/src/capture/designStyleExtractor.ts b/packages/cli/src/capture/designStyleExtractor.ts index b27529be3..601009117 100644 --- a/packages/cli/src/capture/designStyleExtractor.ts +++ b/packages/cli/src/capture/designStyleExtractor.ts @@ -21,8 +21,11 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => { function rgbToHex(color) { if (!color) return ""; if (color.startsWith('#')) return color.toUpperCase(); - var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/); + var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*([\\d.]+))?/); if (!m) return color; + // fully-transparent fill (rgba(...,0)) → sentinel, NOT #000000 — otherwise a transparent + // chip/tab/stat ground reads as solid black on a light-ground site. + if (m[4] !== undefined && parseFloat(m[4]) === 0) return "transparent"; return '#' + ((1<<24) + (parseInt(m[1])<<16) + (parseInt(m[2])<<8) + parseInt(m[3])).toString(16).slice(1).toUpperCase(); } @@ -188,6 +191,100 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => { }; } + // ── 4b. Chips / pills / badges / tags ── + // selector by class-substring, PLUS a shape fallback (small + fully-rounded + short text) so + // hashed/utility class names (Tailwind, CSS-modules) still get caught. + var chipEls = Array.from(document.querySelectorAll( + '[class*="pill"], [class*="Pill"], [class*="badge"], [class*="Badge"], ' + + '[class*="chip"], [class*="Chip"], [class*="tag"], [class*="Tag"]' + )).filter(function(el) { + if (!isVisible(el) || el.closest('nav, [role="navigation"]')) return false; + var r = el.getBoundingClientRect(); + var txt = (el.textContent || "").trim(); + return r.height > 0 && r.height <= 60 && r.width <= 360 && txt.length > 0 && txt.length <= 40; + }); + var shapeChips = Array.from(document.querySelectorAll('span, div, li, a')).slice(0, 500).filter(function(el) { + if (!isVisible(el) || el.closest('nav, [role="navigation"]')) return false; + var st = getComputedStyle(el); + var r = el.getBoundingClientRect(); + var rad = parseFloat(st.borderRadius) || 0; + var txt = (el.textContent || "").trim(); + var hasSkin = (st.backgroundColor && st.backgroundColor !== "rgba(0, 0, 0, 0)" && st.backgroundColor !== "transparent") || (parseFloat(st.borderTopWidth) || 0) > 0; + return hasSkin && r.height > 0 && r.height <= 44 && r.width <= 260 && rad >= (r.height / 2) - 1 && txt.length > 0 && txt.length <= 24 && el.children.length <= 1; + }); + var allChips = chipEls.concat(shapeChips); + var chipMap = {}; + for (var chi = 0; chi < allChips.length; chi++) { + var chs = getStyles(allChips[chi]); + var chKey = chs.background + "|" + chs.borderRadius + "|" + chs.border; + if (!chipMap[chKey]) { + chipMap[chKey] = { + label: (allChips[chi].textContent || "").trim().slice(0, 24) || "chip", + background: chs.background, color: chs.color, padding: chs.padding, borderRadius: chs.borderRadius, + border: chs.border, boxShadow: chs.boxShadow, fontSize: chs.fontSize, fontWeight: chs.fontWeight, height: chs.height + }; + } + } + var chips = Object.values(chipMap).slice(0, 4); + + // ── 4c. Stat / metric cells (a big numeral + a small label) ── + var statEls = Array.from(document.querySelectorAll( + '[class*="stat"], [class*="Stat"], [class*="metric"], [class*="Metric"], ' + + '[class*="kpi"], [class*="KPI"], [class*="figure"], [class*="Figure"]' + )).filter(function(el) { + if (!isVisible(el)) return false; + var r = el.getBoundingClientRect(); + return r.height > 0 && r.height <= 400 && r.width <= 600; + }).slice(0, 14); + function biggestFontChild(el) { + var best = 0, bestEl = null, kids = el.querySelectorAll("*"); + for (var i = 0; i < kids.length; i++) { + if (!isVisible(kids[i])) continue; + var fz = parseFloat(getComputedStyle(kids[i]).fontSize) || 0; + if (fz > best) { best = fz; bestEl = kids[i]; } + } + return bestEl; + } + var statMap = {}; + for (var sti = 0; sti < statEls.length; sti++) { + var numEl = biggestFontChild(statEls[sti]) || statEls[sti]; + var numFz = parseFloat(getComputedStyle(numEl).fontSize) || 0; + if (numFz < 28) continue; // needs a genuinely large numeral to count as a stat cell + var cst = getStyles(statEls[sti]); + var nst = getStyles(numEl); + var stKey = Math.round(numFz) + "|" + cst.background; + if (!statMap[stKey]) { + statMap[stKey] = { + background: cst.background, borderRadius: cst.borderRadius, border: cst.border, boxShadow: cst.boxShadow, + numberFontSize: nst.fontSize, numberFontWeight: nst.fontWeight, numberColor: nst.color + }; + } + } + var statCells = Object.values(statMap).slice(0, 3); + + // ── 4d. Tabs ── + var tabEls = Array.from(document.querySelectorAll( + '[role="tab"], [class*="tab"]:not([class*="table"]):not([class*="Table"])' + )).filter(function(el) { + if (!isVisible(el)) return false; + var r = el.getBoundingClientRect(); + var txt = (el.textContent || "").trim(); + return r.height > 0 && r.height <= 64 && txt.length > 0 && txt.length <= 30; + }).slice(0, 12); + var tabMap = {}; + for (var tbi = 0; tbi < tabEls.length; tbi++) { + var tst = getStyles(tabEls[tbi]); + var tKey = tst.background + "|" + tst.color + "|" + tst.border; + if (!tabMap[tKey]) { + tabMap[tKey] = { + label: (tabEls[tbi].textContent || "").trim().slice(0, 24) || "tab", + background: tst.background, color: tst.color, padding: tst.padding, borderRadius: tst.borderRadius, + border: tst.border, boxShadow: tst.boxShadow, fontSize: tst.fontSize, fontWeight: tst.fontWeight, height: tst.height + }; + } + } + var tabs = Object.values(tabMap).slice(0, 4); + // ── 5. Spacing scale ── // Collect padding and margin values from visible elements var spacingCounts = {}; @@ -273,7 +370,10 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => { shadows: shadows, buttons: buttons, cards: cards, - nav: nav + nav: nav, + chips: chips, + statCells: statCells, + tabs: tabs }; })()`; diff --git a/packages/cli/src/capture/fontMetadataExtractor.test.ts b/packages/cli/src/capture/fontMetadataExtractor.test.ts index 38b23cf39..d18d5a3be 100644 --- a/packages/cli/src/capture/fontMetadataExtractor.test.ts +++ b/packages/cli/src/capture/fontMetadataExtractor.test.ts @@ -6,6 +6,7 @@ import { canonicalizeFamily, extractFontMetadata, inferWeightFromSubfamily, + isIconCharacterSet, } from "./fontMetadataExtractor.js"; describe("inferWeightFromSubfamily", () => { @@ -174,3 +175,41 @@ describe("extractFontMetadata", () => { } }); }); + +describe("isIconCharacterSet", () => { + // codepoint helpers + const latin = Array.from({ length: 26 }, (_, i) => 0x41 + i); // A-Z + const pua = Array.from({ length: 20 }, (_, i) => 0xe000 + i); // Private Use Area + const puaSupp = [0xf0000, 0xf0001, 0x10fffd]; // supplementary PUA-A/B + + it("flags a font whose glyphs are mostly Private-Use-Area (an icon font)", () => { + // ~63% PUA, mirroring a real "hushly" icon set + expect(isIconCharacterSet([...pua, ...latin.slice(0, 12)])).toBe(true); + }); + + it("flags a near-100% PUA set (Font-Awesome-style)", () => { + expect(isIconCharacterSet([...pua, ...puaSupp])).toBe(true); + }); + + it("does NOT flag a normal Latin text font", () => { + expect(isIconCharacterSet(latin)).toBe(false); + }); + + it("does NOT flag a unicode-range subset with no Latin letters but 0% PUA", () => { + // e.g. a cyrillic/latin-ext subset served by Next.js/Google Fonts — the false-positive case + const cyrillic = Array.from({ length: 40 }, (_, i) => 0x0410 + i); + expect(isIconCharacterSet(cyrillic)).toBe(false); + }); + + it("does NOT flag a PUA-heavy TEXT font that still has a full Latin alphabet", () => { + // Apple SF Pro (~81% PUA, ships SF Symbols in the PUA) and Descript's Booton (~50% PUA) are + // full text fonts — the Latin alphabet gate must keep them out of the icon bucket. + const fullAlphabet = latin; // A-Z (26) + const sfProLike = [...fullAlphabet, ...Array.from({ length: 200 }, (_, i) => 0xe000 + i)]; + expect(isIconCharacterSet(sfProLike)).toBe(false); + }); + + it("returns false for an empty character set", () => { + expect(isIconCharacterSet([])).toBe(false); + }); +}); diff --git a/packages/cli/src/capture/fontMetadataExtractor.ts b/packages/cli/src/capture/fontMetadataExtractor.ts index 121cbe1c9..4dba18dd0 100644 --- a/packages/cli/src/capture/fontMetadataExtractor.ts +++ b/packages/cli/src/capture/fontMetadataExtractor.ts @@ -67,6 +67,12 @@ export interface FontFileMetadata { variationAxes: string[]; /** Whether identification came from the binary name table (the trustworthy source). */ identified: boolean; + /** + * True when this is an ICON font — it has no basic Latin letters, or its glyphs live mostly in + * the Unicode Private Use Area (Font Awesome, swiper-icons, a custom "hushly" icon set, …). + * Consumers must NOT treat it as a text family: binding it to one renders headings as tofu/icons. + */ + isIcon: boolean; } export interface FontFamilySummary { @@ -149,6 +155,7 @@ function readSingleFont(fullPath: string, filename: string): FontFileMetadata { style: "normal", variationAxes: [], identified: false, + isIcon: false, }; try { @@ -185,12 +192,45 @@ function readSingleFont(fullPath: string, filename: string): FontFileMetadata { style, variationAxes, identified: true, + isIcon: detectIconFont(font), }; } catch { return empty; } } +/** + * Detect an ICON font by glyph coverage rather than by name (icon fonts often have arbitrary + * names like "hushly" or "swiper-icons" that no name-list can enumerate). See isIconCharacterSet + * for the rule (lacks a Latin alphabet AND mostly Private-Use-Area glyphs). `characterSet` is a + * fontkit runtime member not always in its typings, so it's read through a narrow local shape. + */ +function detectIconFont(font: Font): boolean { + const f = font as unknown as { characterSet?: number[] }; + try { + return isIconCharacterSet(Array.isArray(f.characterSet) ? f.characterSet : []); + } catch { + return false; + } +} + +/** + * Detect an icon font from glyph coverage. Two conditions must BOTH hold: + * 1. it lacks a real Latin alphabet (< 26 of A-Za-z) — a text font ships the full alphabet; + * 2. most of its glyphs (> 50%) live in a Unicode Private Use Area. + * The Latin gate is essential: some text fonts pack thousands of PUA glyphs yet are plainly text — + * Apple's SF Pro (81% PUA, full A-Za-z, ships SF Symbols in the PUA), Descript's Booton (50% PUA, + * full A-Za-z). Flagging those by PUA ratio alone strips a brand's real typeface. Measured icon + * fonts: "hushly" 63% PUA / 7 letters, Font Awesome 95% PUA / 0 letters. Exported for testing. + */ +export function isIconCharacterSet(characterSet: number[]): boolean { + if (!characterSet.length) return false; + const isLatinLetter = (cp: number) => (cp >= 0x41 && cp <= 0x5a) || (cp >= 0x61 && cp <= 0x7a); + if (characterSet.filter(isLatinLetter).length >= 26) return false; // has an alphabet → a text font + const inPua = (cp: number) => (cp >= 0xe000 && cp <= 0xf8ff) || (cp >= 0xf0000 && cp <= 0x10fffd); + return characterSet.filter(inPua).length / characterSet.length > 0.5; +} + /** Aggregate per-file entries into per-family summaries — most useful shape for DESIGN.md. */ // fallow-ignore-next-line complexity function aggregateFamilies(files: FontFileMetadata[]): FontFamilySummary[] { diff --git a/packages/cli/src/capture/types.ts b/packages/cli/src/capture/types.ts index 39c3ab45f..150d8a3fc 100644 --- a/packages/cli/src/capture/types.ts +++ b/packages/cli/src/capture/types.ts @@ -189,6 +189,17 @@ export interface ComponentStyle { height: string; } +export interface StatCellStyle { + background: string; + borderRadius: string; + border: string; + boxShadow: string; + /** the large numeral's type */ + numberFontSize: string; + numberFontWeight: string; + numberColor: string; +} + export interface DesignStyles { typography: TypographyRole[]; spacing: { @@ -200,6 +211,12 @@ export interface DesignStyles { buttons: ComponentStyle[]; cards: ComponentStyle[]; nav: ComponentStyle | null; + /** pill / badge / chip / tag — small rounded labelled elements */ + chips?: ComponentStyle[]; + /** metric / KPI cells (a large numeral + label) */ + statCells?: StatCellStyle[]; + /** tab controls */ + tabs?: ComponentStyle[]; } // ── Assets ──────────────────────────────────────────────────────────────────