Skip to content
Merged
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
104 changes: 102 additions & 2 deletions packages/cli/src/capture/designStyleExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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
};
})()`;

Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/capture/fontMetadataExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
canonicalizeFamily,
extractFontMetadata,
inferWeightFromSubfamily,
isIconCharacterSet,
} from "./fontMetadataExtractor.js";

describe("inferWeightFromSubfamily", () => {
Expand Down Expand Up @@ -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);
});
});
40 changes: 40 additions & 0 deletions packages/cli/src/capture/fontMetadataExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -149,6 +155,7 @@ function readSingleFont(fullPath: string, filename: string): FontFileMetadata {
style: "normal",
variationAxes: [],
identified: false,
isIcon: false,
};

try {
Expand Down Expand Up @@ -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[] {
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/capture/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down
Loading